09/Skills

Binding: Scope, Conditions & Trigger

Binding a skill to a target; ownership, priority, conditions and the free activation gate.


A binding answers exactly one question: "whose requests, and which of them, should this skill be written into?" The binding editor uses the panel's SHARED `ScopePicker` — you pick a target, you never type a slug by hand.

#The scope vocabulary

The scope types come from the SAME shared vocabulary as System Prompts and MCP Bindings: `mapping`, `model`, `node`, `group` (shown as "model group" in the panel), `workspace`, `user`, `global`. `global` ignores scope_value and matches every request that reaches this step; for the others an empty value NEVER matches (so it can never accidentally mean "apply to everyone").

The narrowest does NOT win — they all stack
System Prompts and MCP Bindings pick the NARROWEST matching scope. Skills does NOT: EVERY matching binding is selected and they are stacked into a single block. This is deliberate (handing one request several procedure packs is meaningful) — but keep it in mind when you reason about the budget, because one request is matched TOGETHER by the `user` binding that names it, the `group` binding that covers it, and every `global` binding.

#Ownership: who created the binding, not who it applies to

Every binding carries an `owner_username`, but that field does NOT decide who the binding applies to. Only the scope does: when `skill_service._select` matches, it never reads `owner_username` — it calls `scope_matches` and nothing else. The owner does two jobs: it records who created the binding (the audit trail) and it is part of the uniqueness key (`uq_skill_binding_scope`).

`global`
Truly everyone: it matches every request whoever created it — including traffic whose user cannot be resolved (`user`/`workspace` scopes simply do not match there). This is exactly what "apply to everyone" is, and it is one row.
`user`
Targets the user NAMED in `scope_value`. A binding an admin creates for `alice` goes into alice's traffic, not the admin's.
`workspace`
Carries its owner INSIDE its own value: `scope_value` is composite ("{owner}/{slug}") and is compared against the request's "{user}/{slug}". A skill bound to `alice/research` can never leak into bob's workspace of the same name.
`global` is a fleet-wide decision
Because scope is the only decider, a `global` binding takes room from EVERY user's budget. Each target row in the budget panel already counts it in — but before you bind a skill globally, be sure its body is worth carrying for everyone.

Because the uniqueness key includes the owner, two DIFFERENT creators can each write a binding for the same (skill, scope type, scope value) target, and both match. It is still injected once: `_select` de-duplicates per skill and keeps the highest-priority binding.

#Priority and de-duplication

The selected bindings are ordered by `priority` DESCENDING (higher first); ties break on the skill name, then the binding id — the order is deterministic, which is what makes the budget decision (who gets dropped) reproducible. If the same skill matches through several bindings, only the HIGHEST-priority one survives: writing the same body twice wastes context and tells the model "this matters twice".

#`conditions` — the second filter

An optional AND/OR filter layered ON TOP of the scope, using the SAME engine as System Prompts — there is no second condition language. A NULL/empty condition means unconditional. The shape is one level deep: a `match` across groups (`all` | `any`), and inside each group its own `match` and its conditions.

conditions
{
  "match": "all",
  "groups": [
    { "match": "any",
      "conditions": [
        { "source": "header", "key": "x-persona", "op": "equals", "value": "reviewer" },
        { "source": "model",  "op": "contains", "value": "coder" }
      ] }
  ]
}
source
`header`, `model`, `mapping`, `group`, `node`, `user`, `workspace`. `header` requires a `key`; `node` looks at name, code and id together.
op
`equals`, `not_equals`, `contains`, `not_contains`, `regex`, `exists`, `not_exists`.
`/api/chat` and `/api/generate` do not forward the real client headers upstream. `source: header` conditions still work on those endpoints anyway: the evaluation uses the real headers, carried for this calculation only — without touching what goes upstream or what the other subsystems see.

#`trigger` — the free activation gate

A binding is injected only if the last USER message passes this gate. The gate is entirely deterministic: NO LLM, no extra request, no added latency. An empty/NULL trigger (or one that defines neither keywords nor a pattern) means there is no gate — the skill is active on every matching request.

trigger
{ "match": "any", "keywords": ["pdf", "invoice"], "regex": "^/report" }
match
`any` (default) or `all` — both across the keyword test and the regex test, and among the keywords themselves.
keywords
A case-insensitive substring test. The candidate text is deliberately NOT truncated: truncating would silently ignore a keyword at the end of a long message.
regex
The pattern may be at most 512 characters (rejected at save time) and is applied only to the first 4096 characters of the message. Both are catastrophic-backtracking limits: the pattern runs SYNCHRONOUSLY inside the request and the candidate text is entirely client-controlled. An invalid or over-long pattern never drops the request — it counts as no match and is logged.

The text the trigger looks at is the last `user` message; for multimodal content only the text parts are joined. On the `/api/generate` path there is no message list — its equivalent is the `prompt` field. With no candidate at all, a gated trigger does not match.

LLM-based automatic skill selection is deliberately OUT OF SCOPE: it would add a classifier call to every request. Activation is either by scope + conditions (static) or by trigger (deterministic).