Skip to content

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.

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.

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.

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.

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.

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.

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-memory ToolItems over 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.