Context in ADK: The Objects Passed Into Your Tools and Callbacks
ReadonlyContext, CallbackContext, ToolContext, InvocationContext — and why the read-only vs mutable distinction is a feature, not a limitation.
Every callback, tool, and custom agent you write in Google’s Agent Development Kit (ADK) receives a context object as an argument. That object is your single handle onto everything the runtime knows about the current turn: the session and its state, artifacts, memory, and the control actions you can trigger. What makes ADK’s design worth understanding is that it does not hand you one god-object everywhere. It hands you the narrowest context type appropriate to the call site — a read-only view where mutation would be wrong, a richer view where it’s expected. Getting comfortable with this family of types is the difference between fighting the framework and letting it guide you.
The context family
There are four types you’ll meet, arranged from smallest to largest capability:
ReadonlyContext— the minimal, read-only view. You get it in instruction providers: functions that build a prompt string from state without mutating anything.CallbackContext— adds mutable state and artifactload/save. You get it in agent and model callbacks (before_agent,after_model, and friends).ToolContext— the richest of the callback-family types. Adds actions (escalate, transfer, skip-summarization),search_memory, credential requests, and artifact listing. You get it inside tool functions.InvocationContext— the runtime’s own context, holding the livesession, theagent, the wired services (session/artifact/memory), and therun_config. You get it in a custom agent’s run method.
The inheritance is the whole point. A ToolContext is a CallbackContext is a ReadonlyContext, so anything you can do in a narrower context you can also do in a wider one. In google-adk 2.x the narrowing is expressed as a class hierarchy where ToolContext and CallbackContext are the same underlying class (ToolContext is CallbackContext is literally True) subclassing ReadonlyContext. Treat the four names as capability tiers, not four unrelated classes.
Which context do I get where?
| Call site | Python type | Go type |
|---|---|---|
| instruction provider | ReadonlyContext |
agent.ReadonlyContext |
| agent/model callbacks | CallbackContext |
callback parameters |
| tool function | ToolContext |
agent.Context |
| custom agent run method | InvocationContext |
agent.InvocationContext |
Go models the same idea with interfaces that embed each other rather than a class hierarchy: the richer Context embeds ReadonlyContext, and InvocationContext carries the runtime handles. Same lesson either way — the wider the type, the more you can do.
Reading the InvocationContext
The clearest way to see a context is to write a custom agent that does nothing but report what it was handed. A custom agent is just code that reads a context and yields events, so you can run it offline through a runner with no model and observe exactly what the runtime injects.
In Python, you subclass BaseAgent and implement _run_async_impl(self, ctx):
from typing import AsyncGenerator
from google.adk.agents import BaseAgent
from google.adk.agents.invocation_context import InvocationContext
from google.adk.events import Event
from google.genai import types
class IntrospectionAgent(BaseAgent):
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
name = ctx.agent.name
invocation = ctx.invocation_id
user_name = ctx.session.state.get("user:name", "unknown")
report = f"agent={name} invocation={bool(invocation)} user={user_name}"
yield Event(
author=self.name,
content=types.Content(role="model", parts=[types.Part(text=report)]),
)
Notice what the InvocationContext exposes: ctx.agent (the live agent object), ctx.invocation_id (a unique id for this run), and ctx.session.state (a dict-like store you read with .get(...)). It also carries the wired services — ctx.artifact_service, ctx.memory_service — which is how a custom agent reaches the same artifact and memory backends the runner configured.
Go expresses the identical shape through method calls on an interface instead of attribute access. A custom agent’s Run returns an iterator of events:
func introspectRun(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] {
return func(yield func(*session.Event, error) bool) {
name := ctx.Agent().Name()
invocationPresent := ctx.InvocationID() != ""
user := "unknown"
if v, err := ctx.Session().State().Get("user:name"); err == nil {
if s, ok := v.(string); ok {
user = s
}
}
report := fmt.Sprintf("agent=%s invocation=%t user=%s",
name, invocationPresent, user)
yield(&session.Event{
Author: "introspection_agent",
LLMResponse: model.LLMResponse{Content: &genai.Content{
Parts: []*genai.Part{{Text: report}}}},
}, nil)
}
}
The mapping is mechanical: ctx.agent.name → ctx.Agent().Name(), ctx.invocation_id → ctx.InvocationID(), ctx.session.state.get(k) → ctx.Session().State().Get(k) (which returns a value-and-error pair, so you type-assert). The services live behind ctx.Artifacts() and ctx.Memory().
To drive either version, you seed a session’s state and run through an in-memory runner — no LLM involved. In Python that’s InMemoryRunner(agent=..., app_name=...) with a create_session(..., state={"user:name": "Ada"}); in Go it’s runner.New(...) wired to session.InMemoryService(), artifact.InMemoryService(), and memory.InMemoryService(). This offline pattern is worth keeping in your pocket: you can unit-test real agent behavior against the exact context the runtime provides without ever paying for a model call.
Why the read-only vs mutable distinction matters
ADK deliberately hands an instruction provider only a ReadonlyContext. You can read state to build a prompt, but you cannot mutate it mid-prompt-construction. That is a designed guardrail, not an oversight. Prompt building should be a pure function of current state; if it also mutated state, two invocations could observe different prompts depending on ordering, and reproducing a run would become guesswork.
The practical rule this gives you: when you reach for a capability the context doesn’t offer, that’s usually a signal you’re doing the work in the wrong place. Want to write to state? Do it in a callback or a tool (CallbackContext / ToolContext), not an instruction provider. Want to escalate out of a loop or skip summarization? Those live on ToolContext.actions, because those decisions belong to tool execution. The type you were handed is telling you where the operation is legitimately allowed.
Mental model
Think of the four contexts as concentric permission rings around the same session. ReadonlyContext is the innermost, most restrictive ring; InvocationContext is the outermost with the raw services. The runtime picks your ring based on where your code runs — you don’t choose it, and that’s exactly what keeps state changes traceable to the callbacks and tools that made them.
Next in the series: Callbacks — intercepting the agent, model, and tool lifecycle using the CallbackContext this post introduced.