Tools and transports
The architecture describes consumers — the agent and the sandbox — running over a provider’s actions. This page explains the seam that lets those same consumers run in two very different places without changing: in-process on the server, where an action is a direct function call, and on a client across an HTTP gap, where an action is a network round-trip. The mechanism is a single neutral contract, the Tool, plus two adapters that bind it to each side.
The problem: an action needs Services
Section titled “The problem: an action needs Services”A provider Action is server-shaped. Its execute receives a Services container — the
dependency-injection container holding databases, HTTP clients, and everything else the
provider was set up with. That is exactly what you want on the server, and exactly what you
cannot have on a client: the browser or remote process has no database handle, no provider
connection, no Services.
So a consumer written against Action can only ever run on the server. To let the same
sandbox script or agent loop run on a client, the thing it consumes has to be free of any
server container.
The Tool: a transport-agnostic callable
Section titled “The Tool: a transport-agnostic callable”A Tool (@grundlag/shared) is that container-free contract. It carries the same
Zod-validated input/output schemas an action does, but its execute takes only the input
plus a slim ToolContext — no Services:
type Tool = { id: string; name: string; description?: string; input: z.ZodType; output: z.ZodType; execute(input, context: ToolContext): Promise<output>;};
type ToolContext = { logger: Logger; userId?: string; resumption?: ToolResumption; // the caller's answer to a prior interrupt items?: ToolItems; // read/write access to conversation items signal?: AbortSignal;};Whatever a tool needs beyond that context is bound in when the tool is created — closed
over, not passed at call time. That is the whole trick: because the dependencies are already
captured, the sandbox and agent consume Tool without ever knowing whether calling it runs
local code or makes an HTTP request.
defineTool wraps execute so input is validated before the call and output after, the
same way defineAction does for actions. The schemas are therefore load-bearing wherever
the tool runs — inside a sandbox, an agent, or a client rebuilt from JSON Schema — not only
behind the server’s request validator.
Two adapters, one seam
Section titled “Two adapters, one seam”The same Tool shape is produced on both sides of the gap by two adapters:
| Adapter | Package | Closes over | Produces |
|---|---|---|---|
toTool / ProviderRegistry.toTools() |
@grundlag/core |
a Services container |
a tool that runs action.execute in-process |
client.toTools(manifest) |
@grundlag/client |
an HTTP request function | a tool that POSTs the action call to a server |
Both return Record<string, Tool> keyed by the same function names (see
Function names). Hand either map to a Sandbox
or an AgentRun and it behaves the same:
// On the server — tools are direct calls.const sandbox = new Sandbox({ actions: await providers.toTools() });
// On a client — tools are HTTP round-trips to a running host.const sandbox = new Sandbox({ actions: client.toTools(apiManifest) });This is what makes “build anything on top of it” literal: a sandbox and an agent are not server-only machinery. They run wherever a set of tools can be assembled, and the client package assembles that set from any deployed host.
The wire contract
Section titled “The wire contract”When a tool call does cross HTTP, both directions travel through one shared shape defined
once in @grundlag/shared and used verbatim by the server’s action route and the
client’s tool adapter.
The action envelope. A request is { input, resumption?, items? }. A response is a
single shape covering both outcomes:
// completed{ status: ('completed', result, items);}// interrupted{ status: ('interrupted', message, data, state, items);}items carries whatever the call emitted (see Items across the gap).
An interrupt cannot cross HTTP as a thrown value, so the server serialises it into the
interrupted branch and the client re-throws it as a ToolInterrupt on the far side — so a
client-driven run pauses and resumes exactly like a server-driven one. See
Interrupts.
The manifest. A host describes itself at GET /api/client/schema: every provider’s
actions, entities, and events, each with metadata and JSON Schemas, and each action’s
allocated function name. The client turns this one document into two things — the
compile-time ApiSchema type and the runtime tool set — so the types you code against and
the tools you call are generated from the same source of truth. See
Generate a typed client.
Function names: one allocation everywhere
Section titled “Function names: one allocation everywhere”A tool is addressed by a functionName — a safe, deduped camelCase name derived from
{providerId}_{actionId} (colliding names get _2, _3, …). This is decided in exactly one
place, allocateActionNames, and read by the server’s toTools, the server’s eval sandbox,
the client’s toTools, and the manifest alike. Because there is a single allocation, a
client addresses a tool by precisely the name the server registered it under — a script
written against a local sandbox calls the same demoAdd(...) when the sandbox is backed by
HTTP tools instead.
Items across the gap
Section titled “Items across the gap”A tool can read and write items — the typed artifacts in a conversation transcript (see
Primitives) — through context.items (latest / emit / list).
This is itself a seam with two adapters at the same interface:
createItemStore(@grundlag/shared) — an in-memoryToolItemsover a plain list. This is what a transport and tests use.- The agent’s transcript-backed items — the same interface over a live conversation.
Over HTTP, the client sends its current items with the request (items) so the server’s
latest lookups resolve to the same values they would in-process, and the server returns the
items the call emitted so the client can fold them back in. The net effect: a tool’s item
access works the same whether the call is local or remote.
Where to go next
Section titled “Where to go next”- Generate a typed client — turn a host’s manifest
into an
ApiSchematype and runtime tools. - Call actions and search entities — the typed client for ordinary application code.
- Run agents and sandboxes on the client — assemble HTTP-backed tools and drive a run from outside the server.