Quick start
Let's build your first agent end to end: a researcher you can spawn, message, and watch working in real time — and then extend with a summarizer sub-agent. Every command below runs against the stack you booted in Installation.
teaspill alias and TEASPILL_API_KEY you set while booting it still active in your shell, and an Anthropic API key — the researcher calls a model.Set up an agents package
Your agents live in an ordinary workspace package. Create one next to the built-in ones:
{
"name": "my-agents",
"private": true,
"type": "module",
"scripts": { "build": "tsc -p tsconfig.json" },
"dependencies": {
"@teaspill/agents-sdk": "workspace:*",
"@teaspill/catalog": "workspace:*",
"@teaspill/coordination": "workspace:*",
"zod": "4.1.13"
},
"devDependencies": {
"@types/node": "26.1.1",
"typescript": "6.0.3"
}
}
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}
Pin zod to the same version @teaspill/agents-sdk uses — your schemas flow through the SDK's types, and mixing two zod copies breaks that. Then link it into the workspace:
pnpm install
Define the researcher
An agent type is one defineAgent call: what it's spawned with, what state it keeps, and the engine that runs its model loop.
import { defineAgent, native } from "@teaspill/agents-sdk";
import { z } from "zod";
const researcher = defineAgent({
type: "researcher",
spawnSchema: z.object({ topic: z.string() }),
state: z.object({ summary: z.string().optional() }),
harness: native({
model: "claude-sonnet-4-5",
systemPrompt:
"Research the topic you were spawned with from your own knowledge, " +
"then call `finish` with a short summary.",
ingressUrl: "http://localhost:8080",
}),
});
Three fields do the work. spawnSchema types and validates the arguments an instance is born with — a bad spawn is rejected cleanly, before any model call. state is the agent's durable memory, also schema-checked. harness picks the engine for the model loop: native(...) is the default and works with any provider (Harnesses covers the alternative). The ingressUrl is how the agent's built-in tools reach the rest of the platform — localhost:8080 is Restate's ingress port published on your host, since this process will run on the host.
Serve and register
serve() turns definitions into a running service and registers it through the gateway. Its deps are deployment wiring — the connections into the stack that every deployment supplies, not agent logic you write per type:
// …add below the definitions, with the imports at the top of the file:
import { serve } from "@teaspill/agents-sdk";
import {
createAgentNotifier,
createDrizzleArchiveCatalog,
createDrizzleEntityDirectory,
createDrizzleOutboxCatalog,
DurableStreamsProjectionOutbox,
HttpTimelineStreamTransport,
} from "@teaspill/coordination";
import { createCatalogClient } from "@teaspill/catalog";
const { db } = createCatalogClient(); // reads DATABASE_URL
await serve({
agents: [researcher],
deps: {
outbox: new DurableStreamsProjectionOutbox({
transport: new HttpTimelineStreamTransport({ baseUrl: "http://localhost:4437" }),
catalog: createDrizzleOutboxCatalog(db),
}),
notifier: createAgentNotifier(),
directory: createDrizzleEntityDirectory(db),
archiveCatalog: createDrizzleArchiveCatalog(db),
},
port: 9082,
registration: {
gatewayUrl: "http://localhost:8787",
deploymentUrl: "http://host.docker.internal:9082",
apiKey: process.env.TEASPILL_API_KEY,
},
});
console.log("researcher deployment up on 9082");
Each deps entry is one of those connections: outbox writes every agent's timeline to the history store and its row to the catalog, notifier carries messages and wakes between agents, directory tracks entities so deliveries find them, and archiveCatalog is what lets an idle agent archive and later come back — without it, an archived agent cannot be resurrected. You rarely assemble these by hand: the reference deployment under packages/reference-deployment wires the same pieces from public APIs, and copying it is the intended starting point once you outgrow one file.
deploymentUrl is the address Restate dials from inside its container — for a host-run process that's http://host.docker.internal:9082, never localhost (which would register fine and then fail on every invocation).Build and run it, with DATABASE_URL pointing at the stack's Postgres on your host:
pnpm --filter my-agents build
DATABASE_URL="postgresql://teaspill:teaspill@localhost:5432/teaspill?sslmode=disable" \
ANTHROPIC_API_KEY="sk-ant-..." \
TEASPILL_API_KEY=tsp_local_dev_only \
node packages/my-agents/dist/agents.js
# serving … registered … researcher deployment up on 9082
serve() registers once and throws if the gateway isn't reachable — fine here, since the stack is already up. For day-to-day iteration, teaspill dev wraps the same registration with a health wait, retries, and re-registration on rebuild.
Spawn your researcher
From a second terminal (with the teaspill alias and TEASPILL_API_KEY set):
teaspill spawn researcher '{"topic":"the history of tea"}'
spawned /t/default/a/researcher/01kxw5gkeqax7e518tz5ctqz0c
logs: teaspill logs /t/default/a/researcher/01kxw5gkeqax7e518tz5ctqz0c
That path is the instance's entity URL — its stable identity everywhere in teaspill: in the catalog, in events, in the API (Entities & addressing). It also now shows up in the catalog:
teaspill agents ls --type researcher
active /t/default/a/researcher/01kxw5gkeqax7e518tz5ctqz0c (researcher) head=5
That head=5 is how far the agent's timeline has advanced — it climbs as the agent works. By default each spawn mints a fresh, sortable id. Pass --id my-researcher to supply your own — re-running the same spawn then reattaches instead of duplicating, which is what makes scripted spawns safe to retry. Multi-agent patterns builds on that trick.
Send it a message
Commands accept the short form /a/<type>/<id> too. Message bodies carry structured content blocks — the same shape the timeline records:
teaspill send /a/researcher/01kxw5gkeqax7e518tz5ctqz0c \
'{"kind":"message","content":[{"type":"text","text":"one more angle: green vs black tea"}]}'
sent → /t/default/a/researcher/01kxw5gkeqax7e518tz5ctqz0c
The platform delivers your body to the agent as-is. If you'd rather accept shorthand like {"text":"..."}, normalize it at your deployment — the Building agents guide shows the pattern.
Watch the timeline in a browser
teaspill logs follows an entity's timeline right in your terminal, rendering each event as it lands:
teaspill logs /a/researcher/01kxw5gkeqax7e518tz5ctqz0c
# output illustrative — your researcher's answers will differ
following /t/default/a/researcher/01kxw5gkeqax7e518tz5ctqz0c
12:04:11 #0 ● spawned researcher
12:04:11 #1 user: {"topic":"the history of tea"}
12:04:11 #2 ▶ run inv_1eyJ… started (native claude-sonnet-4-5) wake=spawn
12:04:18 #3 ⚙ tool finish({"result":"Tea originated in southwest China…"})
12:04:18 #4 → ok finish: Run complete.
12:04:18 #5 ■ run inv_1eyJ… success tokens=734 7311ms
12:06:03 #6 user: one more angle: green vs black tea
12:06:03 #7 ▶ run inv_1fZk… started (native claude-sonnet-4-5) wake=message
12:06:09 #8 ⚙ tool finish({"result":"Green and black tea differ…"})
12:06:09 #9 → ok finish: Run complete.
12:06:09 #10 ■ run inv_1fZk… success tokens=402 5871ms
The same numbered events are one HTTP request away — this is the raw stream your UI reads (the Gateway API reference documents the stream routes):
curl -H "Authorization: Bearer $TEASPILL_API_KEY" \
"http://localhost:8787/streams/t/default/agents/researcher/01kxw5gkeqax7e518tz5ctqz0c/timeline?offset=-1"
And @teaspill/frontend-sdk folds that stream into live, render-ready state — messages plus in-flight token deltas:
import { createAgentTimeline } from "@teaspill/frontend-sdk";
const timeline = createAgentTimeline(
"http://localhost:8787/streams/t/default/agents/researcher/<id>/timeline",
{ auth: { apiKey: "tsp_local_dev_only" }, deltas: true }, // API key in a browser: dev only
);
timeline.subscribe((state) => {
render(state.timeline.messages, state.timeline.liveDeltas);
});
state.timeline.liveDeltas) stream out of the box against the reference deployment — it emits them on every agent's sibling /deltas stream. The stripped-down serve() above skips delta wiring to stay minimal, so here deltas: true connects but stays quiet and you still see every finalized message. A hand-rolled deployment turns deltas on by wiring the delta-emitter seam the way the reference deployment does; Building agents covers the deps it rides on.Real apps never ship an API key to the browser — they mint short-lived read tokens server-side instead. The Frontend integration guide covers tokens, React bindings, and joining long timelines efficiently.
Make it multi-agent
Now the payoff. Add a summarizer, and tell the researcher to delegate to it:
const summarizer = defineAgent({
type: "summarizer",
spawnSchema: z.object({ text: z.string() }),
state: z.object({}),
harness: native({
model: "claude-sonnet-4-5",
systemPrompt:
"Summarize the text you were spawned with in three sentences, " +
"then call `finish` with the summary.",
ingressUrl: "http://localhost:8080",
}),
});
Update the researcher's systemPrompt and the served list:
systemPrompt:
"Research the topic you were spawned with. Spawn a `summarizer` " +
"with your notes, then call `wait`. When its child_finished " +
"message arrives, call `finish` with the summary.",
agents: [researcher, summarizer],
Stop the process, rebuild, and start it again — re-registration overwrites the previous one, so nothing else changes. Spawn a fresh researcher and follow its logs:
pnpm --filter my-agents build
# restart the deployment process, then:
teaspill spawn researcher '{"topic":"the history of tea"}'
teaspill logs /a/researcher/<id>
# output illustrative
13:12:04 #0 ● spawned researcher
13:12:04 #1 user: {"topic":"the history of tea"}
13:12:04 #2 ▶ run inv_1kPa… started (native claude-sonnet-4-5) wake=spawn
13:12:11 #3 ⚙ tool spawn_agent({"type":"summarizer","args":{"text":"…notes…"}})
13:12:11 #4 → ok spawn_agent: Spawned summarizer as /t/default/a/summarizer/01kxw7…
13:12:11 #5 ↳ spawned child summarizer/01kxw7…
13:12:11 #6 ⚙ tool wait({})
13:12:11 #7 → ok wait: Turn yielded.
13:12:12 #8 ■ run inv_1kPa… success tokens=978 8102ms
13:12:26 #9 ↳ child 01kxw7… success: {"summary":"Tea spread from China…"}
13:12:26 #10 ▶ run inv_1mQb… started (native claude-sonnet-4-5) wake=child_finished
13:12:30 #11 ⚙ tool finish({"result":"Tea spread from China along…"})
13:12:30 #12 → ok finish: Run complete.
13:12:31 #13 ■ run inv_1mQb… success tokens=411 4210ms
Look at seq #8: the researcher's first run ends right after spawning — spawn_agent returned the child's id immediately, never its result, and wait yielded the turn instead of blocking. The summary arrives fourteen seconds later as a child_finished message that starts a brand-new wake (#10). Nothing in teaspill ever blocks or polls — that's the model that makes agents cheap to suspend and safe to crash, and Durable agents & the wake model makes it stick.
You could kill the deployment process between #8 and #9 and restart it: the researcher is asleep, the wake is durable, and the summary still arrives.
Next steps
You've run the whole loop: define → serve → spawn → message → watch → delegate. Three directions from here:
- Concepts — the ideas under everything you just did, in reading order.
- Building agents — the full
defineAgentsurface: tools,onWake, state revisions, testing. - Frontend integration — production timelines in your UI: read tokens, React, fast-join.