Call actions and search entities
createClient turns the generated ApiSchema into
a fully typed handle on a running host. Every call is checked against the host’s real schemas:
action inputs are validated at compile time and the awaited results carry the host’s output
shapes. This is the client for ordinary application code — a web app, a script, a background
job — that talks to the host over HTTP.
Create a client
Section titled “Create a client”Pass the generated ApiSchema as the type parameter and the host’s base URL:
import { createClient } from '@grundlag/client';import type { ApiSchema } from './client.generated.js';
const client = createClient<ApiSchema>({ baseUrl: 'http://localhost:3800' });The client is a thin set of proxies — nothing about ApiSchema exists at runtime, so the
client builds each request URL from the property path you access. providers.demo.actions.echo
resolves to a POST at /api/providers/demo/actions/echo; the types come entirely from the
schema.
Invoke an action
Section titled “Invoke an action”Reach a provider’s action through providers.<id>.actions.<id>. The argument is the action’s
input; the resolved value is its output — both typed from the schema:
const echo = await client.providers.demo.actions.echo({ message: 'hi' });// echo: { message: string; length: number }
const { sum } = await client.providers.demo.actions.add({ a: 2, b: 3 });Passing the wrong input shape is a compile error, and the awaited result is never any — it
is exactly what the host declares the action returns.
Search and fetch entities
Section titled “Search and fetch entities”An entity handle exposes the two read operations the host supports — paginated search and
get by id:
const page = await client.providers.demo.entities.note.search({ query: 'hello' }, { limit: 10 });// page.results: Note[]; page.nextCursor?: string
const note = await client.providers.demo.entities.note.get('1');// note: Note | undefinedsearch takes the entity’s searchInput plus optional cursor / limit, and returns
{ results, nextCursor? }. When nextCursor is present, pass it back as cursor to fetch the
next page; when it is absent there are no more pages.
Events appear in the schema but have no client method — the host describes them but exposes no HTTP route to invoke them.
Resuming after an interrupt
Section titled “Resuming after an interrupt”An action can pause itself to ask for a decision — approval, a missing value — by throwing an
interrupt. Over HTTP that arrives as a thrown ToolInterrupt carrying the data the action
wants you to act on and an opaque state checkpoint. To resume, call the action again with the
answer under resumption:
import { ToolInterrupt } from '@grundlag/client';
try { await client.providers.demo.actions.transfer({ amount: 100 });} catch (error) { if (error instanceof ToolInterrupt) { // error.data — what the action needs a decision on await client.providers.demo.actions.transfer( { amount: 100 }, { resumption: { data: { approved: true }, state: error.state } }, ); } else { throw error; }}Pass the interrupt’s state back verbatim — it is the action’s private checkpoint. See
Interrupts for the shared model.
Read and write bucket objects
Section titled “Read and write bucket objects”A host can expose a bucket
for file blobs. client.buckets offers the same operations the provider has in process:
const files = client.buckets.open('reports');
await files.putText('monthly/june.md', '# June', { metadata: { author: 'morten' } });await files.put('logo.png', pngBytes, { contentType: 'image/png' });
const markdown = await files.getText('monthly/june.md');const bytes = await files.get('logo.png'); // Uint8Array
const object = await files.stat('logo.png'); // undefined when absentconst objects = await files.list({ prefix: 'monthly/' });
await files.delete('monthly/june.md');open performs no request — it returns a handle. client.buckets.list() asks the host which
buckets it exposes.
Buckets are keyed by id at runtime rather than through ApiSchema: an object is bytes, so
there is no per-bucket type for the generator to describe and every bucket offers the same
operations.
Two details differ from the in-process API. get throws ClientRequestError with
status: 404 for a missing object, where the server-side Bucket throws
BucketObjectNotFoundError — the client does not depend on core, so it cannot re-create that
error. And stat reports contentType: 'application/octet-stream' for an object stored
without one, because that is what the response header says; list reports it as absent.
Keys are /-separated and encoded per segment, so spaces and & are safe. A key containing
an empty, ., or .. segment throws before any request is made: URLs resolve dot segments
before they reach the host, so such a key would silently address the wrong endpoint rather
than fail.
Errors
Section titled “Errors”A non-2xx response throws ClientRequestError, which carries the HTTP status and the parsed
response body — so you can branch on a 400 validation failure versus a 404:
import { ClientRequestError } from '@grundlag/client';
try { await client.providers.demo.actions.echo({ message: 'hi' });} catch (error) { if (error instanceof ClientRequestError && error.status === 400) { console.error(error.body); // the host's structured validation error }}Auth and custom headers
Section titled “Auth and custom headers”headers attaches to every request. Pass a static map, or a (possibly async) function called
per request — handy for tokens that need refreshing:
const client = createClient<ApiSchema>({ baseUrl: 'https://host.example.com', headers: async () => ({ authorization: `Bearer ${await getToken()}` }),});You can also override prefix (default /api) and supply a custom fetch.
Next steps
Section titled “Next steps”- Run agents and sandboxes on the client — go beyond direct calls and drive a sandbox or agent from the client.
- Call the HTTP API — the raw wire contract the client speaks.