Instructions
Rule-gated context injection. The Instruction primitive activates a prompt when a predicate matches the current iteration's context.
A user types "my refund still hasn't arrived I'm furious". You want the agent to acknowledge feelings before facts — but only on this kind of message, not every message. That's an Instruction. A predicate matches the iteration; the matching prompt joins the system slot for this turn only. No global state to drift, no separate "rules engine" to maintain.
What an Instruction is
An Instruction is one flavor of the Injection primitive — content that lands in a slot when a trigger matches. For Instructions specifically:
- Slot:
system-prompt(the default — delivered by every provider), ormessageswith a role you name - Trigger:
rule— a predicate(ctx) => booleanyou write - Fires: every iteration of the agent loop, against fresh context
If the predicate returns true, the prompt text is appended to the system slot for that iteration. If it returns false, nothing happens. The predicate re-runs every iteration so the same Instruction can activate on iteration 3 but not on iteration 5 — context is fresh each time.
Defining an Instruction
defineInstruction takes an id, a prompt, and an activeWhen predicate. The predicate receives an InjectionContext with the current userMessage, iteration count, lastToolResult, activatedInjectionIds (ids of skills/injections the LLM activated this turn), and conversation history:
const calmTone = defineInstruction({ id: 'calm-tone', description: 'Calm, empathetic tone with frustrated users.', activeWhen: (ctx) => /upset|angry|frustrated/i.test(ctx.userMessage), prompt: 'The user sounds upset. Acknowledge feelings before facts. Avoid corporate jargon.',});const concise = defineInstruction({ id: 'concise', activeWhen: (ctx) => ctx.iteration === 1, // first iteration only prompt: 'Keep your first response under 3 sentences.',});Two instructions, two predicates. calmTone activates when the user's message contains an upset-sounding word. concise activates only on the first iteration (so follow-up turns can be longer if they need to be). Predicates are pure functions — no side effects, no async, no IO. They run dozens of times per agent.run().
Attaching to an Agent
Once defined, attach with .instruction(...). Multiple instructions stack — each runs its own predicate; matches all land in the system slot in registration order:
const agent = Agent.create({ provider: provider ?? mock({ reply: 'I hear you. Let me help.' }), model: 'mock', maxIterations: 1,}) .system('You are a customer support assistant.') .instruction(calmTone) .instruction(concise) .build();The agent doesn't know there are "instructions" attached — it just sees a system prompt that varies by turn. The agentfootprint.context.injected event fires with source: 'instructions' and the matching id so observability surfaces can show which rules fired when.
The on-tool-return trigger (Dynamic ReAct)
An Instruction whose predicate inspects ctx.lastToolResult is naturally one-shot — fires on the iteration RIGHT AFTER the named tool ran, then the predicate stops matching on the next iteration because lastToolResult will be from a different (or no) tool. This is the on-tool-return trigger pattern from the 4-trigger taxonomy:
const postPii = defineInstruction({ id: 'post-pii', description: 'Brief reminder to use the redacted text, not the original.', activeWhen: (ctx) => ctx.lastToolResult?.toolName === 'redact_pii', prompt: 'Use the redacted text in your reply. Do not paraphrase the original.',});The reminder lands ONLY on the iteration where the LLM is about to read the redacted output. Without this, the LLM sometimes paraphrases the original (defeating the redaction). With it, the LLM is told "use the redacted text" at the exact moment it needs to hear it — a system prompt that says it on the one turn it matters, rather than on every turn. (Modern LLMs attend more strongly to recent messages than to the system prompt; when that difference matters for a rule, put the words in the tool's own return value — see the next section.)
This is the Dynamic ReAct pattern from Shinn 2023's reflection paper — context that adapts mid-loop based on what the agent just observed.
Where the Instruction lands — the slot, and who speaks
An Instruction's prompt joins the system slot by default. Since 7.21.0 it can also
be delivered into the conversation itself — but only with a role you name, and only
where the wire can actually take it.
defineInstruction({
id: 'premium-note',
activeWhen: (ctx) => ctx.userMessage.includes('refund'),
prompt: 'This customer is on the premium plan; refunds are pre-approved under $200.',
slot: 'messages',
role: 'assistant', // required — no default
});Delivered means delivered: the message enters scope.history, the same window the
window strategies govern and the request is built from. There is no second list spliced
in at send time, so the trace, the slot composition, the token count and the wire are
all describing one conversation.
role is required, on purpose
There is no default. Who appears to speak is a meaning your app owns — before 7.19.1
this option defaulted to 'system', which reached the model on OpenAI-family providers
and silently vanished on Anthropic-family ones, because the Anthropic wire has no system
role inside the message list (system is a separate top-level field). Each provider now
declares what it carries, and a role it cannot carry is refused when the run starts,
naming the provider and its roles. The library never quietly re-roles your message to
one that fits.
| provider | carries inside messages |
|---|---|
openai, azure-openai, ollama, browser-openai | system, user, assistant |
anthropic, bedrock, browser-anthropic, gemini | user, assistant |
| a third-party adapter that declares nothing | user, assistant (the floor) |
One honest limitation, stated plainly
A delivered message goes at the END of the window, and providers reject two turns of the
same role in a row. In a tool-using loop the window ends on the user's turn (first
iteration) or on tool results (every iteration after), and tool results count as a user
turn on the strictest wire — so a role: 'user' injection will typically never
deliver inside an agent loop. Use 'assistant', use 'system' on a provider that
carries it, or return the words from the tool.
When a message cannot be placed, it is deferred, not dropped: it waits for the next
iteration boundary, nothing is reordered to make room, and nothing is ever inserted
between a tool call and its result. The reason is committed to
snapshot.sharedState.messagesDelivery.deferred as a sentence — that record is the
answer to "why is my declaration not on the wire?".
const delivery = agent.getSnapshot()?.sharedState.messagesDelivery;
delivery.delivered; // [{ injectionId, role, wireIndex, contentHash }]
delivery.deferred; // [{ injectionId, reason: 'role-collision', note: '…' }]The tool result is still the sharpest tool
An Instruction whose predicate watches ctx.lastToolResult fires on exactly the right
turn — and when the words themselves must sit at the very end of the conversation,
return them from the tool:
defineTool({
name: 'redact_pii',
// …
async execute({ text }) {
return `${redact(text)}\n\nUse the redacted text only. Do not paraphrase the original.`;
},
});A tool result IS a recent message, it needs no role negotiation, and it lands after the assistant's turn every time.
Saying a run-time fact, not just gating on one
The predicate gets an InjectionContext, so an Instruction could always switch
on at action 22. It could never say so: Injection.inject is static data,
which is why defineStepsHint, defineMenuHint and defineRelevanceHint — the
library's own advisory injections — each carry a static body and push their DATA
through a tool description instead. That workaround only works for content the
framework can attach to a tool it owns.
Since 9.57.0 an Instruction can carry a promptTemplate, rendered fresh on
every action:
defineInstruction({
id: 'budget-awareness',
activeWhen: (ctx) => (ctx.iterationsRemaining ?? Infinity) <= 5,
promptTemplate:
'You are on action {{action}} of {{actionBudget}}; {{actionsRemaining}} remain. ' +
'Finish what you have rather than start something new.',
});The model reads "You are on action 25 of 30; 5 remain." This is measured, not decorative: given its remaining budget a model wrote "I have 5 steps left, enough to finish this properly" and landed the task, where before it spiralled and produced no answer at all.
The vocabulary is closed, on purpose
Three words, all about the turn's action budget:
| placeholder | what it renders |
|---|---|
{{action}} | the action about to be taken (1-based) |
{{actionBudget}} | the turn's maxIterations |
{{actionsRemaining}} | how many are left — never negative |
A name outside that set is refused at define time, with the three listed in the error. Nothing renders as a literal in front of a model.
It is a closed vocabulary rather than inject: (ctx) => string because of
absence. Given a function, an author writes ${ctx.maxIterations} and ships
"23 of undefined", or writes ?? 0 and ships a fabricated denominator that
nothing downstream — and no model — can tell from a real zero. With named slots
the library owns absence and applies one rule: if any named fact is
unavailable, the whole instruction is skipped, by name, as
skipped: 'unknown-fact' on agentfootprint.context.evaluated. Never a gap,
never a fake zero, never the placeholder.
Three rules a template lives under
promptorpromptTemplate, never both. They are the same decision spelled two ways, and the library will not pick one for you.- Not
slot: 'messages'. That slot delivers each piece once per run and its ledger keys by the content itself, so a template that renders differently every action would deliver a new message every action, all run. The system-prompt slot is re-composed per action by design, which is exactly what a template needs. - Not cacheable. A cache marker asserts a stable prefix; these bytes change
every call.
cache: 'always'on a template is refused. Because a never-cached injection truncates the cached prefix at its declaration position, declare templated instructions last.
One consequence worth knowing before you look at a dashboard: ContextRecorder
dedups by content hash per run, so a templated Instruction files one
agentfootprint.context.injected per action where a static one files one per
run. That is more truth, not less — but a consumer counting rows will see N times
as many.
The budget facts reach the predicate too
ctx.maxIterations and ctx.iterationsRemaining are on every agent-mounted
evaluation, whether or not you use a template — so activeWhen can gate on how
much room is left rather than on a raw iteration number that means nothing on its
own. They arrive paired: both present, or neither. A count without a
denominator is the fabrication the pairing exists to prevent.
Anti-patterns
- ❌ Don't put dynamic state in the predicate's closure — it's evaluated per iteration, with fresh
ctx. Reading fromctxis correct; capturing alet counterin the closure is racy. - ❌ Don't make
activeWhenasync or side-effecting — it runs many times per turn; latency multiplies. - ❌ Don't combine many predicates into one giant Instruction — register multiple small ones with single-purpose predicates. Easier to reason about, easier to observe (one event per matching id).
- ❌ Don't reach for a template when a static prompt will do — a templated Instruction re-renders (and re-records) every action. Use one when the number is the point, not for decoration.
- ❌ Don't rely on Instruction order for correctness — registration order determines the order of matching prompts in the system slot, but the LLM doesn't strictly read top-to-bottom. If two Instructions could conflict, write one Instruction with the conflict resolved in its prompt.
Next steps
- Skills, explained — context engineering for instructions, taken further (LLM-activated skills + tools)
- Memory guide — for stateful context across runs (Instructions are per-iteration; Memory is cross-run)
- Key concepts — the Injection primitive (
slot × trigger × cache) and how every flavor reduces to it
Graph
graph() runs a fixed DAG of runners. Independent nodes run concurrently, an edge carries the producer's output unchanged, and a broken shape — a cycle, an unknown edge, an un-joined fan-in — is refused at build time.
Compaction
Keep the live context window inside a token budget by folding the oldest turns into a summary — while the commit log keeps every folded turn, byte for byte.
