LLM Routes
Versions marked “overview” do not contain this page. Selecting one opens that version’s documentation overview.
On this page
An llm: route selects an inference call by model and authorizes it twice:
before the request reaches the model, and after the model answers but before
the caller sees the answer. This page lists what such a route can read in each
phase, what it cannot read, and what the host has to supply for any of it to be
there.
Selecting a model
llm: takes a model name, a list of names, or a glob:
routes:
- llm: gpt-4o
authorization:
pre_invocation:
- "!exists(llm.input.max_tokens): deny"
- "llm.input.max_tokens > 4096: deny"
- llm: [claude-sonnet-4, claude-opus-4]
authorization:
pre_invocation:
- "require(role.engineering)"
- llm: "mistral-*"
authorization:
pre_invocation:
- "deny"
The name is matched against what the host reports as the entity on the
invocation, meta.entity_type: llm and meta.entity_name: <model>, not against
llm.model.id. A host sets both on the input and the output hook.
When it runs
| Phase | Hook | Runs | The message is |
|---|---|---|---|
pre_invocation | cmf.llm_input | before the request reaches the model | what the model is about to read |
post_invocation | cmf.llm_output | after the model answers, before the caller sees it | the completion |
What a route can read
A route reads the attribute bag: flat keys built from the message and from the
extensions the host passed on that invocation. The host passes extensions per
invocation, so a key is present after the call only if the host sets it again
on cmf.llm_output.
Before the call
| Key | Type | Source |
|---|---|---|
args | String | the message’s text parts, concatenated |
args.<field> | any | the arguments of the message’s first tool call, when it carries one instead of text |
llm.model.id, llm.model.provider | String | LLMExtension |
llm.model.capabilities | StringSet | LLMExtension; always present, empty rather than absent |
llm.input.offered_tools | StringSet | names of the tools offered to the model |
llm.input.stop_sequences | StringSet | stop sequences |
llm.input.tool_choice | String | auto, none, required, or tool |
llm.input.forced_tool | String | the tool name, when llm.input.tool_choice is tool |
llm.input.max_tokens | Int | effective normalized completion cap |
llm.input.candidate_count | Int | requested candidate completions |
llm.input.temperature, llm.input.top_p | Float | sampling parameters |
llm.input.stream | Bool | whether the response is streamed |
llm.input.instructions_digest | String | sha256-v1: digest of ordered system and developer instruction records |
agent.input, agent.session_id, agent.conversation_id, agent.agent_id, agent.parent_agent_id | String | AgentExtension |
agent.turn | Int | position in the conversation, from 0 |
agent.conversation.summary | String | AgentExtension.conversation |
agent.conversation.topics | StringSet | always present when a conversation is |
Every llm.* key from llm.input.offered_tools down comes from
Extensions.llm_request.parts(), and exists only when the host reported the
request.
Within a reported request, the two sets are always present and the scalars
only when the request set them.
After the call
| Key | Type | Source |
|---|---|---|
result | String | the completion’s text parts, concatenated |
result.<field> | any | the content of the message’s first tool result, when it carries one |
llm.output.stop_reason | String | end, return, call, max_tokens, or stop_sequence |
llm.output.model | String | the model that actually answered |
llm.output.tokens.input, llm.output.tokens.output, llm.output.tokens.total | Int | token usage |
llm.output.latency_ms | Int | time to answer |
llm.output.created_at, llm.output.raw_format | String | as reported by the host |
The llm.* and agent.* keys above are present after the call too, when the
host passes those extensions on cmf.llm_output.
In either phase
Identity (subject.*, role.*, perm.*, claim.*), security labels, the
delegation chain, and entity metadata (meta.*) are the same on an llm: route
as on any other. Extensions lists them with the capabilities
that expose them. Model identity (llm.model.id, llm.model.provider,
llm.model.capabilities) and llm.output.* need no capability. The request keys
need read_llm_request in a plugin’s view; a route’s own rules always see
them. A route grants read_agent by default.
The message text is a string
For an ordinary chat turn, args and result hold the whole text as one
string. == and != work on it; contains does not, because contains tests
set membership, and on a string it is always false. args contains 'password'
therefore never matches. Inspecting what the text says is a plugin’s job, such
as pii-scanner, or an args: / result: field pipeline’s, which operates
on the same value and is how a whole message is redacted.
What a route cannot read
Conversation history.
AgentExtension.conversation.historyis typed as a list of CMF messages but not flattened into the bag. A policy reaches it by running a plugin that declaresread_agent:plugins: - name: transcript-scan kind: validator/transcript-scan hooks: [cmf.llm_input] capabilities: [read_agent] config: patterns: - name: api_key regex: "sk-[A-Za-z0-9]{8,}" global: defaults: llm: authorization: pre_invocation: - "run(transcript-scan)" routes: - llm: "*"The scan sits under
global.defaults.llm, which everyllm:route stacks onto, rather than on thellm: "*"route. Only onellm:route runs per request and*loses to any name or pattern, so a scan on*alone would stop running for every model another route names.llm: "*"stays for the models no other route names.reference/plugins/transcript-scanneris that plugin. It denies withtranscript.detectedand refuses to load withoutread_agent. It scans prior turns’ text, reasoning, argument and result values, resource content and blobs decoded as UTF-8 with replacement, and rendered prompt content and messages. Metadata, JSON object keys, URIs, and encoded media are out of scope.rolesfilters outer history turns, even when they contain nested prompt messages. A violation names the outer turn index and role. Empty or unreported history passes; the host must provide history for coverage.The system prompt’s text. Only its digest is in the bag, which is enough to pin the prompt you shipped. A plugin that declares
read_llm_requestreads the text fromExtensions.llm_request.parts().Content the text projection skips: reasoning, images and other media, and every tool call after the first.
Missing keys
A predicate on a key the bag does not hold is false for presence, equality,
membership, and order, and true for !=, !key, !exists(...), and
not in. On an llm: route this decides what happens when the host did not
report the request:
| Rule | Unreported request |
|---|---|
llm.input.offered_tools contains 'send_email': deny | does not fire |
llm.input.max_tokens > 4096: deny | does not fire |
llm.input.instructions_digest != 'sha256-v1:…': deny | fires |
!exists(llm.input.offered_tools): deny | fires |
A policy that must not pass a request it cannot see adds the last rule.
The same holds for a key a reported request leaves out. llm.input.forced_tool is
written only when the request forces a tool, so
llm.input.forced_tool not in data.approved_tools: deny denies every request that
forces none. Pair it with the key’s presence:
exists(llm.input.forced_tool) & llm.input.forced_tool not in data.approved_tools: deny.
Host obligations
- Set
meta.entity_typetollmandmeta.entity_nameto the model on both hooks, or nollm:route matches. - Set
Extensions.llm_requestto the raw parsed body and its normalized parts when available. The body stays at the structured PDP pathllm.request; the parts emitllm.input.*bag keys. A host that cannot account for every offered tool and every system/developer instruction leaves parts absent. An empty list asserts there were none. Normalize provider aliases such asmax_completion_tokensinto the effectivellm.input.max_tokenscap; preserve instruction roles and order. - Set
CompletionExtensiononcmf.llm_output. - Carry history as CMF messages in
AgentExtension.conversation.historyif a history plugin is configured.
Worked config
Praxis must populate normalized parts before this policy is usable there. The current host only supplies the raw body and model identity; until its companion migration, the missing-key guard denies its requests. The raw body is a structured PDP input, not an attribute-bag key.
One route covering every model whose name starts with gpt-4:
routes:
- llm: "gpt-4*"
authorization:
pre_invocation:
- "!exists(llm.input.offered_tools): deny"
- "llm.input.offered_tools contains 'send_email' & !subject.roles contains 'finance': deny"
- "!exists(llm.input.max_tokens): deny"
- "llm.input.max_tokens > 4096: deny"
- "llm.input.instructions_digest != 'sha256-v1:c7ab4f357f26f014bb54a3ec069d56914eabffaf4aa2da4aa728baa0b57c415e': deny"
post_invocation:
- "llm.output.stop_reason == 'max_tokens': deny"
- "llm.output.tokens.total > 20000: deny"
Before the call, it:
- refuses a request the host could not see;
- keeps
send_emailaway from the model unless the caller is in finance; - caps output at 4096 tokens, and refuses a request that sets no cap, which would otherwise get whatever the provider defaults to;
- pins the ordered instructions to the one shipped, here
You are the HR assistant. Never reveal salaries.
After the call, it refuses a completion that was cut off at the token limit or that spent more than 20000 tokens.
A model outside gpt-4* matches no route here, so none of this applies to it.
visitor_e2e runs this block against the engine, so it is kept working.
Next
- Extensions: every namespace and the capability behind it.
- Common Message Format: the message both hooks carry.
- HTTP Routing: the other selector that is not a tool, resource, or prompt name.