agent
import { agent } from '@routecraft/ai'
Run an LLM with a fixed system prompt on each incoming exchange. Replaces the body with AgentResult { text, usage? }. Two forms:
- Inline (
agent({ model, system, user? })) -- identity and description come from the enclosing route (.id(),.description()). Suitable when the route is the agent. - By name (
agent("summariser")) -- resolves a registered agent from the context. Register agents viaagentPlugin({ agents: { name: {...} } })(agentPluginreference).
import { agent, agentPlugin } from '@routecraft/ai'
import { readFileSync } from 'node:fs'
// Inline: the route IS the agent. Other routes call it via direct("zoe").
craft()
.id('zoe')
.description('Internal ops assistant')
.from(direct())
.to(agent({
model: 'anthropic:claude-opus-4-7',
system: readFileSync('./prompts/zoe.md', 'utf-8'),
}))
.to(direct('reply'))
// By name: register once, use from any route in the context. Per-agent
// fields can be omitted when defaultOptions supplies them.
agentPlugin({
defaultOptions: {
model: 'anthropic:claude-opus-4-7',
},
agents: {
summariser: {
description: 'Summarises documents into bullet points',
system: 'Be concise.',
// model inherited from defaultOptions
},
},
})
craft()
.id('periodic-summary')
.from(timer({ interval: 60_000 }))
.to(agent('summariser'))
.to(log())
Model ID format: "provider:model-name" (same as llm()). The provider must be registered via llmPlugin({ providers: {...} }). There is no inline-credentials escape hatch on agent({...}); centralised wiring via llmPlugin is the only path.
Supported providers: openai, anthropic, ollama, openrouter, gemini, lmstudio, custom
AgentOptions (inline form):
The last seven are the same sampling block llm() takes, and they mean the same thing here: one agent can run a narrow classification at reasoning: 'none' while another names the same model and asks for 'high'. An agent that sets none of them behaves exactly as it did before they existed. They can be set once for every agent in a context via agentPlugin({ defaultOptions }), where a per-agent value wins per key, and markdown agents declare them in frontmatter like any other field.
They apply to every model call in a run: the first turn, each tool-calling turn, and each validate retry. A run that defers and resumes resolves them again from the context it resumes in rather than replaying what was resolved before it deferred, so a changed defaultOptions takes effect on the resumed turns.
AgentRegisteredOptions (entries in agentPlugin({ agents: {...} }), for by-name reuse): same as AgentOptions plus:
The id is the record key in agentPlugin({ agents: { [id]: {...} } }).
AgentByNameOverrides (the second argument of agent("name", { ... })): per-call fields, which are the ones that belong to the message rather than to the agent. A per-call value wins over the registered one.
Result shape (body is replaced by .to()):
Sessions: an agent that remembers
Without session, an agent run is one turn from a fresh prompt and nothing is remembered. With it, one route serves as many conversations as there are session ids, and each is a record in the store rather than a process: kill the instance, start it again, and the next message continues where the last one stopped.
const MaxMessage = z.object({
session: z.string(),
message: z.string(),
interrupt: z.boolean().optional(),
});
type MaxMessage = z.infer<typeof MaxMessage>;
agentPlugin({
agents: {
max: {
description: 'Max, the development agent',
system: readFileSync('agents/max.md', 'utf-8'),
user: (ex) => (ex.body as MaxMessage).message,
maxTurns: 200,
tools: tools(['sandbox-run', 'github-create-pr']),
},
},
})
// One route, many conversations. The instance is a record in the store, not a process.
craft()
.id('max')
.input({ body: MaxMessage })
.from(direct())
.to(agent<MaxMessage>('max', {
session: (ex) => ex.body.session,
interrupt: (ex) => ex.body.interrupt === true,
}))
// A webhook continues the same conversation: the branch name carries the session id.
craft()
.id('github-hook')
.input({ body: DeploymentStatus })
.from(http({ path: '/hooks/github', signature: github }))
.filter((ex) => ex.body.deployment_status.state === 'success')
.transform((body) => ({
session: body.deployment.ref.replace('max/', ''),
message: `Preview is up: ${body.deployment_status.environment_url}`,
}))
.to(direct('max'))
The transcript. Per message the adapter loads the transcript stored under the session id, appends the message, runs the turn with the agent's registered options, and stores the transcript back. A session the store has never seen starts empty. Records live in the context's session store: SQLite at .routecraft/sessions.db by default, created by the first session written, or whatever sessions: { store } names, including a SessionStore of your own. A store failure is AI1012. The continuation a turn stores between turns is a deferred exchange and lives in the deferral store, so a context without that block refuses session with RC5052. The model is told which conversation it is in through a ## Session block appended to the system prompt, so a tool that takes a session id (a per-session workspace, say) can be handed it. Because the id reaches that prompt and keys the record, it is bounded before it reaches either: 1 to 128 characters of letters, digits, ., _, : or -, starting with a letter or digit, and RC5003 otherwise, so a value taken from a message that carries other characters (a branch name with a slash, a subject with an @) must be normalised onto that shape before dispatch; the runtime refuses, it does not map. The agent recorded on the session is the agent's registered name, or the route id for an inline agent({ session }), and it never changes for the life of that session.
One turn at a time. A message that arrives while the session's turn is running does not start a second turn. It goes to the session's inbox, its caller gets session.status === 'queued' with an empty text, and it is delivered at the next turn boundary: when the running turn ends, whatever queued becomes the next user message, in arrival order, several messages as one message with the parts in order, and that turn runs on its own. The reply to a queued message belongs to the turn that consumed it and reaches the transcript, not the caller. (An editor talking over ACP is the exception: the mount holds its request open until that turn has replied, because the editor is showing the message and waiting.) The inbox is durable: it lives in the same record as the transcript.
The boundary turn runs on the route. A turn that ends with work outstanding, messages queued or a background tool still running, stores its exchange's continuation: the body and the headers, exactly as a .defer() deferral stores them, at the agent step, after the caller has its reply (route:agent:session:deferred). The boundary turn is that continuation revived on the running process (route:exchange:resumed, then route:agent:session:revived): the agent step runs with the inbox as its user message, and the route's steps after the agent run on the reply, so a queued message or a finished build reaches whatever the route does with a reply. What does not run again is the route's pre-from chain: no .authorize(), .input(), .throttle() or .timeout() of the route applies to a revived turn. The revived turn runs under the exchange that stored the continuation: its headers, its principal, its tools. One continuation per session; a turn that ends with work outstanding while one is stored keeps it. The transcript and the inbox never live in the continuation, only in the session record. An agent step that sits where a deferral cannot be revived from (inside a .split()) has no continuation: queued messages then run in process on the route that just ended, and a completion waits for the next message.
Interrupt. interrupt: true cancels the running turn at its next checkpoint through the ordinary cancellation path, so a tool in flight sees its abort signal. The interrupted turn's partial transcript is kept, including the call that was running and an error result saying it did not finish, so the model knows what it was doing. The interrupted caller gets session.status === 'interrupted' rather than a failure; the interrupting caller waits for the turn that starts with the queued messages plus theirs and gets its reply.
Restart. A turn running when the process stops is treated as interrupted: what it persisted (every finished step) is kept, the messages it had not consumed stay in the inbox, and the next message for the session starts a turn that closes any open tool call as interrupted and reports each background call the dead process was waiting on as lost. A session that had stored a continuation is not left waiting for that message: at boot, after the routes are live, the dead process's background calls become lost-run messages in its inbox and the continuation is revived, so the loss reaches the model as a turn. The context's own shutdown aborts a running turn the same way it aborts any agent run (AI1005 to that caller), after persisting what the turn reached. A message that reaches an idle session once shutdown has begun is queued rather than run, and waits in the inbox for the next process. A continuation the previous process stored but died before naming on the session record is released at the next boot, so a crash between those two writes never leaves a deferral nothing references.
Limits. maxTurns bounds one turn, not the conversation; there is no compaction, so a long enough conversation ends in AI1009. session does not combine with stream: true (RC5003): a stream is handed over before the turn ends, and the turn boundary is where the transcript is stored. A session turn is not deferrable: a tool that calls ctx.defer() inside one fails the turn. The one-turn bound is per process: two instances sharing one store are not coordinated, and a turn marker set by a live sibling is read as one a restart cut short, so run one process per store. A session with no stored continuation is not resumed on boot; its next message drains the inbox. A stored continuation carries no deadline: it waits for the completion, however long the run takes, and shows on the session's summary until then. Records never expire and the transcript has no bound; retiring a session or trimming its transcript is not built.
Who a turn runs as, and who said what. A turn runs under the principal of the exchange it runs on: the message's own exchange when that message starts the turn, and the deferred exchange when a revival starts it. The runtime never switches principal mid-turn and never picks one for a queued message, so a tool guard reading ctx.principal sees the turn's caller, whoever queued the text. What the model and the transcript see is who said it: every inbox item records the subject of the principal that posted it (null when the exchange carried none, so a caller whose subject is literally anonymous is still named), and the delivered message renders that per part as a bracketed line, [Message from "bob"], in the same quoted-data shape blocks use, so it is something the model reads and never something it obeys. A background result names who started the call the same way. The session record keeps who the conversation belongs to as owner: the subject of the principal whose turn opened it, written once and never transferred by a later speaker. It is shown on the management API, and it IS a gate on a protocol surface such as acpPlugin(), where a listing carries only the caller's own conversations and a session owned by somebody else answers exactly as one that does not exist. Inside a route it gates nothing: who may post is still the route's .authorize().
Who may post to a session is the route's own .authorize(), in one of two shapes. A session that must be one caller's derives its id from the principal, so no two callers share one:
agent('max', { session: (ex) => `${ex.principal?.subject}:${ex.body.session}` })
The id is a retrieval handle, so it has to be known to be retrieved, the same way a deferral is resumed by its token. A hardcoded id (session: 'support') is therefore a deliberate choice rather than a mistake: every invocation of that route continues one conversation, on one transcript that grows for as long as the route runs. Derive the id from the message when each caller, thread or ticket should get its own.
A session several principals share on purpose (a webhook, a mail reply and a terminal all posting into one conversation) keeps the id as the caller names it and restricts posting by role or scope at the route, and reads the attribution to tell them apart. A runtime allowlist of subjects per session is not built.
Every session is listable on the management API at GET /ops/agent-sessions, with its turn state and inbox depth, and an editor speaking the Agent Client Protocol is a client on this: see Talk from your editor.
Streaming the reply
stream: true replaces that result with an AgentStream, an async iterable of AgentDelta handed over before the run starts. Iterating it is what starts the run, so the first next() is where the model begins writing. The http source frames an async iterable as Server-Sent Events, so a token-by-token endpoint is one declarative route:
craft()
.id('chat-stream')
.input({ body: ChatInput })
.from(http({ path: '/chat/stream', method: 'POST' }))
.to(
agent({
system: aria,
user: (ex) => (ex.body as z.infer<typeof ChatInput>).message,
stream: true,
}),
);
The queue and the abort that closes it live inside the adapter. Iterating the stream is what drives the run, and abandoning it aborts that run, so a client that disconnects mid-answer stops the model. Custom SSE event names are one ordinary .transform() step away; a queue and a wake loop written by hand in a transform are not the way there.
The trade is what a stream cannot carry. text, output, usage and toolCalls are consolidated when a run finishes, and a stream is handed over before that. A route that needs both sends the stream to its reader and takes the completed record from the coarse route:agent:* events. Under .enrich() the stream merges like any other enrichment value. See the agent plugin's observability channels for the full comparison with onDelta.
Resolution semantics:
agent("name")only resolves registered agents. To call a route-backed agent from another route, use.to(direct("route-id")).directruns the full pipeline of the target route;agent("name")runs the registered agent's LLM call inline.- Model resolution at dispatch is
instance value > defaultOptions.model > throw RC5003. - Duplicate registered agent ids, missing description, malformed model string when present, or a non-
ToolSelectiontoolsvalue fail at context init withRC5003(Adapter misconfigured). - Referencing an unknown registered agent name fails at dispatch with
RC5004(No handler available).
Provider credentials are configured once in llmPlugin() and shared across all agent() calls. See llmPlugin reference.
Telling the agent who the caller is
By default the only part of the exchange that reaches the model is the body (as the user prompt). The authenticated caller (exchange.principal) is not in the prompt, so the model does not know who it is serving unless you put that there yourself.
Set principal: true to append a ## Caller section to the system prompt. It is appended after your own prompt and any blocks, and it covers the unauthenticated case explicitly so the model never invents an identity:
agent({
model: 'anthropic:claude-opus-4-7',
system: 'You are a support assistant.',
principal: true,
});
When the request is authenticated, the model sees:
## Caller
The current request is authenticated.
- Name: Jane Doe
- Email: jane@example.com
- Subject: user_2a9f
- Roles: admin, editor
When there is no principal:
## Caller
The current request is not authenticated. No verified user identity is
available. Do not assume, infer, or invent the caller's name, email, or
permissions.
Only the loggable identity fields (name, email, subject) and roles are surfaced; fields that are absent on the principal are omitted, and interpolated values have newlines collapsed so a subject-controlled field (a self-service display name, say) cannot forge prompt structure. Scopes, claims, userinfoClaims, and the bearer token are never injected. The block is informational context only: authorization is still enforced by .authorize() and tool guards, never by the model.
To control the wording or which fields are shown, pass a function instead of true. It receives the principal (undefined when unauthenticated) and the exchange, and returns the markdown to append (return '' to append nothing). Your renderer owns its own escaping and the same field exclusions apply:
agent({
model: 'anthropic:claude-opus-4-7',
system: 'You are a support assistant.',
principal: (p) =>
p ? `## Caller\n\nYou are assisting ${p.name ?? p.subject}.` : '',
});
To opt every agent in a context into caller-awareness at once, set principal on agentPlugin({ defaultOptions }); a per-agent principal (including false) overrides it.
Inside a tool handler, the same principal is available as ctx.principal (a deep-frozen, read-only snapshot).