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.
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 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.
