Skip to content

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.

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.

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.

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 | undefined

search 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.

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.

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 absent
const 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.

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
}
}

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.