Changelog
Agents

Building agents

Define an agent with the Agents SDK — schemas, harness, tools, the onWake hook, state revisions, and serving it to the gateway.

An agent is one defineAgent({...}) call: you declare its type, the shape of its spawn arguments and state, which harness runs its model loop, and which tools it can use. serve(...) compiles that definition into a running service and registers it with the gateway. This page builds one end to end, then covers each piece in depth.

A complete agent

Here is a working agent in full. It researches a topic, and when a message arrives it keeps going. Every field is explained below; read it once to see the shape.

agents/researcher.ts
import { defineAgent, native } from "@teaspill/agents-sdk";
import { z } from "zod";

export const researcher = defineAgent({
  type: "researcher",
  spawnSchema: z.object({ topic: z.string() }),
  state: z.object({ findings: z.array(z.string()).default([]) }),
  harness: native({
    model: "claude-sonnet-4-5",
    systemPrompt:
      "Research the topic you were spawned with. Record findings, then finish.",
    ingressUrl: process.env.TEASPILL_INGRESS_URL!,
  }),
});

That is a deployable agent. defineAgent returns an AgentDefinition; the rest of this page is the rulebook for filling it in.

defineAgent, field by field

defineAgent takes one object. type, state, and harness are required; everything else is optional.

type

The agent type — a stable, lowercase name that identifies this kind of agent everywhere: in entity URLs, in the catalog, in every command. It must match ^[a-z0-9][a-z0-9_-]{0,47}$. An invalid type throws at definition time.

type: "researcher",

spawnSchema

A Zod schema for the arguments passed when the agent is spawned. teaspill validates spawn arguments against it before the agent ever wakes; bad arguments are rejected cleanly at the door, with no retry and no partial agent. Omit it for an agent that takes no arguments.

spawnSchema: z.object({ topic: z.string() }),

state

A Zod schema for the agent's persisted state — the memory it carries across wakes, deploys, and crashes. Keep it bounded: state is durable working memory, not a log (the full history lives on the timeline). This field is required.

state: z.object({
  findings: z.array(z.string()).default([]),
  summary: z.string().optional(),
}),

Changing this schema after agents are live has rules — see state revisions below.

inboxSchemas

Optional Zod schemas for inbound messages, keyed by an application-defined message kind. They are carried into the registration manifest as typed metadata.

By default — with no schema — the platform delivers a send body to your agent verbatim, and the model loop expects the canonical message shape: the same content-block structure the timeline records.

// The default accepted body — what teaspill/spawn, send, and the CLI produce:
{ "kind": "message", "content": [{ "type": "text", "text": "one more angle: green vs black tea" }] }

A schema keyed default (or message) adds one extra check: when a message is a single text block whose text is itself JSON, that JSON is validated against the schema before the wake commits, and a mismatch is rejected cleanly at the door. Any other shape — multiple blocks, non-text content, or text that isn't JSON — passes through unvalidated.

inboxSchemas: {
  // Validates the JSON carried inside a single-text-block message.
  default: z.object({ note: z.string() }),
},
// Accepted: the text block's text parses and matches { note: string }.
{ "kind": "message", "content": [{ "type": "text", "text": "{\"note\":\"focus on green tea\"}" }] }

// Rejected: the JSON is present but the wrong shape.
{ "kind": "message", "content": [{ "type": "text", "text": "{\"foo\":1}" }] }

Accepting shorthand bodies. If you'd rather let callers send loose shorthand like { "text": "…" } instead of the canonical shape, fold it into a canonical message before your agent sees it — the platform never rewrites payloads for you. The reference deployment does exactly this and exports the helper it uses, normalizeLooseMessage, which turns { text } (or any object) into a single-text-block message; it wraps its agent loop's message handling with it, so every demo agent accepts shorthand. Copy that approach when you want the same. This is a deployment-side convenience, not a stable SDK export — treat the reference deployment as the pattern to lift.

harness

The engine that runs the model loop: native(...) or claudeAgentSdk(...). This is the one field you swap to change engines — nothing else in the definition changes. See choosing a harness.

tools

Your own tool definitions, appended after the platform and workspace tools the harness already provides. Reach for this when your agent needs a capability teaspill doesn't ship — a domain API, a search index, a calculator.

tools: [myWeatherTool, mySearchTool],

tenant

The default tenant for this type's entities. One deployment is one tenant, so you rarely set this; it defaults to "default".

Choosing a harness

Two harnesses, both durable, differing in who owns the loop. Pick per agent:

  • native(...) — the default. teaspill owns the loop, works with any model provider, and records the finest-grained recovery. Start here.
  • claudeAgentSdk(...) — runs the loop through the Claude Agent SDK, for when you specifically want Claude Code's session and compaction behavior inside a durable agent.

The trade-offs live in Concepts → Harnesses; the configuration is below.

native(...)

native({
  model: "claude-sonnet-4-5",        // the model id string, or a full model object
  provider: "anthropic",             // default for string model ids
  systemPrompt: "",                 // API-level system prompt (never timeline history)
  contextBudgetTokens: 150_000,      // crossing it triggers summarization
  maxSteps: 40,                      // hard cap on model steps per run
  reasoning: "medium",               // provider thinking level (optional)
  temperature: 0.2,                  // optional
  maxTokens: 4096,                   // optional per-response cap
  platform: true,                    // platform tools (default all; or { include }, or false)
  workspace: false,                  // workspace tools (default off — see below)
  ingressUrl: process.env.TEASPILL_INGRESS_URL!, // needed for spawn_agent / send_message
})
FieldMeaning
modelModel id (or a full model object). Required.
providerProvider for string model ids. Default anthropic.
systemPromptThe model's system prompt. It is never the timeline — history is folded in separately.
contextBudgetTokensCache-inclusive budget; crossing it triggers summarization and the run continues on the folded context.
maxStepsHard cap on model steps in a single run.
platform / workspaceWhich built-in toolsets to expose — see restricting tools.
ingressUrlWhere the agent reaches other agents. Required for spawn_agent / send_message; omit for a leaf agent that only replies.

claudeAgentSdk(...)

claudeAgentSdk({
  model: "claude-sonnet-4-5",
  systemPrompt: "",                 // replaces the Claude Code preset
  sessionStore: "/data/casdk-sessions", // durable session dir on a volume → warm resume
  maxTurns: 30,                      // hard cap on SDK turns per run
  forceCold: false,                  // ops lever: cold-rebuild every wake, no code change
  platform: true,
  workspace: false,
  ingressUrl: process.env.TEASPILL_INGRESS_URL!,
})

The sessionStore directory is what makes resumes warm: point it at a volume that survives restarts. Omit it and the agent keeps an in-process session that every fresh process rebuilds from the timeline — correct, just slower on the first turn. The heavy Claude Agent SDK dependency loads lazily on the first run only, so selecting this harness costs nothing until an agent actually runs.

Both harnesses expose the same platform, workspace, and developer tools, and route every side-effecting tool call through the same exactly-once guarantee. A researcher on the native harness can spawn a summarizer on the Claude Agent SDK harness and neither notices.

The platform tools

Every harness gives the model six coordination tools. You don't write them — they are the agent's built-in vocabulary for working with other agents and ending its turn.

ToolWhat it does
spawn_agent(type, args, id?, workspace?)Spawn a child and return its id immediately — never its result.
send_message(to, message, mode?)Fire-and-forget message to another agent by entity URL; returns on enqueue.
list_children()Read-only view of this agent's known children.
wait(reason?)Yield the turn. Returns immediately — it does not block.
finish(result?, summary?)End the turn and mark the run complete; result goes to the parent.
set_status(status)Update the agent's short status line; non-terminal.

The mental model behind them is the whole wake model: spawn returns an id now, the child's result arrives later as a child_finished message on a future wake, and wait never blocks. Agents never poll — they spawn or send what they need, call wait or finish, and end the turn; the runtime re-wakes them when input arrives.

send_message also accepts mode: "steer", which lets one agent nudge another mid-turn — a parent redirecting a child it just spawned. If the recipient isn't mid-turn, the steer degrades to an ordinary message. This is agent-to-agent only; see Multi-agent patterns.

The workspace tools

An agent that needs a filesystem and a shell gets five more tools when you set workspace: true on the harness. They run inside the agent's workspace — a sandboxed environment fixed at spawn time.

ToolWhat it does
bash(command, …)Run a shell command; returns the exit code and a tail of the output (the full output streams out of band).
read_file(path)Read a UTF-8 text file, relative to the workspace root.
write_file(path, content)Create or overwrite a file; parent directories are created.
edit_file(path, old_string, new_string)Replace text where old_string matches exactly once, or fail loudly.
ls(path?)List a directory; defaults to the workspace root.
harness: native({
  model: "claude-sonnet-4-5",
  workspace: true,   // adds bash / read_file / write_file / edit_file / ls
  ingressUrl: process.env.TEASPILL_INGRESS_URL!,
}),

Every write (bash, write_file, edit_file) happens exactly once no matter how the run is retried; reads need no such guarantee.

Restricting tools

Both platform and workspace accept the same three forms, so you can hand each agent exactly the tools it should have:

// All platform tools (the default):
platform: true,

// A subset — a leaf agent that answers but never spawns:
platform: { include: ["send_message", "wait", "finish", "set_status"] },

// None — a purely deterministic agent with no coordination tools:
platform: false,

// Read-only workspace access:
workspace: { include: ["read_file", "ls"] },

Restricting tools is the cleanest way to shape behavior: a worker that must never spawn children simply doesn't get spawn_agent.

onWake

onWake is an optional hook that runs before the harness, deterministically, on every wake. Use it for logic that shouldn't cost a model call: routing, validation, pure state machines, or the deterministic agents you write for testing.

The hook receives a context with the entity URL, what woke it, the bounded canonical context, a replay-stable clock, and methods to emit events, send, and spawn. It returns one of two outcomes:

  • Return falsy — your emitted events land first, then the harness runs the model as usual (onWake-then-harness).
  • Return { handled: true } — the wake is fully handled and no model runs (onWake-only).
agents/echo.ts
import { defineAgent, native } from "@teaspill/agents-sdk";
import { z } from "zod";

export const echo = defineAgent({
  type: "echo",
  state: z.object({}),
  harness: native({ model: "claude-sonnet-4-5" }),
  onWake: async (ctx) => {
    const last = ctx.canonicalContext.at(-1);
    if (last?.type === "message") {
      await ctx.emit([
        { type: "message", payload: { role: "assistant", content: last.payload.content } },
      ]);
      return { handled: true }; // deterministic — no model call
    }
    // fall through to the harness for anything else
  },
});

An onWake hook that always returns { handled: true } never reaches the harness, so the harness config above is inert — defineAgent still requires the field, but its model is never called and no provider credential is needed. This is exactly how deterministic, fully testable agents are built.

Read the clock through ctx.now() and never Date.now(), so the hook replays identically after a crash.

State revisions

State schemas are additive-only within a revision. A live agent persists state shaped by the revision it was spawned under, so a running deployment may only widen that shape in backward-compatible ways — add optional fields. Removing a field, changing a field's type, or adding a required field is breaking, and requires a new revision.

defineAgent enforces this at definition time against a baseline — the currently-deployed revision and schema — so a mistake is a loud build error, never silent state corruption at runtime.

defineAgent({
  type: "researcher",
  revision: 1,
  state: z.object({ findings: z.array(z.string()) }),
  baseline: { revision: 1, state: previouslyDeployedStateSchema },
  // …
});
// Adding an OPTIONAL field here is fine.
// Removing/retyping a field, or adding a required one, throws StateRevisionError
// unless you bump to revision: 2.

Bumping the revision applies the new shape to new instances only; existing agents keep their revision until they archive. When you don't know the deployed schema, omit baseline — the first deploy has nothing to compare against.

The standalone helpers diffStateSchema and assertStateRevision are exported if you want to run the same check in your own tooling or CI.

Serving and registration

serve(...) compiles your definitions into running services, binds them, listens, and — when you give it registration — registers the deployment with the gateway.

deploy.ts
import { serve } from "@teaspill/agents-sdk";
import { researcher } from "./agents/researcher.js";
import { summarizer } from "./agents/summarizer.js";

await serve({
  agents: [researcher, summarizer],
  deps: platformDeps,  // the deployment's plumbing — see below
  port: 9080,
  registration: {
    gatewayUrl: process.env.TEASPILL_GATEWAY_URL!,
    // The URL Restate dials for every invocation — forwarded as-is, no rewrite.
    deploymentUrl: "http://host.docker.internal:9080",
    apiKey: process.env.TEASPILL_API_KEY,
  },
});

The deps object (CompileDeps) carries the connections the deployment supplies: the projection outbox and the notifier that move an agent's activity out to the timeline and catalog, plus optional pieces like emitDelta (live token streaming) and archiveCatalog. You rarely build these by hand — the reference deployment wires them all from public APIs, and copying it is the intended starting point.

The deploymentUrl is the address Restate dials to reach your service, forwarded verbatim with no rewriting. It must be reachable from inside the stack: use the compose service name on the network, or host.docker.internal:<port> for a host-run service in development. Registering localhost is the classic mistake — the container's localhost is not your host. See Self-hosting for the full networking rules.
Resurrection — bringing an archived agent back with a new message — requires an archive catalog wired into deps (archiveCatalog). Without it, an agent can archive but can never come back. The reference deployment wires this for you; if you assemble deps yourself, include it.

serve does a single registration attempt and throws on failure. For local development, let the teaspill dev CLI drive it instead — it waits for the gateway to be healthy and retries registration with backoff, which removes the boot-order race.

Testing your agent

An AgentDefinition exposes compileConfig(deps), which produces the configuration the coordination layer runs — so you can drive an agent's handlers directly with fake dependencies and a fake context, no live stack required. onWake-only agents are the easiest to test this way, since they never call a model.

For end-to-end behavior — crash-resume, fan-out gather, workspace-exec durability — the conformance kit ships ready-made deterministic agents and the scenario checks that assert on them; the reference deployment serves them, and its own suite runs entirely offline.

Next steps

Your agents are running. To spawn them, watch their timelines, and stream their activity into a UI, continue to Frontend integration. To deploy the stack they run on, see Self-hosting.