Changing Context Mid-Session
An agent's system prompt is fixed for a session: editing it would change every request's prefix and throw away the provider's prompt cache. To tell the model something new while a session runs — an instruction, a notice, a tool it may no longer use — you add a context row instead. Context rows are appended to the session's log, so earlier requests stay byte-identical and stay cached.
Context rows
A context row is a message with role: 'context'. Its kind says what it is:
| Kind | What it carries |
|---|---|
instruction | Something the model should do from now on |
notice | A fact the model should know (a late tool answer, a finished upload) |
tools | Tools switched off (disable) or back on (enable) |
memory | Recalled memories (written by the runtime when memory auto-inject is on) |
You never write the row yourself. You add items, and before each model call the runtime's context pass commits everything new as rows, in one store commit, then calls the model with them. A row is sent to the model on every later call, exactly as stored. Context rows are never shown to users: UI messages, snapshots and the chat stream skip them.
Each row records its source: 'app' (your app and your hooks), 'hook' (the stepContext hook below), 'tool' (a tool), 'memory', 'late-answer'. The source decides how the row is presented to the model: rows from your app and its hooks take a system form, while a tool's rows are presented like a user turn, as a tool's output is.
import { instruction, disableTools, enableTools } from '@helix-agents/core';
instruction('Answer in one sentence.'); // { kind: 'instruction', content: '...' }
disableTools(['web_search']); // { kind: 'tools', disable: ['web_search'] }
enableTools(['web_search']); // { kind: 'tools', enable: ['web_search'] }Adding context from a tool or a hook
Every tool and every agent hook receives ctx.addContext(items). The items are queued for the session and delivered at the next model call, once.
const startUpload = defineTool({
name: 'start_upload',
description: 'Starts an upload',
inputSchema: z.object({ file: z.string() }),
execute: async ({ file }, ctx) => {
await ctx.addContext([instruction(`Do not edit ${file} until the upload finishes.`)]);
return { started: true };
},
});- A tool's items always carry
source: 'tool', whatever the item says. - A hook's items default to
source: 'app'. - An item is validated when it is added: an unknown kind, a
toolsitem that switches__finish__off, or an item over 32 KiB is refused with a typedvalidation_error, and nothing is queued. - Adding is idempotent per call site: if a tool or hook is re-run for the same call, its items are not queued twice.
Keep tool output, documents and web content out of instruction items. An instruction from your app takes a system form, which gives it operator authority; text you did not write should not have it. Put such text in a notice, or leave it in the tool's result.
Adding context from your app
Your app adds items from outside the run with executor.addContext(sessionId, items, { key? }). It works whether the session is idle, running or paused on a client tool: the items wait in the queue and reach the next model call.
import { instruction, disableTools } from '@helix-agents/core';
const { recorded } = await executor.addContext(
sessionId,
[instruction('Answer in French.'), disableTools(['web_search'])],
{ key: `lang-${requestId}` }
);Over HTTP, the CF Durable Object answers POST /context and agent-server mounts POST /context, both with the body { sessionId, items, key? } and the answer { recorded } (see agent-server and runtime-cloudflare). Neither route authorizes the caller for you: check it in your app (the worker in front of the DO, or agent-server's authenticate), as for your other routes.
A key names one request. Pass a key when you may retry a call:
- a retry with the same key and the same items records nothing (
{ recorded: 0 }); - the same key with other items (one changed, or more or fewer) is refused whole with a typed, non-retryable
state_context_key_conflict(HTTP 409), and nothing is written.
So retry with exactly the items you sent, and send a new request under a new key. Without a key, every call is a new request and records its items again.
Items get source: 'app' unless they name their own; 'hook' is reserved for the stepContext hook. An invalid item is a validation_error (HTTP 400) and a session that does not exist is state_session_not_found (404); nothing is recorded on any refusal. A tool's or a hook's ctx.addContext keeps first-wins instead of the request rule: a re-executed body repeats its keys by design, so its repeats record nothing and are never refused.
Switching tools off and on
A tools item changes the agent's active tools from the next model call on. The tool list the model is offered does not change (so the request prefix stays cached); instead, a call to a tool that is switched off is answered with a typed result and never runs:
- no tool body, no
beforeTool/afterToolhooks; - no approval request, no client-side ask, no sub-agent or companion started;
- the call's result is a not-executed tool result with reason
tool_inactive(switched off) ortool_unavailable(the agent does not declare that tool at all), so the conversation stays valid and the model can carry on.
The active set is folded from the session's log, so it follows the log through a rewind or a branch.
Who may switch tools on is restricted: only rows from your app and its hooks (source: 'app') and from the stepContext hook (source: 'hook') enable a tool. A tool may switch tools off, never on. __finish__ can never be switched off, and during forced completion the completion tool is always callable, even if a row switched it off.
A switch-off reaches an approval that is already waiting. If a call is waiting for its approval and the tool is switched off before the approval is answered (by a tool in the same step, a hook, or your app), an approval of that call does not run it: it gets tool_inactive. The approval answers "may this call run", not "override the switch". This holds even though the switch-off is still queued (it becomes a row at the next model call): the approval step reads the queued items too.
The stepContext hook
For context that comes from outside the session — a feed, a ticket queue, a document that changed — give the agent a stepContext hook. The runtime calls it before every model call (each step's first attempt, a retried attempt, and a forced completion call), and commits what it returns as context rows.
const agent = defineAgent({
name: 'support-agent',
systemPrompt: 'You help with support tickets.',
llmConfig,
stepContext: async ({ sessionId, cursor, abortSignal }) => {
const since = typeof cursor === 'number' ? cursor : 0;
const updates = await ticketUpdates(sessionId, { since, signal: abortSignal });
if (updates.length === 0) return; // nothing new: no row
return {
items: updates.map((u) => ({ kind: 'notice', content: `Ticket ${u.id}: ${u.summary}` })),
cursor: updates[updates.length - 1].sequence,
};
},
stepContextLimits: { maxBytes: 32_768, timeoutMs: 5_000 },
});The cursor. Return a cursor with your items, and the next call receives it back. The cursor is stored on the rows it came with, so it is always the cursor of the last batch that reached the log. Return only what is new since the cursor you were given: the hook runs before every model call, and a hook that returns the same text each time adds a row each time.
Limits and failures. A batch (its cursor included) is limited to maxBytes (default 32 KiB) and the hook to timeoutMs (default 5 s; abortSignal fires at the limit). A hook that throws, times out or returns something that is not a valid result is skipped: no row is written, the cursor does not move, the run continues, and the runtime writes a context_skipped stream chunk (dropped by the AI SDK transformer, so a chat UI never sees it) and logs core.context.skipped. A batch over the size limit keeps the items that fit, in order, followed by one notice saying how many were left out; its cursor still advances, so the hook is not handed the same batch forever.
Rewinds and branches
Context rows are ordinary rows of the log:
- A rewind (
retry()to a checkpoint,resume({ mode: 'from_checkpoint' })) truncates them like any other row. A queued item whose row was truncated is delivered again, once; thestepContextcursor reverts to the last surviving row, so the hook produces again from there; memory is recalled again; the active tools are folded from what is left. A switch-off or instruction a tool queued goes with that tool's call: if the rewind removes the call, its item is not delivered again (the rewind undoes the call's state writes the same way); if the call is kept, it is. - A
retry()after a step committed keeps the context rows of the step it re-runs. A run that failed on its first step is retried from its start, so that step's rows are re-made instead (queued items delivered again, the hook called again from the cursor before them), once each: never lost, never doubled. - A branch copies the rows it copies, with their cursor and their effect on the active tools, but never the source session's queue: an item still queued in the source is delivered only there.
- A step lost after its context rows were committed (a crash during the model call) keeps them; the recovering step's hook starts from their cursor.
Memory and late answers
With memory auto-inject on, recalled memories reach the model as a memory context row, committed before the call. A recall too large for the store's message limit commits an empty memory row (so it is not recalled again each step) and a context_skipped chunk. A client-executed tool's answer that arrives after its deadline is delivered as a notice row (source: 'late-answer') at the next model call.
Runtime support
The context pass, the tool gate, stepContext, ctx.addContext and the app's executor.addContext run on runtime-js and the Cloudflare Durable Object runtime. On CF Workflows, Temporal and DBOS they are not wired yet:
executor.addContextrejects with a typed, non-retryableframework_not_supported(and agent-server'sPOST /contextanswers it);- an agent (or any agent in its tree) that sets
stepContextis refused at entry with a typedframework_not_supported, before anything is written; ctx.addContextrejects with a typedframework_not_supported(in a tool, the call fails with that error). DBOS's lifecycle and suspension hooks have no context methods at all (see Hooks), so therectx.addContextis absent, likegetState.
They are never silently ignored.