Skip to content

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:

KindWhat it carries
instructionSomething the model should do from now on
noticeA fact the model should know (a late tool answer, a finished upload)
toolsTools switched off (disable) or back on (enable)
memoryRecalled 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.

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

typescript
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 tools item that switches __finish__ off, or an item over 32 KiB is refused with a typed validation_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.

typescript
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 / afterTool hooks;
  • 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) or tool_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.

typescript
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; the stepContext cursor 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.addContext rejects with a typed, non-retryable framework_not_supported (and agent-server's POST /context answers it);
  • an agent (or any agent in its tree) that sets stepContext is refused at entry with a typed framework_not_supported, before anything is written;
  • ctx.addContext rejects with a typed framework_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 there ctx.addContext is absent, like getState.

They are never silently ignored.

Released under the MIT License.