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").
#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.
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.
{
"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`.
#`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.
{ "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.