Skip to content

Run agents and sandboxes on the client

The typed client calls actions one at a time. This guide goes further: it turns a host’s actions into a set of neutral tools and hands them to a Sandbox or an agent — so the runner itself lives on the client, while the tools it calls execute on the server. Because a sandbox and an agent consume the same Tool shape whether the tools are local or remote, the run looks identical to one hosted on the server; only the transport underneath differs.

client.toTools(manifest) reconstructs the host’s provider actions as tools backed by HTTP calls. Pass it the apiManifest from your generated file — it carries the JSON Schemas the tools rebuild their validation from, and the function names the server keyed them under:

import { createClient } from '@grundlag/client';
import { apiManifest, type ApiSchema } from './client.generated.js';
const client = createClient<ApiSchema>({ baseUrl: 'http://localhost:3800' });
const tools = client.toTools(apiManifest);
// tools: Record<string, Tool> — e.g. tools.demoAdd, tools.demoEcho

Each tool is keyed by the same function name the server assigns, so a script that calls demoAdd(...) addresses the same action whether the sandbox is backed by in-process tools or these HTTP-backed ones.

Hand the tools to a Sandbox and evaluate a script. The add below runs on the server; only its result crosses back:

import { Sandbox } from '@grundlag/sandbox';
const sandbox = new Sandbox({ actions: tools });
const { data } = await sandbox.eval({
script: 'const r = await demoAdd({ a: 2, b: 3 }); data.sum = r.sum;',
data: {},
});
// data.sum === 5 — produced by the live server executing `add`

The sandbox is running in your process; the tool call inside the script is the only thing that touches the network. Everything else about the sandbox — the deterministic call log, resumable interrupts, type-checked scripts — works unchanged. See Execute a script.

The client tools compose with the rest of the toolkit. You can wrap a client-backed sandbox as a single tool — the same sandbox-as-a-tool pattern — so an agent orchestrates HTTP-backed actions by writing one script:

import { Sandbox, createSandboxTool } from '@grundlag/sandbox';
import { createItemStore } from '@grundlag/shared';
const sandbox = new Sandbox({ actions: client.toTools(apiManifest) });
const runScript = await createSandboxTool(sandbox);
const store = createItemStore();
const output = await runScript.execute(
{ script: "data.echoed = (await demoEcho({ message: 'hi' })).message;" },
{ logger, items: store.items },
);

Two things a tool needs beyond its input still flow across the gap:

  • Items. A tool reads and writes conversation items through its context. When you supply an item store, the client sends its current items with each call so the server’s latest lookups resolve to the same values they would in-process, and folds back whatever the call emitted. Create one with createItemStore and pass store.items on the context.
  • Interrupts. An action that pauses server-side comes back as an envelope and is re-thrown as a ToolInterrupt in your process — so a client-driven run suspends and resumes exactly like a server-driven one. See Interrupts.
import { createItemStore, type Logger } from '@grundlag/shared';
const store = createItemStore([{ itemType: 'memo', data: { text: 'remembered' } }]);
const readMemo = tools.demoReadMemo;
// The action reads its `memo` item server-side; the value is there only because
// the client forwarded the item snapshot over HTTP.
const result = await readMemo.execute({}, { logger, items: store.items });

Reach for client-side tools when the runner should live outside the server — an agent loop in a worker, a sandbox in a CLI, a script orchestrating a remote host — while the capabilities stay behind the host. When the runner already lives inside the host, use the server’s own ProviderRegistry.toTools() instead and skip the network entirely; the consuming code is the same either way.