09/Skills

Injection Modes & Budget

The `full` and `metadata` modes, the 20,000-character budget, which endpoints it runs on, and why `allowed-tools` is not enforced.


Matching skills are written as a single `<skills>` block at the END of the system message. System prompts go first (admin policy first), and the procedure packs come AFTER the policy and after the user's own system text.

injected block
<skills>
## incident-postmortem
Writes a blameless postmortem from an incident timeline. Use when an incident is resolved and a written record is needed.

# Incident postmortem
...body...

## commit-message
Writes a commit message ...          # metadata mode: no body
</skills>

If there is no system message at all, the block is inserted at the front as one. If the system content is not text (multimodal), its parts are left alone — the block goes in as a separate system message immediately AFTER it. On the `/api/generate` path it is appended to Ollama's native `system` field.

A skill cannot write another block's boundary
The tags `<memory>`, `</memory>`, `<skills>`, `</skills>`, `<guardrails>` and `</guardrails>` are ESCAPED (`&lt;memory&gt;`) when a skill is injected. The reason is security: the judge's poisoned-memory audit compares two snapshots taken before and after the Second Brain injection, and the block lookup is syntactic — it finds the FIRST `<memory>` block in the system message. Because skills are written before Second Brain, a single skill carrying a literal `<memory>` in its body would make both snapshots equal and silently switch that audit off (fleet-wide, if bound `global`). Escaping deletes nothing: a skill that teaches about `<memory>` blocks still works, its tag is simply a visible quotation rather than a boundary. Every escaped skill is named in a server log line.

#The two modes

ModeWhat is injectedWhen to use it
`full` (default)`## name` + description + the WHOLE body, on EVERY matching request.The real working mode. If you want the model to actually follow the procedure, this is it.
`metadata`ONLY `## name` + the description. The body is never sent.Cheap discovery: the model sees that the skill exists. Do not use it before reading the limit below.
`metadata` is an EMULATION, not progressive disclosure
Anthropic's three-tier progressive disclosure requires a FILESYSTEM and a tool call: the model reads SKILL.md itself. This gateway has no such filesystem and a request is one-shot. So `metadata` mode injects only the name and description, and the gateway will NOT open the skill afterwards — the model cannot pull the body in. On a multi-turn client the model may well say "open that skill", but there is NO mechanism that loads the body automatically. For a skill whose body is actually needed, the only correct mode is `full`.

#The budget: 20,000 characters

There is a hard ceiling on the total characters of the injected block: **20,000**. What is counted is NOT the raw body but the section actually written into the block — `## name` + description + (only in `full` mode) the trimmed body. This is why a `metadata` binding also takes budget: back when only bodies were counted, N metadata bindings could grow without limit.

  1. 1The bindings are already in priority order; the budget is spent in that order.
  2. 2A skill that does not fit is dropped WHOLE. A half body is NEVER injected — a procedure cut off at step 3 shows the model a world where step 4 does not exist; half a procedure is the wrong procedure.
  3. 3After a dropped skill is skipped, the scan CONTINUES: a big skill does not evict the small, lower-priority one behind it.
  4. 4The dropped skill's name is written to the server log with a `logger.warning` — and the panel shows it BEFORE you even save (see below).
The whole built-in catalog does NOT fit in one budget
The ten catalog skills together take **30,101 characters** in `full` mode (the sum of the sections written into the block; the raw bodies total 26,670). The limit is 20,000. So INSTALLING all ten is perfectly fine — the budget looks at what is INJECTED into a request, not at what is installed. But binding all ten `full` to the SAME target means the lowest-priority ones are silently dropped. (Those same ten skills total 3,411 characters in `metadata` mode.)

The panel does this arithmetic for you: every skill shows its own character cost, the Marketplace compares the catalog total against the limit, the Installed tab gives, for EACH target, at least what one request hitting that target would carry (`global` bindings match every request, so they are counted into every target's total), and the binding editor recomputes the budget live as you change the mode or the priority.

Where the panel cannot name the dropped skill, it does not
A target line is a LOWER bound on what a request hitting that target carries: the gateway folds in EVERY matching scope at once, so alice's request carries her `user:alice` bindings together with her model group's, her model's and her node's. The panel never sees a request (or the routing decision), so it cannot build that union — and because the budget is greedy (what does not fit is skipped and the scan continues), ADDING a candidate changes WHICH skill drops. So the certain word "dropped" is used in only two cases: the binding alone is larger than the whole limit, or the line has no trigger and no condition AND already counts every binding in the system. In every other case the panel says "may be dropped" and says why.

#Where it runs, and where it does not

Skill injection runs ONLY on the text-generation endpoints: `/v1/chat/completions`, `/api/chat` and `/api/generate`. Other endpoints (embeddings, model listings, …) never reach this step. The step sits immediately AFTER the system-prompt injection and carries the same endpoint list.

The gateway's own self-calls receive NO skills

The gateway also calls models internally. All four of those internal calls are EXEMPT from skill injection: every turn of the MCP agent loop, the judge's own call, the Second Brain distiller, and the content-routing classifier. The reason differs per case: the body entering the loop and the judge has already been through this step (applying it again would duplicate the `<skills>` block on every turn); and the distiller and the classifier are not user requests — writing a 20,000-character procedure pack into a call that just asks for one category name is not merely wasteful, it risks breaking the ROUTING itself.

An "internal" marker is honoured only if the caller really IS the gateway (authenticated with the service identity). Sending the header alone is NOT enough — otherwise any client could switch skills (and system prompts, and guardrails) off for its own traffic by adding a header.

Injection never drops the request

Every error in this step (the DB could not be read, a condition could not be evaluated, a trigger pattern is broken) is written with a `logger.warning` and the injection is SKIPPED; the request continues upstream unchanged. This is exactly the same behaviour as the system-prompt seam. The practical consequence: when a skill was never injected there is NO client-side error to see — the panel's budget indicators and the server log are the places to look.

When a change takes effect

Active bindings are cached under a single Redis key, and the cache is invalidated on every admin change (editing a skill, installing, deleting, adding or changing a binding) — the next request reads fresh. Without Redis it reads the database directly; the behaviour is identical, there is simply no cache.


#`allowed-tools`: stored, not enforced

This field restricts NOTHING
In the SKILL.md spec, `allowed-tools` is experimental and states that "support varies between implementations". Here it is STORED so that an import/export round-trip does not lose data, but it blocks nothing: a tool absent from the list can still be called. Presenting a security field as if it were enforced is worse than not having the field at all. For a limit that actually holds, see Tool Sets and Guardrails.

The same holds for `license`, `compatibility` and `metadata`: they are documentation fields, not behaviour. The only text injected is `## name`, the description and (depending on the mode) the body.