Agent Development Kit · Build
Context: what the model actually sees
An agent's context is a list of functions, not a string. Before every model call
the ADK runs that list over the session ledger and hands the result to the provider. The
ledger itself is never sent. This chapter makes the difference inspectable: the cells below
record each render as it happens and print it, with no API key.
src/context/
Step 1
A context is a pipeline of renderers
A context renderer takes a RenderContext and returns one. The agent's
context array is applied in order, each renderer seeing what the previous one
produced. app.context.system(…) appends a system event;
app.context.history() appends the session's events; a filter drops some again.
Because a renderer is an ordinary function, one that returns its input unchanged is a
tap — a way to see the finished render. That is the whole trick this page uses.
import { adk, type Agent } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'
const app = adk()
// One entry per model call, in the order the calls happen.
const renders: Array<{ messages: string[]; tools: string[] }> = []
// A renderer that changes nothing and records everything: the exact list the provider is about
// to serialize. `app.context(fn)` is the escape hatch — raw RenderContext in, RenderContext out.
const tap = app.context((ctx) => {
renders.push({
messages: ctx.events.flatMap((e) =>
e.type === 'system' || e.type === 'user' || e.type === 'assistant' || e.type === 'thought'
? [`${e.type}: ${e.text}`]
: e.type === 'tool_call'
? [`tool_call: ${e.name}(${JSON.stringify(e.args)})`]
: e.type === 'tool_result'
? [`tool_result: ${JSON.stringify(e.result)}`]
: [],
),
tools: ctx.functionTools.map((tool) => tool.name),
})
return ctx
})
const allergies = app.tool({
name: 'lookup_allergies',
description: 'Look up the recorded allergies for a patient',
schema: z.object({ patient: z.string() }),
execute: (ctx) => ({ patient: ctx.args.patient, allergies: ['penicillin'] }),
})
const triage = app.agent({
name: 'triage',
model: openai('gpt-5.6-luna'),
context: [
app.context.system('You are a triage nurse. Check the allergy record before advising.'),
app.context.history(),
tap,
],
tools: [allergies],
})
triage.context.length
Three renderers, and the tap is the last one, so it sees the final result. Drop
app.context.history() from that array and the agent sees no conversation at all —
the session would still be recording every word. Nothing reaches the model except through this
list.
Step 2
The ledger is stored; the context is rendered
The session ledger is append-only and permanent. The context is neither — it is rebuilt from
scratch before every model call, so a tool-using turn renders twice. Run the same
agent with a scripted model and compare the three things below: what accrued in the ledger,
what the two renders contained, and what each model_start event recorded.
import { isModelStartEvent } from '@animahealth/adk'
import { runTest, user, model } from '@animahealth/adk/testing'
renders.length = 0 // so this cell prints this run, however many times you press Run
const consult = await runTest(triage, [
user('Can I take amoxicillin?'),
model({
thought: 'Check the record first.',
toolCalls: [{ name: 'lookup_allergies', args: { patient: 'ada' } }],
}),
model('Your record lists penicillin, and amoxicillin is a penicillin. Avoid it.'),
])
const seam = {
ledger: consult.events.map((e) => e.type),
modelStarts: consult.events.filter(isModelStartEvent).map((e) => ({
step: e.stepIndex,
messageCount: e.messageCount,
tools: e.tools.map((t) => t.name),
})),
firstRender: renders[0],
secondRender: renders[1],
}
seam
The first render is the system message and the question. The second render is that plus the thought, the tool call, and the tool's real result — the model gets its own decision back as input, which is how a tool-using turn resolves. Neither render is stored anywhere; both were derived from the ledger and thrown away.
model_start is a receipt, not a transcript. It records
stepIndex, messageCount, a tools list of names and
descriptions, and the outputSchema name — the shape of the render, not
its text. That is deliberate: prompts carry the sensitive payload and the ledger already
holds every message event separately. When you need the exact text, tap the render as the
cells here do.
Note also what app.context.history() copies: the session slice whole,
bookkeeping events included. Only message-shaped events — system,
user, assistant, thought, tool_call,
tool_result — are serialized into a prompt, and messageCount counts
exactly those. That is why the ledger list above is longer than the message counts beside it.
Step 3
Scopes and filters: paying for less
history() takes a scope that decides which invocations are visible —
it matters once an agent hands off to another. Filters then run after history and
shrink what it produced, so order in the array is the whole meaning:
pruneReasoning() before history() would prune an empty list.
history({ scope }) |
Includes |
|---|---|
'direct' (default) |
This invocation, plus every root invocation — the ordinary conversation. An invocation reached by an isolated handoff sees only itself. |
'all' |
Every event in the session, unfiltered. |
'invocation' |
Only this invocation's events. |
'ancestors' |
This invocation, its parent chain, and the root invocations. |
'agent' |
That same lineage, narrowed to user events, this agent's own events, and assistant
events from the agents named in agents: […].
|
Now the filters. This agent prunes reasoning and keeps only the two most recent prompt events. Watch what falls out of the window on the second call.
const terse: string[][] = []
const briefTriage = app.agent({
name: 'brief_triage',
model: openai('gpt-5.6-luna'),
context: [
app.context.system('Be brief.'),
app.context.history(),
app.context.pruneReasoning(),
app.context.selectRecent(2),
app.context((ctx) => {
terse.push(ctx.events.map((e) => e.type))
return ctx
}),
],
tools: [allergies],
})
const trimmed = await runTest(briefTriage, [
user('Can I take amoxicillin?'),
model({
thought: 'Check the record first.',
toolCalls: [{ name: 'lookup_allergies', args: { patient: 'ada' } }],
}),
model('Avoid it.'),
])
const filtered = { ledger: trimmed.events.map((e) => e.type), rendered: terse }
filtered
On the second call the user's actual question has fallen out of the window and the model is
answering from a tool result alone. That is the cost of a small
selectRecent, and it is why the filter counts only prompt events and re-attaches
a tool_call whose tool_result survived the cut — a result without
its call is a request providers reject outright. It also lifts system events to the front, so
instructions are never the thing that gets trimmed.
Step 4
Which tools the model may pick, this turn
Tools belong to the agent, but availability is per-render.
limitTools(names) sets allowedTools on the context and
toolChoice(choice) overrides the agent's default — both for this call only, and
both are ordinary renderers, so they can be conditional on state.
const gates: Array<{ tools: string[]; allowedTools: string[]; toolChoice: string }> = []
const escalate = app.tool({
name: 'escalate_to_gp',
description: 'Escalate the question to a GP',
schema: z.object({ reason: z.string() }),
execute: (ctx) => ({ escalated: true, reason: ctx.args.reason }),
})
const gatedTriage = app.agent({
name: 'gated_triage',
model: openai('gpt-5.6-luna'),
context: [
app.context.system('Look up the allergy record before doing anything else.'),
app.context.history(),
app.context.limitTools(['lookup_allergies']),
app.context.toolChoice('required'),
app.context((ctx) => {
gates.push({
tools: ctx.functionTools.map((t) => t.name),
allowedTools: [...(ctx.allowedTools ?? [])],
toolChoice: JSON.stringify(ctx.toolChoice),
})
return ctx
}),
],
tools: [allergies, escalate],
})
const gatedRun = await runTest(gatedTriage, [
user('Can I take amoxicillin?'),
model({ toolCalls: [{ name: 'lookup_allergies', args: { patient: 'ada' } }] }),
model('Avoid it.'),
])
const gateView = {
rendered: gates[0],
modelStartTools: gatedRun.events.filter(isModelStartEvent).map((e) => e.tools.map((t) => t.name)),
}
gateView
Read that result carefully, because it is the one thing about
limitTools that surprises people: both tool definitions are still in
functionTools, and both still appear in model_start.
limitTools does not delete a tool — it narrows the choice. The OpenAI
adapter turns allowedTools into a tool_choice of type
allowed_tools, folding in toolChoice as the mode:
// what serializeToolChoice(ctx.toolChoice, ctx.allowedTools) produces for the render above
{
"type": "allowed_tools",
"mode": "required",
"tools": [{ "type": "function", "name": "lookup_allergies" }]
}
Other adapters express the same constraint in their own dialect. If you need a tool to be genuinely invisible — absent from the token count as well as the choice — leave it off the agent, or give the agent a narrower peer.
Step 5
Typed prompt fragments
Pass a function instead of a string to system or user and it is
rendered per call against the app's state schema. The argument carries state,
typed from that schema — session keys are readable at the top level and by scope — and
outputSchema, the agent's output type already rendered as text you can drop into
a prompt. A typo in a state key is a compile error, not a blank in a prompt.
const clinic = adk({
schema: {
session: {
patientName: z.string(),
lastVisit: z.string(),
},
},
})
const systemTexts: string[] = []
const briefing = clinic.agent({
name: 'briefing',
model: openai('gpt-5.6-luna'),
context: [
clinic.context.system(
(c) => `Brief the clinician on ${c.state.patientName}, last seen ${c.state.lastVisit}.`,
),
clinic.context.history(),
clinic.context((c) => {
for (const e of c.events) {
if (e.type === 'system') systemTexts.push(e.text)
}
return c
}),
],
})
// One pre-1.0 rough edge: `runTest` declares its runnable with the erased state schema, so a
// schema-typed agent needs a widening cast to reach it. The run itself is unchanged.
const briefed = await runTest(
briefing as unknown as Agent,
[user('Summarise the last visit.'), model('Ada was seen in March for a repeat prescription.')],
{ initialState: { session: { patientName: 'Ada Lovelace', lastVisit: '2026-03-02' } } },
)
const typedView = { systemTexts, status: briefed.status }
typedView
The state was seeded on the session, and the system message was written at render time from the values that were true then. Change the state mid-run and the next turn's system message changes with it — the prompt is a projection of state, the same way the message list is a projection of the ledger.
Reference
The whole vocabulary
Everything app.context offers. Each returns a renderer; put them in the agent's
context array in the order you want them applied.
| Renderer | Effect on the render |
|---|---|
app.context(fn) |
Your own function over the RenderContext. Everything below is built from
this.
|
.system(text | fn) |
Append a system event. A function is rendered per call against typed state. |
.user(text | fn) |
Append a user event the reader never typed — a preamble, a retrieved document. |
.cacheableUser(text) |
The same, tagged for provider prompt caching. The tag alone enables nothing; the model config must also turn caching on. |
.history(options) |
Append the session's events for the given scope. See the table above.
|
.transform(fn, options) |
Rewrite the text of user events in place. options.at chooses which state
a state-aware transform sees: 'message', 'invocation', or
'current'.
|
.pruneUserMessages(name) |
Drop user events tagged with an agent's name; 'self' means this agent's
own.
|
.selectRecent(n) |
Keep the n most recent prompt events, plus every system event and any
tool_call a surviving tool_result needs.
|
.pruneReasoning() |
Drop thought events — usually the cheapest token saving available. |
.limitTools(names) |
Narrow which of the agent's tools the model may choose this call. |
.toolChoice(choice) |
'auto', 'none', 'required', or
{ name }, overriding the agent's setting for this call.
|
Text agents render synchronously. A renderer that returns a promise — an embedding lookup, a database read — throws when the agent's context is built. Fetch outside the render and pass the result in as state, or do the retrieval in a tool.
Where this goes next: the ledger these renders read from is the subject of
Sessions and state, the outputSchema a typed prompt can
quote comes from Structured output, and the scripted model
driving every cell on this page is Testing agents without a model.