Skip to content
cccskills
FreePublished on GitHub

routir

Use RoutIR — call a running endpoint with the client, stand up a local server that mixes locally-hosted services with services proxied from a remote master server, and extend RoutIR with new bi-encoders, rerankers, and document collections by writing a small config (and optionally one Python file referenced via `file_imports`). No checkout of the routir source is required.

The English summary is being prepared. Showing the original description.

Works withRedisOpenAI
See how to install

Files included (113)

  • SKILL.md22.3 KB
  • .github/workflows/publish-release.yml3.5 KB
  • .github/workflows/sphinx.yml1.0 KB
  • .gitignore336 B
  • CLAUDE.md15.1 KB
  • docs/Makefile638 B
  • docs/routir.png50.5 KB
  • docs/source/api/config.rst347 B
  • docs/source/api/models.rst1.1 KB
  • docs/source/api/pipeline.rst333 B
  • docs/source/api/processors.rst1.2 KB
  • docs/source/api/serve.rst200 B
  • docs/source/api/utils.rst341 B
  • docs/source/architecture.rst1.1 KB
  • docs/source/changelog.rst2.0 KB
  • docs/source/conf.py2.9 KB
  • docs/source/contributing.rst622 B
  • docs/source/getting_started.rst9.9 KB
  • docs/source/index.rst1.3 KB
  • examples/CLAUDE.md15.7 KB
  • examples/client_tutorial.ipynb13.8 KB
  • examples/gemma_example_config.json542 B
  • examples/me5-instruct_example_config.json694 B
  • examples/pyserini_example_config.json388 B
  • examples/pyserini_extension.py1.3 KB
  • examples/pyterrier_example_config.json408 B
  • examples/pyterrier_extension.py1.4 KB
  • examples/qwen3_example_config.json1.1 KB
  • examples/rank1_example_config.json207 B
  • examples/rank1_extension.py14.0 KB
  • examples/vllm_qwen3reranker_extension.py7.1 KB
  • LICENSE1.0 KB
  • pyproject.toml2.5 KB
  • README.md18.8 KB
  • scripts/build_proto.sh786 B
  • scripts/webui.py74.3 KB
  • src/routir/__init__.py241 B
  • src/routir/client/__init__.py399 B
  • src/routir/client/client.py12.9 KB
  • src/routir/client/grpc.py12.9 KB
  • src/routir/client/rest.py5.7 KB
  • src/routir/client/sync.py2.9 KB
  • src/routir/client/transport.py1.7 KB
  • src/routir/collections/__init__.py943 B
  • src/routir/collections/indexing/__init__.py287 B
  • src/routir/collections/indexing/offset_file.py11.9 KB
  • src/routir/collections/indexing/sidecar.py5.6 KB
  • src/routir/collections/indexing/tar_index.py6.7 KB
  • src/routir/collections/indexing/warmup.py8.0 KB
  • src/routir/collections/processor.py7.4 KB
  • src/routir/collections/relay.py3.8 KB
  • src/routir/collections/views/__init__.py333 B
  • src/routir/collections/views/abstract.py1.9 KB
  • src/routir/collections/views/local_path.py3.6 KB
  • src/routir/collections/views/tar.py7.4 KB
  • src/routir/collections/views/text_jsonl.py2.7 KB
  • src/routir/config/__init__.py287 B
  • src/routir/config/config.py25.7 KB
  • src/routir/config/load.py19.2 KB
  • src/routir/grpc_interceptors.py2.7 KB
  • src/routir/models/__init__.py1.5 KB
  • src/routir/models/abstract.py24.8 KB
  • src/routir/models/fusion.py3.6 KB
  • src/routir/models/llm_engine.py16.1 KB
  • src/routir/models/lsr.py2.9 KB
  • src/routir/models/mt5.py7.7 KB
  • src/routir/models/plaidx.py12.8 KB
  • src/routir/models/qwen3.py8.6 KB
  • src/routir/models/qwen3reranker.py4.5 KB
  • src/routir/models/relay.py5.6 KB
  • src/routir/models/st.py9.6 KB
  • src/routir/pipeline/__init__.py171 B
  • src/routir/pipeline/aliases.py3.5 KB
  • src/routir/pipeline/cache.py1.5 KB
  • src/routir/pipeline/parser.py13.9 KB
  • src/routir/pipeline/pipeline.py24.3 KB
  • src/routir/processors/__init__.py1.7 KB
  • src/routir/processors/abstract.py12.0 KB
  • src/routir/processors/cache.py8.3 KB
  • src/routir/processors/content_processors.py426 B
  • src/routir/processors/query_processors.py3.2 KB
  • src/routir/processors/registry.py9.6 KB
  • src/routir/processors/relay_content.py387 B
  • src/routir/processors/score_processors.py2.0 KB
  • src/routir/proto/__init__.py71 B
  • src/routir/proto/_generated/__init__.py0 B
  • src/routir/proto/_generated/routir_pb2_grpc.py11.3 KB
  • src/routir/proto/_generated/routir_pb2.py9.9 KB
  • src/routir/proto/_generated/routir_pb2.pyi10.6 KB
  • src/routir/proto/routir.proto3.6 KB
  • src/routir/serve.py19.6 KB
  • src/routir/servicer.py10.9 KB
  • src/routir/utils/__init__.py3.9 KB
  • src/routir/utils/evaluate_pipeline.py3.3 KB
  • src/routir/utils/extensions.py3.0 KB
  • src/routir/utils/faiss_indexing.py11.8 KB
  • src/routir/utils/file_io.py496 B
  • src/routir/utils/qwen3_encode.py5.2 KB
  • tests/__init__.py0 B
  • tests/_bytes_engine.py891 B
  • tests/_trivial_engine.py604 B
  • tests/conftest.py4.9 KB
  • tests/test_client_transports.py7.6 KB
  • tests/test_collection_views.py12.3 KB
  • tests/test_grpc_bytes_passages.py4.0 KB
  • tests/test_local_path_backend.py13.4 KB
  • tests/test_modality_validation.py19.9 KB
  • tests/test_pipeline_views.py13.3 KB
  • tests/test_proto_nodiff.py2.7 KB
  • tests/test_registry_submit.py2.8 KB
  • tests/test_sidecar_fallback.py12.9 KB
  • tests/test_tar_backend.py17.7 KB
  • tests/test_tar_gz_policy.py8.4 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

RoutIR skill

RoutIR is an HTTP/gRPC service that hosts retrieval engines (dense, sparse, rerankers, fusion, query expanders) behind a uniform API and composes them into multi-stage pipelines with a small DSL. This skill covers the five things you actually do with it:

  1. Call a running RoutIR endpoint with the client.
  2. Stand up your own RoutIR server, optionally importing services from a master server so users can pipeline local + remote engines together.
  3. Wrap a bi-encoder retrieval index.
  4. Wrap a cross-encoder reranker.
  5. Serve document collections (single- or multi-view).

Run everything through uvx. This project forbids bare pip, python, pytest, ruff. Every command below uses uvx so the deps land in a throwaway venv instead of your conda environment.

The full reference for extending RoutIR with Python lives in examples/CLAUDE.md; pipeline / DSL / config reference in the project CLAUDE.md. This skill is the operational overview — read those when you need depth.


1. Use the client (and the pipeline DSL)

routir.client.Client is the sync facade (auto-uses gRPC when the server advertises it, falls back to REST). AsyncClient is the async version with the same surface.

from routir.client import Client

with Client(endpoint="http://compute01:5000", api_key="…optional…") as c:
    print(c.avail())                          # what's available
    print(c.transport)                        # "grpc" or "rest"

    # search a single hosted index
    r = c.search(service="qwen3-neuclir", query="…", limit=20)
    # r["scores"] -> {doc_id: float}

    # score a list of passages against a query
    r = c.score(service="my-reranker", query="…", passages=["p1", "p2", …])
    # r["scores"] -> [float, float, …]   (one per passage)

    # fetch document content
    r = c.content(collection="neuclir", id="doc-id-123", view="asr")
    # r["text"] or r["bytes"]

    # run a composed pipeline (the main entry point)
    r = c.pipeline(
        pipeline="{dense%1000, bm25%1000}RRF%100 >> rerank@asr%20",
        query="…",
        collection="neuclir",                 # needed when a stage reranks
        runtime_kwargs={"rerank": {"some_engine_kwarg": 0.5}},  # optional
    )

Endpoint scheme rules: http(s)://… → REST; grpc(s)://… → gRPC; bare host:port is treated as http:// and may auto-upgrade to gRPC if the server advertises grpc_port via /avail. Pass transport="rest" to force REST.

Pipeline DSL — quick reference

DSL formMeaning
svc%NCall svc, keep top N.
A%1000 >> B%20Sequential: A retrieves 1000, B reranks down to 20. B gets role rerank.
{A%K, B%K}Merger%NParallel: A and B run concurrently; Merger.fuse_batch fuses to top N.
Expander{A%K, B%K}Merger%NExpander makes sub-queries; each runs A and B in parallel; all fused.
svc[alias]%NName a stage so runtime_kwargs can target it by alias.
svc@view%NPick a named view of the collection at rerank time (multi-modal).

Built-in mergers: RRF (reciprocal rank fusion) and ScoreFusion are always available. You only need to write your own fusion engine if you need something neither covers.

Aliases: pipeline_aliases in the server config let you give a long pipeline a short name ("ragtime2": "{zho%100, rus%100, …}ScoreFusion"); the alias is then usable everywhere a service name is. The call-site %N re-caps the alias's outer-most stage.

Bound the result set. /pipeline ignores a top-level limit; the result-set size is whatever the final %N produced. If your pipeline has no %N, you'll get the full inner result.

scripts/query.py is the canonical batch-query script that writes the JSONL run format documented in CLAUDE.md. Copy it when scripting evaluations.


2. Stand up a local server (and import a master server)

Minimal my-config.json:

{
    "services": [
        { "name": "my-bm25", "engine": "PyseriniBM25",
          "config": {"index_path": "/path/to/bm25-index"},
          "cache": 1024, "cache_ttl": 600,
          "batch_size": 16, "max_wait_time": 0.05 }
    ],
    "collections": [
        { "name": "my-corpus", "doc_path": "/data/corpus.jsonl" }
    ],
    "server_imports": [
        "http://master-host:5000"
    ],
    "file_imports": [
        "./my_extension.py"
    ]
}

server_imports is the master-import mechanism. At startup the local server queries /avail on every entry and registers a Relay-backed local proxy for every remote search / score / content service it doesn't already host. After that, your users can write pipelines that freely mix local and remote services as if they were all on one box:

{my-bm25%1000, remote-qwen3%1000}RRF%100 >> remote-cross-encoder%20

Notes that bite people:

  • Local services win on name collision. Remote services with a name already registered locally are skipped, not overridden.
  • Each entry can be a dict with endpoint, grpc_endpoint, api_key, transport, etc., not just a bare URL — handy when the master is gRPC.
  • Cache relayed content. Reranking against a remote collection re-fetches every doc per query without a cache. Set "relay_content_cache": 4096 (and relay_content_cache_ttl) at the top level of the config, or wire a Redis URL.

Serve it:

# REST only, port 5000
uvx --with "routir[dense] @ ." \
    routir my-config.json --port 5000

# REST + gRPC; add --with for every runtime dep your engines need
uvx --with transformers --with torch --with "routir[dense,grpc] @ ." \
    routir my-config.json --port 5000 --grpc --grpc-port 50051

# With auth (prefer env over --api_key; CLI args show up in ps)
ROUTIR_API_KEY=sekret uvx --with "routir @ ." routir my-config.json --port 5000

Verify it works:

curl http://localhost:5000/avail        # lists search/score/fuse/collection
curl http://localhost:5000/ping         # always unauthenticated; for liveness

/avail is also what server_imports uses for discovery — if your service doesn't appear there, no one else will see it either.

Per-service knobs in services[] that you'll routinely tune:

keywhat it does
cacheLRU capacity for this service's results. -1 (default) disables.
cache_ttlTTL in seconds. Applies to LRU and Redis.
cache_key_fieldsRequest fields that go into the cache key. Default ["query", "limit"]; add "subset" etc. when they affect results.
cache_redis_urlUse Redis instead of in-memory LRU.
batch_sizeMax requests batched into one engine call. Default 32.
max_wait_timeMax seconds to wait for a batch to fill. Default 0.05; raise for throughput, lower for latency.
scoring_disabledSet to true to refuse to register /score for an engine that can score, when you only want search.

3. Wrap a bi-encoder with an index

Default path: write zero Python. RoutIR ships Qwen3 and SentenceTransformerEngine and they cover the majority of dense models purely through config. Reach for a custom engine only when neither fits.

3a. Zero-code: external query encoder via OpenAI-compatible API (preferred)

This is the right answer almost always — it keeps the query encoder on its own GPU/process (vLLM, llama.cpp, sglang, …), so RoutIR doesn't have to share VRAM with it and you can scale the encoder independently.

Run your query encoder as an OpenAI-compatible /v1/embeddings server (vLLM, llama.cpp, sglang, TEI — any of them) and point RoutIR at it:

{
    "services": [
        {
            "name": "qwen3-neuclir",
            "engine": "Qwen3",
            "cache": 1024, "cache_ttl": 600,
            "batch_size": 32, "max_wait_time": 0.05,
            "config": {
                "index_path": "/path/to/faiss-index",
                "embedding_base_url": "http://gpu-host:8000/v1/",
                "embedding_model_name": "Qwen/Qwen3-Embedding-8B",
                "api_key": "…or set OPENAI_API_KEY env…",
                "k_scale": 5
            }
        }
    ]
}

Likewise SentenceTransformerEngine covers ME5-Instruct, ArcticEmbed, BGE-M3, Jina-v3, etc. by setting embedding_model_name, instruction, prompt_name_query, task_query, normalize_embeddings. The full key list is on the class in src/routir/models/st.py.

index_path is a directory with index.faiss + index.ids (one doc id per line, in the same order as FAISS vectors). The hfds:<org/repo> prefix auto-downloads from Hugging Face Datasets at startup (e.g. "index_path": "hfds:routir/neuclir-qwen3-8b-faiss-PQ2048x4fs").

3b. Run the query encoder in-process (less ideal)

If you must (e.g. small model, no spare encoder host, sharing VRAM is fine), either drop embedding_base_url from the configs above — both engines fall back to local transformers/sentence-transformers — or write a tiny custom engine if neither fits. The cost is real: the query encoder now contends with everything else this RoutIR process is doing.

If you do need to write your own, use file_imports to ship one .py without touching the routir checkout:

# my_extension.py
from typing import Dict, List
import faiss, numpy as np
from routir.models.abstract import Engine

class MyBiencoderEngine(Engine):
    def __init__(self, name=None, config=None, **kwargs):
        super().__init__(name, config, **kwargs)
        self.encoder = load_my_query_encoder(config["model"])
        self.index = faiss.read_index(f"{config['index_path']}/index.faiss")
        with open(f"{config['index_path']}/index.ids") as f:
            self.doc_ids = [ln.strip() for ln in f]

    async def search_batch(self, queries: List[str], limit=1000, **kwargs) -> List[Dict[str, float]]:
        if isinstance(limit, int):
            limit = [limit] * len(queries)
        q_emb = self.encoder.encode(queries)                    # (N, d)
        scores, idx = self.index.search(q_emb.astype(np.float32),
                                        k=max(limit) * 2)
        return [
            dict(list(zip([self.doc_ids[i] for i in idx[qi]], scores[qi].tolist()))[:k])
            for qi, k in enumerate(limit)
        ]
{
    "file_imports": ["./my_extension.py"],
    "services": [{
        "name": "my-dense",
        "engine": "MyBiencoderEngine",
        "config": {"model": "org/name", "index_path": "/path/to/index"},
        "batch_size": 16, "max_wait_time": 0.05
    }]
}

The class name in engine: must match the Python class name exactly — that's how lookup works. There is no registration boilerplate.

If your engine can also score query-passage pairs cheaply (true for dense models — it's just q @ p.T), implement score_batch on the same class too and a /score endpoint registers automatically. See Qwen3.score_batch for the canonical implementation.

Building the FAISS index is out of scope here — produce index.faiss + index.ids any way you like and point index_path at the directory.


4. Wrap a reranker as a scorer

A reranker is an Engine that implements score_batch. The input shape is intentionally flat-with-strides and trips everyone up the first time, so read this section before you write the method.

The contract

async def score_batch(
    self,
    queries: List[str],                  # N queries
    passages: List[str],                 # flattened over ALL queries
    candidate_length: List[int] = None,  # passages-per-query strides
    **kwargs,
) -> List[List[float]]:                  # one score list per query
  • passages is not grouped per query — it's one flat list across the whole batch. candidate_length[i] says how many consecutive passages belong to queries[i]. So sum(candidate_length) == len(passages) and len(candidate_length) == len(queries).
  • The return value is grouped: one list of floats per query, in passage order, higher = more relevant.
  • candidate_length=None defaults to [len(passages)] (all passages belong to a single query).

The canonical implementation skeleton — copy this

# my_reranker.py — ship via file_imports
from typing import List
from routir.models.abstract import Engine

class MyReranker:
    """Sync model wrapper. Knows nothing about RoutIR or async."""
    def __init__(self, model_path: str, batch_size: int = 32):
        self.model = load_my_model(model_path)
        self.batch_size = batch_size

    def score(self, pairs):
        # pairs: List[Tuple[str, str]] of (query, passage)
        # return: List[float] of the same length, higher = more relevant
        out = []
        for i in range(0, len(pairs), self.batch_size):
            out.extend(self.model.predict(pairs[i:i + self.batch_size]))
        return out


class MyRerankerEngine(Engine):
    """RoutIR adapter. Implements score_batch ONLY."""

    def __init__(self, name=None, config=None, **kwargs):
        super().__init__(name, config, **kwargs)
        self.model = MyReranker(
            model_path=config.get("model", "org/reranker"),
            batch_size=config.get("batch_size", 32),
        )

    async def score_batch(self, queries, passages, candidate_length=None, **kwargs):
        if candidate_length is None:
            candidate_length = [len(passages)]
        assert len(candidate_length) == len(queries)
        assert sum(candidate_length) == len(passages)

        # 1) expand queries so each passage has its query alongside it
        expanded = [queries[i] for i, n in enumerate(candidate_length) for _ in range(n)]
        pairs = list(zip(expanded, passages))

        # 2) score all pairs at once (sync call inside async is fine for GPU work)
        flat = self.model.score(pairs)

        # 3) regroup back into per-query lists, in original passage order
        out, cursor = [], 0
        for n in candidate_length:
            out.append(flat[cursor:cursor + n])
            cursor += n
        return out

Config:

{
    "file_imports": ["./my_reranker.py"],
    "services": [{
        "name": "my-reranker",
        "engine": "MyRerankerEngine",
        "config": {"model": "org/reranker", "batch_size": 32},
        "cache": 1024, "cache_ttl": 600,
        "batch_size": 16, "max_wait_time": 0.05
    }]
}

Smoke test:

curl -X POST http://localhost:5000/score -H 'Content-Type: application/json' \
  -d '{"service":"my-reranker","query":"q","passages":["p1","p2","p3"]}'
# -> {"scores": [...], ...}

In a pipeline, the reranker is the second stage:

my-bm25%1000 >> my-reranker%20

The pipeline runs my-bm25 to get 1000 candidates, fetches each candidate's text from the collection's /content endpoint, then calls your score_batch(queries, flattened_passages, candidate_length) and keeps the top 20.

Gotchas

  • Don't override score (the singular one). The base class implements it as a one-element call to score_batch. Overriding both is a known legacy footgun.
  • Return floats in the original passage order. The pipeline pairs your output back to candidate ids positionally — don't sort inside score_batch.
  • Fallbacks beat crashes. If a single pair errors, return 0.5 (or any neutral score) for it rather than blowing up the whole batch.
  • Truncate to your model's context. Long passages will OOM or get silently clipped by tokenizers.
  • Bytes/multimodal rerankers (keyframes, audio): set accepts_view_kind = "bytes" as a class attribute. Then passages is List[List[bytes]] (one inner list per doc, possibly empty / multi-blob). REST /score refuses bytes engines — reach them via pipeline DSL or gRPC Score. See examples/CLAUDE.md for the full multimodal contract.
  • If candidates aren't supplied by the pipeline (i.e. you're hitting /score standalone or the engine should run its own first-stage), look at the Reranker base class in src/routir/models/abstract.py — it implements search_batch for you via an upstream_service + text_service. Most users only need score_batch and the pipeline supplies candidates.

5. Serve a document collection (single- and multi-view)

Collections power /content and feed text/bytes to reranker stages. Every collection has one or more named views; a view picks a backend (JSONL, tar, local files) and a modality (text or bytes).

Single text view (the common case)

Either form works. The "legacy" form is still fully supported and is the shortest path.

{
    "collections": [
        { "name": "my-corpus", "doc_path": "/data/corpus.jsonl",
          "id_field": "docid", "content_field": "text" }
    ]
}

Equivalent modern form (use this for anything non-trivial):

{
    "collections": [{
        "name": "my-corpus",
        "views": {
            "text": {
                "kind": "text",
                "source": {
                    "source": "text_jsonl",
                    "doc_path": "/data/corpus.jsonl",
                    "id_field": "docid",
                    "content_fields": ["title", "body"],
                    "sep": "\n"
                }
            }
        }
    }]
}

JSONL access is O(1): RoutIR builds a .offsetmap sidecar on first open. Random access then uses byte offsets.

Multi-view

{
    "collections": [{
        "name": "multivent",
        "default_view": "asr",
        "views": {
            "asr": {
                "kind": "text",
                "source": {"source": "text_jsonl",
                           "doc_path": "/data/mv.jsonl",
                           "id_field": "chunk_id",
                           "content_fields": "asr_transcript"}
            },
            "keyframe": {
                "kind": "bytes",
                "source": {
                    "source": "tar",
                    "tar_template": "/data/mv/shard_{shard:06d}.tar",
                    "shard_resolver": {
                        "kind": "manifest",
                        "path": "/data/mv/catalog.csv",
                        "id_column": "chunk_id",
                        "shard_column": "shard_index"
                    },
                    "matcher": {"kind": "glob", "pattern": "{id}.kf_uni5s.t*.jpg"},
                    "mime": "image/jpeg",
                    "cache_dir": "/expscratch/me/routir/.cache"
                }
            }
        }
    }]
}

Pipeline stages pick a view with @view:

{asr-search%1000, kf-search%1000}RRF%200 >> kf-reranker@keyframe%20

The kf-reranker stage gets the keyframe view's bytes; asr-reranker (if you added one) would @asr and receive strings.

default_view is used when a stage omits @view. Required to be set explicitly when there are multiple views; auto-elected when there's only one.

View backends

source:KindUse for
text_jsonltextOne JSONL file (optionally gzipped). The default.
local_pathbytesOne-or-many files per id (path_template or path_glob).
tarbytesSharded .tar archives with a member-name matcher.

For tar collections, the shard resolver decides which tar holds a given id: manifest (CSV/TSV id,shard), modulo (hash-based), or substring (slice of the id is the shard token).

Sidecar caches and warmup — important on shared filesystems

tar and text_jsonl backends build sidecar indexes (.taridx, .offsetmap) on first access. Scanning 50–500 K members in a tar on a cold request will stall.

  • Always set cache_dir per view when the dataset mount is read-only (most shared FS). Point at an in-tree ./.cache/ by convention.
  • Pre-build sidecars off the login node with scripts/warmup_slurm.sh <config.json> [view]. One sbatch job per view — never per shard. See CLAUDE.md for the rule and the exact command.

Caching /content

CollectionConfig.cache is 256 entries by default and keys on (view, id) — bytes views especially need this. Crank it up for hot corpora; set to 0 to disable.

For server_importsed remote collections, enable relay_content_cache at the top level of the config; otherwise every reranked candidate triggers a remote fetch.


Where to look when this skill isn't enough

  • DSL grammar and execution model: src/routir/pipeline/parser.py and src/routir/pipeline/pipeline.py. The grammar at the top of parser.py is short and authoritative.
  • Engine contract: src/routir/models/abstract.py — the docstrings on search_batch, score_batch, decompose_query_batch, fuse_batch are the source of truth.
  • Reference engine implementations: src/routir/models/ (built-ins) and examples/*_extension.py (file-imports pattern).
  • Config schema: src/routir/config/config.py — every JSON field the server accepts is a Pydantic field with a docstring.
  • Detailed extension how-to: examples/CLAUDE.md.
  • Project conventions and warmup: CLAUDE.md.

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

Report a problem with this skill