A tool is where the model touches the world. The design of that touch point determines what the agent can do, what it cannot do, and — critically — what it cannot do by accident. In v1, tools were closures: they captured live context at construction time and held it forever. The design worked until it didn't — until the need to reconstruct a session from a durable record exposed that the closed-over references were gone. v2 rethinks the tool from the ground up. A tool definition is a context-free value; context enters only at session construction, through an explicit binding step. The result is a toolset that can be reconstructed by any process that has the session spec and the registry.
This chapter traces that evolution. Chapter 4 established the construction seam as the single place where a session's dependencies are assembled. Tools are assembled at that same seam. Chapter 9 covers the sandbox that tools use to touch the filesystem. This chapter covers the tools themselves: how they are defined, how they receive their context, how the registry resolves names to factories, and how execute_code — Cy's core affordance — sits at the center of it all.
In v1, a tool is a function with a schema. You write a name, a description, a raw JSON Schema object for the parameters, and a handler. The handler receives args as an untyped record and returns a string. That is the entire contract.
v1: Tool as Closure (context baked in at creation)
Session construction
│
▼
┌─────────────────────────────────┐
│ tools = [ │
│ execute_code(env, store), │ ← live objects captured
│ read_file(env), │ in closure at build time
│ send_message(chat, store), │
│ ] │
│ │
│ Cannot serialize tool list │
│ Cannot reconstruct from spec │
│ Cannot narrow for subagent │
└─────────────────────────────────┘
The problem is not with the interface — it is with when context enters the picture. Cy's tools are not pure functions over their arguments. They need to know which sandbox to write to, which session they belong to, how to spawn a child agent. In v1, that context enters the picture at definition time. build_toolset in core/tools.ts is called during harness construction, and the tools it produces are closures: their handlers capture ctx.sandbox, ctx.chat, and whatever else the harness had assembled at that moment.
This works for a single, long-lived process. It stops working the moment you need to reconstruct the same toolset from a durable record. When a process restarts and reloads a session from storage, you cannot replay build_toolset with the same context — the original context is gone. The closed-over references are dead. And because the parameters schema is a hand-written JSON Schema object with no link to the handler's actual argument expectations, nothing catches the drift when someone updates the schema but forgets to update the handler, or vice versa. Two sources of truth will eventually disagree.
The comment at the top of v1's tools.ts says it plainly: "a run's tools are NOT a static list on the profile: capabilities derive tools." The derivation is correct. The problem is that the derivation happens eagerly at harness construction time, producing live closures rather than durable records. The tools cannot be serialized. The session spec cannot describe them. The factory cannot reconstruct them without re-running the same initialization code in the same process.
v2 breaks the tool into two halves that exist at different points in the lifecycle. The first half is the ToolDefinition: a pure value that carries the name, description, Pydantic model class for the parameters, and the execute function. It has no live references. It holds no context. You can import it, inspect it, and pass it around as data — it says nothing about which session it will run in. The second half is the ToolContext: the per-session state the tool needs to do its work.
The execute body in a ToolDefinition takes both the validated arguments and a ctx parameter. It reads the sandbox from ctx.env, the session identity from ctx.session, the child-session factory from ctx.factory. None of those references live in the definition — they are passed in at the moment of binding. bind_tool is the function that performs this binding. It takes a ToolDefinition[P, Ctx] and a Ctx value, calls model_json_schema() on the parameter class to derive the JSON Schema, and returns an AgentTool: the concrete, provider-callable object the model loop hands to the LLM on each turn.
# definition — context-free, shareable, loaded at module init
def define_tool(def_: ToolDefinition[P, Ctx]) -> ToolDefinition[P, Ctx]: ...
# binding — one call per tool per session construction
bound: AgentTool = bind_tool(definition, tool_context)With Pydantic, the model class generates its JSON Schema at class definition time — schema and validation are always in sync by construction, because both derive from the same class. bind_tool calls model_json_schema() on the parameter class to get the JSON Schema the provider sees, and validates incoming arguments with ParamModel.model_validate(args) — both operations go through the same Pydantic class, so drift is impossible by design. On each invocation, bind_tool's wrapper validates the raw arguments via model_validate. If validation fails, it raises a structured error that the model receives as a tool error. If it succeeds, execute receives args as an instance of P (the Pydantic model class) — not a dict[str, Any], but the actual typed model the handler expects. (One tool is exempt: execute_code predates the Pydantic migration and uses a raw JSON Schema dict, so its handler receives an untyped dict[str, Any] — see §5.)
The ToolContext carries everything a tool might need: env for the cwd-scoped sandbox view, execute_code for the code execution backend, session for identity and tenant lineage, factory for minting child sessions or resolving siblings, store for the execution log, and coordinator for cross-session signaling. A tool that only needs the sandbox gets ctx.env. A tool that needs to spawn workflows gets ctx.factory. The context is wide; each tool extracts only what it uses. Nothing is ambient. Nothing is closed over at module load.
The phrase "tools as factories" refers to a specific type: ToolFactory = Callable[[ToolContext], AgentTool]. A ToolFactory is not a tool — it is the recipe for constructing one. It takes the live per-session state and returns the ready-to-call AgentTool. This is the indirection layer that makes the whole system serializable.
Consider what a session spec needs to say about its tools. It could carry AgentTool objects directly — but those contain live function references and cannot be serialized to JSON. It could carry ToolDefinition objects — but those are still code values, and deserializing them from JSON would require a dynamic eval or an equivalent. The only thing a session spec can carry durably is strings. A name like "execute_code" or "workflow" serializes trivially. It survives a process restart. It survives being written to a database and read back.
The ToolRegistry holds the mapping from those strings to their factories. When a process starts up, it registers the built-in tools and any application-level tools. When the SessionFactory constructs a session, it reads the tool names from the spec, resolves each name against the registry to get a ToolFactory, passes the session's ToolContext to each factory, and collects the resulting list[AgentTool]. The model loop gets live tools. The spec carries only names. The registry is the bridge.
DEFINITION TIME (module load)
define_tool(ToolDefinition(name, description, parameters=MyParams, execute=...))
│
└─ ToolDefinition (context-free, no live references)
SESSION CONSTRUCTION TIME (SessionFactory.create)
ToolRegistry.resolve(name) → ToolFactory
│
ToolFactory(ToolContext) → bind_tool(definition, context)
│
└─ AgentTool (JSON Schema from Pydantic model, execute wired to live env)
│
└─ sent to model as tool spec
The consequence of this design is that any process with the registry can rebuild any session's toolset from the spec alone. There is no closed-over state from the original construction. The session spec is self-contained. If you want to move a session from one worker process to another, the receiving worker reads the spec, resolves the tool names, constructs a fresh ToolContext from the session's stored state, binds the tools, and the session is back in service. No context from the previous process survives, and none needs to.
The registry's interface is straightforward: register(name, factory) and build(names, ctx). It holds a dict[str, ToolFactory] internally. build maps each name to its factory, calls each factory with the provided ToolContext, and returns the list[AgentTool] in the order the names were listed. If a name is not registered, the registry skips it and calls an on_unknown callback (typically a warning log) — the session starts with a narrower toolset rather than failing outright.
The set of built-in names includes execute_code, workflow, terminal, process, and the file operation tools (read_file, write_file, list_directory, and their siblings). Each is registered at startup with its factory. Application-level tools — tools specific to a particular deployment or tenant — register alongside the built-ins. A session spec that lists ["execute_code", "read_file", "my_custom_tool"] gets exactly those three, in that order, bound to that session's live context.
What the registry makes possible is worth dwelling on. Suppose you have ten concurrent sessions, all using the execute_code tool. Each session has its own ToolContext — its own sandbox env, its own code executor, its own session identity. The registry resolves the same factory ten times and produces ten distinct AgentTool instances, each bound to a different context. The definition is shared. The binding is per-session. The name is what the spec carries. This is how you get isolation without duplication: one factory, many instances.
The registry also enforces a clean separation between the built-in toolset and the application layer. A new deployment can register additional factories without touching the core. A tool can be removed from the registry and any spec that lists its name will be logged and skipped — the session runs with the remaining tools rather than failing to construct. The failure mode is degradation with observability, not a hard crash.
Most LLM agent frameworks give the model a catalog of JSON tools: search_web, read_file, create_issue. Each tool is a narrow, pre-built capability. The model calls the right tool with the right arguments and the framework does the work. Cy's design philosophy runs in the opposite direction. Cy's core affordance is execute_code: the model composes Python source and runs it in a sandbox. The Python program calls whatever APIs and libraries it needs. The tool boundary is not around individual capabilities — it is around execution itself.
This means create_execute_code_tool is the most load-bearing function in the toolset. It takes a CodeExecutor — a typed callback, Callable[[str, CodeOptions], Awaitable[ShellResult]] — and returns an AgentTool. The tool's execute body is small: resolve the filename (defaulting to main.py), call execute_code(code, filename=filename, timeout_ms=timeout_ms, signal=signal), and lift the ShellResult into the pi text-content shape. If the exit code is zero, it returns stdout. If it is nonzero, it returns stdout plus stderr plus the exit code, formatted so the model can read the failure and self-correct.
The CodeExecutor abstraction is where the tool stays backend-agnostic. There are two implementations. The local executor, create_local_code_executor, takes a SessionEnv and a run command string. It calls env.write_file(filename, code) to write the source, env.resolve_path(filename) to get an absolute path, and env.exec(command, opts) to run uv run python <file>. This is the dev loop path: local process, local sandbox, standard child process execution. The remote executor is different: it forwards the source to the control server's execute endpoint. The control server is the only path that opens the per-execution SDK socket, which is how the agent's in-sandbox sdk.call(...) calls ride back to the runtime's broker for approval flows and cross-session communication.
The create_execute_code_tool function knows nothing about either backend. It receives a CodeExecutor and calls it. Which backend the factory produces depends on what the SessionFactory put in the ToolContext. The tool definition is the same in both cases. The schema is the same. The execute body is the same. The only thing that varies is the execute_code callback, and that is a per-session decision made at session construction time, not at tool definition time.
One implementation note: execute_code uses a hand-written raw JSON Schema dict (EXECUTE_CODE_PARAMETERS) rather than the define_tool/bind_tool/Pydantic path described in Section 2. The source comment says it explicitly: "raw JSON Schema — no typebox dep." It is on the legacy path that define_raw_tool serves. The principle — context-free definition, per-session binding — holds, but the type-safety guarantee from Pydantic model validation does not apply here. New tools added to the registry use define_tool; execute_code predates that migration. This is the trade-off of a live codebase: the architectural direction is clear, but not every existing tool has crossed over yet.
build_toolset in the v1 codebase is both a toolset assembler and a context-binder: it takes the profile and context and returns the finished list[AgentTool] directly. In v2, it plays the same compositional role but is no longer the place where context is baked in — that happens in the registry's build method. The v1 build_toolset in core/tools.ts is the composition root that orchestrates what the session gets.
Trust Scope: Per-Session, Not Per-Tool
Root session Subagent session
┌────────────────────────┐ ┌────────────────────────┐
│ ToolContext: │ │ ToolContext: │
│ env = /workspace/ │ │ env = /workspace/ │
│ store = root-key │ │ child-dir/ │
│ chat = Slack │ │ store = child-key │
│ factory = can spawn │ │ chat = None ← no msg│
│ │ │ factory = depth-1 │
│ ALL tools share this │ │ │
│ same context │ │ ALL tools share this │
└────────────────────────┘ │ narrower context │
└────────────────────────┘
│
▼
Trust boundary is HERE (between sessions)
not between individual tools within a session
The v1 version (which remains in the codebase as the active path) follows this sequence. It creates a SessionEnv by wrapping ctx.sandbox with a cwd of /workspace — the Cy sandbox convention for the writable workspace root. It then calls create_execute_code_tool(sandbox_env), which in the v1 context takes the env directly rather than a CodeExecutor abstraction. It appends the chat tools if a chat is bound (ctx.chat.tools(ctx) if ctx.chat else []) — subagents receive chat=None and so get no messaging tools by structural absence. Extra tools from the caller come next. Finally, if the profile declares subagents, it appends either the real delegate tool (when DelegateWiring is injected by the recursion engine) or an inert placeholder (when it is not).
The v2 pattern replaces the direct wiring with registry resolution: given a ToolContext and a list of names from the spec, ToolRegistry.build iterates, resolves, and calls each factory. build_toolset becomes the function that constructs the ToolContext and invokes the registry. The result is identical from the model's perspective — it receives an list[AgentTool] — but the path through which that array is produced is now traceable from spec through registry through factory through binding, with no ambient closures at any point.
What build_toolset makes concrete is the answer to the question every agent runtime must answer: given a session's identity, configuration, and live infrastructure, what tools does the model get, and in what order? The answer in Cy's case is: execute_code always (it is the core affordance), messaging tools only when a chat is bound (structural not conditional), extra tools from the caller when provided, and delegation tools only when the profile declares subagents and the recursion engine is wired in. The composition root enforces this order. The registry enforces name resolution. The factories enforce context binding. Each layer has one job.
The cross-cutting concern worth naming is trust scope. A tool receives exactly the ToolContext its factory was given — no more. Note the level: trust is enforced between sessions (a subagent's ToolContext is constructed narrower), not between tools within a session. Every tool bound to one session shares its context; the structural question is "does this session's context carry factory?", not "does this tool?" A subagent's ToolContext has env scoped to the child's workspace directory, factory limited to minting one more level of children, and store scoped to the child's session key. The parent session's context is not in scope. The execute_code tool cannot access the parent's sandbox because it does not have the parent's env. A tool that does not receive factory in its context cannot spawn workflows. Trust is structural: it is enforced by what is in the ToolContext, not by runtime permission checks that can be bypassed or forgotten. This is the same principle the SessionFactory from Chapter 4 applies to session construction — the thing you are not given cannot be misused.
The transferable pattern in this chapter is the separation between definition and binding. In v1, both happened in the same place at the same moment, which made the tool inseparable from the process that built it. Splitting them — pure definition at module load, live binding at session construction — makes tools serializable by name, reconstructable by any process with the registry, and testable without a live session. The registry is the naming layer that connects the two.
Chapter 9 picks up the other side of the boundary: what the tools call. Every env.exec() and env.write_file() inside a tool handler travels through SandboxApi, the interface that hides whether the sandbox is local or remote, a container or a VM. That interface is the next seam worth understanding.
-
docs/the-rewrite/chapters/08-tools-as-factories.md— The existing outline, which provided the section structure, key vocabulary, and the ASCII diagram to adapt. Contains concise descriptions of the v1 and v2 models and the registry/factory pattern. -
apps/runtime-v1/src/core/tools.ts— The v1build_toolsetimplementation. Shows how tools are assembled by closing over liveAgentContextstate (sandbox, chat, recursion engine) at harness construction time, and howdelegate/workflowtools are wired via injectedDelegateWiringto avoid import cycles. -
apps/runtime/src/tools/define.ts— The v2defineTool/bindTool/defineRawToolimplementation. The source of truth for howToolDefinition<P, Ctx>is structured, howbindToolderives JSON Schema from zod at bind time, and how argument validation viasafeParseworks. Also documents the legacyToolSpecpath for tools not yet migrated to zod. -
apps/runtime/src/tools/execute-code.ts— ThecreateExecuteCodeToolandcreateLocalCodeExecutorimplementations. Shows theCodeExecutorabstraction, the two backends (local write+exec vs. remote control server), and how the tool liftsShellResultinto pi's text-content shape with self-correction-friendly failure output.