Read the numbered files in src/lessons from 01 onward. Each file
stands alone; you do not need to import or edit previous lessons. Lessons 01–10
build up the agent and workflow primitives. The later lessons are goal-based
recipes you can read in any order. services.ts lists
the services in tutorial order, app.ts serves them, and
shared.ts configures the model.
Each prompt handler declares a Zod input schema with a scenario-specific
default for the request editor. The research example also supplies demo sources. Rediscover
the endpoint after changing a schema so Restate receives the updated defaults.
Prompt inputs use JSON objects with .default(...) on their fields. This keeps
string values correctly quoted in the Restate UI request editor, ready to send.
| Lesson | New concept | Source |
|---|---|---|
| 01 | One agent, one request | 01_hello.ts |
| 02 | A tool with validated arguments | 02_tools.ts |
| 03 | Conversation history between requests | 03_conversation.ts |
| 04 | Validated, typed output | 04_structured_output.ts |
| 05 | A bounded draft-and-review loop | 05_evaluator_optimizer.ts |
| 06 | Human approval | 06_human_approval.ts |
| 07 | Concurrent agents | 07_racing_agents.ts |
| 08 | Parallel worker RPCs and synthesis | 08_research_report.ts |
| 09 | Conversation and turn controls | 09_support_session.ts |
| 10 | A whole Restate service as one tool | 10_service_tool.ts |
| 11 | A coding agent in a Modal sandbox | 11_coding_agent.ts |
| 12 | Route to a specialist | 12_routing.ts |
| 13 | Enforce a tool policy | 13_tool_guardrail.ts |
| 14 | Search the public web | 14_web_search.ts |
| 15 | Exchange feedback over durable RPC | 15_peer_review_rpc.ts |
| 16 | Follow an agent's progress | 16_progress_feed.ts |
| 17 | Tools from a remote MCP server | 17_mcp_tools.ts |
| 18 | Serve an AG-UI client | 18_ag_ui.ts |
| 19 | Give an agent skills from a folder | 19_skills.ts |
If you have a specific goal, start here:
| Goal | Smallest lesson |
|---|---|
| Give an agent an API-backed tool | 02 |
| Remember earlier requests | 03 |
| Require a person before a tool runs | 06 |
| Split research across workers | 08 |
| Steer or interrupt a running agent | 09 |
| Route a request by its meaning | 12 |
| Enforce a budget before an action | 13 |
| Answer with public sources | 14 |
| Let agents review each other's work | 15 |
| Show progress while an agent works | 16 |
| Use tools from an MCP server | 17 |
| Connect a UI such as CopilotKit | 18 |
| Teach an agent task-specific skills | 19 |
Start a Restate server (for example pnpm dlx @restatedev/restate-server), then
from the workspace root:
pnpm install --frozen-lockfile
export OPENAI_API_KEY=...
# To change providers, edit the model in the lesson; see "Model configuration" below.
pnpm devIn another terminal, register or rediscover the endpoint:
pnpm dlx @restatedev/restate deployments register http://localhost:9080
RESTATE_CALL_URL=http://localhost:8080If your server uses a scoped call prefix, use that instead, for example:
RESTATE_CALL_URL='http://[fd07:b51a:cc66::2]:8080/restate/call'Start with agent({model}) and a single handler. The handler passes the request
to the agent and returns its answer as a JSON string. Each call starts with no
conversation history.
curl --globoff "$RESTATE_CALL_URL/Hello/ask" --json '{"message":"Say hello in one sentence."}'The response is just the answer, such as "Hello!". A run that stopped or was
interrupted has no answer, so the handler returns its short summary instead:
if (result.status !== "completed") return result.output ?? result.reason;
return result.output;Add instructions and a tools map. lookupTicket declares its input with Zod
and returns a fixed demo record. The agent chooses when to call it and uses the
result in its answer.
curl --globoff "$RESTATE_CALL_URL/TicketLookup/ask" \
--json '{"message":"What is the status of ticket T-42?"}'The lesson uses asyncTool with an ordinary async execute(...) callback.
The SDK journals its result, so you can replace the fixed record with an async
API call directly. The optional second argument supplies application context,
call metadata, and an AbortSignal for cancellable I/O.
The tool sets retry explicitly: up to five attempts, starting with a 250 ms
retry delay and doubling between attempts. See retry policies
for model calls, individual actions, and handler calls.
Use tool when execution needs to compose SDK operations in a generator with
yield*. Use handlerTool for typed calls to other Restate handlers.
Change the service to a keyed Restate object. Before the run, load its saved
messages. Pass them to the agent, then save result.messages afterward. Return
only the reply; the history stays in object state.
curl --globoff "$RESTATE_CALL_URL/ChatSession/bob/ask" --json '{"message":"My name is Bob."}'
curl --globoff "$RESTATE_CALL_URL/ChatSession/bob/ask" --json '{"message":"What is my name?"}'Both requests use bob, so the second sees the first exchange. A different key gets
its own history. This lesson stores the full history; the support-session lesson
adds conversation compaction and lifecycle controls.
Add an output schema to describe the result. The SDK validates the model's
answer and gives the caller the typed object directly.
curl --globoff "$RESTATE_CALL_URL/TicketTriage/classify" \
--json '{"message":"I was charged twice for my subscription."}'A successful response is an object such as
{"category":"billing","summary":"The customer reports a duplicate subscription charge."}
without a run-result wrapper. Lesson 05 uses this same mechanism to connect two agents.
One agent returns {code}; another returns {approved, feedback}. The handler
passes the draft and feedback into the next iteration, stopping at acceptance
or after three attempts. Each stage has a distinct run name.
curl --globoff "$RESTATE_CALL_URL/CodeReview/generate" \
--json '{"task":"Read the first item of an array safely, returning undefined when it is empty."}'The response is the accepted code as a string. If all three reviews reject the
draft, the response explains that it still needs work and includes the latest
feedback and code. Acceptance is the model's review decision; the code is
returned as text and never executed. A stopped or interrupted run throws a
TerminalError, so it fails the stage instead of advancing the workflow.
A run completes one task; a step is one planning model call inside that run. This loop starts fresh runs for successive drafts. See the glossary for the lifecycle terms.
The lesson adds needsApproval: true to one
closeTicket tool. The tool simulates an async API call; no external ticketing
system is involved.
Start a request in one terminal:
curl --globoff "$RESTATE_CALL_URL/TicketApproval/bob/ask" \
--json '{"message":"Close ticket T-42 after I approve it."}'This request waits. Before executing the tool, the SDK calls onApproval(request)
and then waits on a durable signal. The lesson saves the request directly in
object state. In a second terminal, inspect it:
curl --globoff --request POST "$RESTATE_CALL_URL/TicketApproval/bob/status"status returns the stored request, containing the proposed tool name and arguments,
plus invocationId and signalName, or null when no request is stored. Copy
both identifiers from the request and submit a decision:
curl --globoff "$RESTATE_CALL_URL/TicketApproval/bob/approve" --json '{
"invocationId": "<copy from status>",
"signalName": "<copy from the same request>",
"approved": true
}'Approval resumes the waiting tool. The original
ask request returns only the agent's reply. To try rejection, start a request
under another key and submit "approved": false: the tool is skipped and the
agent receives a denied result. The approve handler directly resolves the
supplied invocation's signal and returns true to acknowledge sending it.
It does not check whether that request is still pending.
status and approve are shared handlers, so they run while the exclusive
ask handler is waiting. The example stores the latest approval request in one
state entry and clears it when the run ends, whether or not it succeeded. The SDK has no default
approval timeout; this lesson waits until a decision or invocation cancellation.
onApproval is also available on the agent definition. It receives a copy of the
request and the application's run context. The SDK awaits publication before waiting for a decision.
To publish to an event bus, use a journaled run operation or a durable service
call in the callback.
Spawn two agents and use restate.any to take the first successful answer. An
error or exhausted run cannot win. Interrupt and join the other task so its
cleanup finishes before returning; if both fail, return a terminal error.
curl --globoff "$RESTATE_CALL_URL/RacingAgents/answer" \
--json '{"question":"How should a durable workflow handle external side effects?"}'The response is the winning answer as a string. Both agents start, so the pattern can
consume more tokens than a single run. They share a model with different
instructions; change their model fields to compare providers.
The planner returns 2–4 questions. Start a ResearchWorker RPC for each, await
them concurrently, and synthesize the findings. Each worker gets its own source
context and reads supplied evidence through tools. A guard rejects citations to
sources it did not read. Parent cancellation also cancels unfinished worker calls.
curl --globoff "$RESTATE_CALL_URL/ResearchReport/generate" --json '{
"topic": "Explain recovery and external side effects",
"sources": [
{
"id": "journal",
"title": "Durable execution",
"text": "Restate journals completed operations and reuses their results during replay."
},
{
"id": "effects",
"title": "Idempotency",
"text": "An external effect can repeat if its result was not committed. Use stable idempotency keys for external mutations."
}
]
}'The response is the final report as a string. Worker findings remain internal. This example uses supplied documents and does not browse the web. Citation IDs are checked against tool reads; factual quality and final prose still depend on the model.
A keyed object owns one conversation per key. doTurn runs one turn at a time
and stores the messages in object state, compacting long conversations. While a
turn runs, shared handlers inspect and control it: status shows the active
turn and any pending approval, steer adds a message, interrupt stops the
turn with a short summary, and approve answers an approval request. Ticket
effects are demonstrations.
curl --globoff "$RESTATE_CALL_URL/AiSession/bob/doTurn" \
--json '{"message":"Look up ticket T-42, then close it after my approval."}'doTurn returns only the answer or interruption summary. Read conversation
history through history. steer and interrupt have example messages.
For approve, copy invocationId and signalName from status().approval and set
approved to true to allow the action; its default is false.
serviceTool(refundIface) turns a Restate interface into a single tool. The
model picks a handler (getPolicy or issueRefund) and passes its input; the
handler descriptions and input schemas come from the interface contract, so no
extra wiring is needed. Pass {iface, handler: "issueRefund"} to scope a tool
to one handler instead. Virtual objects and workflows also take a key.
curl --globoff "$RESTATE_CALL_URL/RefundAgent/ask" \
--json '{"message":"Refund order O-99 for $12.50 and tell me the refund policy."}'Each call is a durable child invocation. Interrupting the run cancels an unfinished call, and a failed handler is reported to the model as a tool error.
CodingAgent gives an agent three tools, readFile, writeFile, and
runCommand, backed by a Modal Sandbox.
The handler opens a sandbox, runs the agent, and releases the sandbox:
function* (task) {
const sandbox = yield* connect();
try {
const result = yield* coder.run(task, {context: {sandbox}});
if (result.status !== "completed") return result.output ?? result.reason;
return result.output;
} finally {
yield* release(sandbox);
}
}connect creates the sandbox in a journaled step, so a retried or replayed
request reuses it instead of creating another. Only the sandbox ID is
journaled; connect then wraps it in a Modal Sandbox handle, which needs no
I/O, and the tools use that handle from the run context. release terminates
the sandbox in a journaled step, so it runs once even if the request fails. Each tool is an
asyncTool, so its result is journaled like any other. runCommand makes one
attempt: a failure goes back to the model instead of running the command again.
Authenticate with modal token new, or set MODAL_TOKEN_ID and
MODAL_TOKEN_SECRET, then:
curl --globoff "$RESTATE_CALL_URL/CodingAgent/ask" \
--json '{"task":"Write primes.js that prints the first 10 primes, then run it."}'Use a small structured-output agent to choose billing, account, or product,
then let the chosen specialist answer. The application owns the branch:
function* (question) {
const routed = yield* route.run(question);
if (routed.status !== "completed") throw new TerminalError(`Router ${routed.status}`);
const result = yield* specialists[routed.output.specialist].run(question);
if (result.status !== "completed") return result.output ?? result.reason;
return result.output;
}curl --globoff "$RESTATE_CALL_URL/SupportRouter/answer" \
--json '{"question":"I cannot log into my account. How can I regain access?"}'The specialist starts a fresh run with only the question. Use handoff when
the specialist should take over the same run and history.
bookHotel.before checks the authoritative quote and budget from application
context before execute runs. The model chooses a hotel ID, but cannot set its
price or bypass the limit by supplying a cheaper number. A denied tool call is
returned to the model so it can explain why no booking was made.
*before({hotelId}, {context}) {
if (hotelId !== context.hotelId) return "Only the quoted hotel can be booked";
if (context.quotedTotalCents > context.maxTotalCents)
return "The quote exceeds the budget";
}curl --globoff "$RESTATE_CALL_URL/GuardedBooking/ask" \
--json '{"hotelId":"H-42","quotedTotalCents":12500,"maxTotalCents":10000}'Raise maxTotalCents to 15000 to see the allowed path. The reservation is a
demo result; connect a real booking API only after obtaining the quote from your
own trusted service.
14_web_search.ts defines searchWeb locally with
asyncTool. Its execute function calls the Tavily Search API
using fetch, validates the response, and returns up to five bounded snippets.
asyncTool journals the result, so replay does not repeat a completed search.
This is an example of integrating an API, not a built-in library tool.
Set TAVILY_API_KEY in the example server's environment before starting it.
The key is read inside execute and is never included in the model's tool arguments.
Keep private data out of queries; the agent treats snippets as untrusted evidence
and cites the returned URLs.
curl --globoff "$RESTATE_CALL_URL/WebResearch/answer" \
--json '{"question":"Find the Restate TypeScript SDK docs and cite their URLs."}'PeerReview asks PeerAuthor for a draft, sends that draft to two
PeerReviewer invocations in parallel, and sends their labeled feedback back
to the author for one revision. Each role is a normal Restate service. The
conversation between them is explicit in typed request and response bodies:
sequenceDiagram
participant Lead as PeerReview
participant Author as PeerAuthor
participant Reviewer as PeerReviewer
Lead->>Author: write(brief)
Author-->>Lead: draft
par requirements
Lead->>Reviewer: review(brief, draft, requirements)
Reviewer-->>Lead: feedback
and clarity
Lead->>Reviewer: review(brief, draft, clarity)
Reviewer-->>Lead: feedback
end
Lead->>Author: write(brief, draft, both reviews)
Author-->>Lead: revised answer
curl --globoff "$RESTATE_CALL_URL/PeerReview/answer" \
--json '{"brief":"Write a two-sentence release note for a feature that retries transient failures and resumes completed steps from a journal."}'The caller receives only the revised answer. Restate journals each RPC, so a replay does not repeat completed author or reviewer calls. There is no shared mailbox or bespoke agent-to-agent protocol.
The agent's onProgress handler sends each event to a ProgressFeed object.
Its shared subscribe handler waits for the next event.
curl --globoff --request POST "$RESTATE_CALL_URL/ProgressFeed/audit-1/subscribe" &
curl --globoff "$RESTATE_CALL_URL/InventoryAudit/audit" --json '{"job":"audit-1"}'mcpTools lists a remote server's tools once, journals the catalog, and turns
each tool into a journaled step. The lesson gives an agent the tools of the
Restate docs server at https://docs.restate.dev/mcp.
curl "$RESTATE_CALL_URL/DocsGuide/ask" --json '{"question":"What is a virtual object?"}'AG-UI is an open protocol between agents and user
interfaces; CopilotKit is one client. An AgUiThread object, one per AG-UI
thread, runs each turn and appends the AG-UI events of its work to its state.
agUiHandler is AG-UI's HTTP transport: it takes a RunAgentInput and streams
the turn's events as server-sent events. app.ts serves it on port 9081
and calls the Restate ingress at RESTATE_CALL_URL (default
http://localhost:8080); export it before pnpm dev if your server uses a
scoped call prefix.
A run does the first of these that applies: resume answers an approval;
tool messages last answer the client tools the turn waits for; a user
message last starts a turn; anything else reconnects.
A run with a user message last sends it as a turn, once per message ID: a
retried run, or a client that reconnects with the same messages, follows the
same turn. A run with no new message reconnects: it gets the conversation as
MESSAGES_SNAPSHOT, then the turn in progress, including an approval that is
still open.
An approval ends the AG-UI run with an interrupt. The turn waits on a durable
signal meanwhile, and a run with resume answers it
(payload: {"approved": true}) and follows the turn on, including what the
turn did while no stream was open. Closing a stream never stops a turn.
curl -N localhost:9081 --json '{"threadId":"t1","runId":"r1","messages":[{"id":"m1","role":"user","content":"Cancel order O-7."}]}'
curl -N localhost:9081 --json '{"threadId":"t1","runId":"r2","messages":[],"resume":[{"interruptId":"<id from the interrupt>","status":"resolved","payload":{"approved":true}}]}'The client's tools, such as CopilotKit's frontend tools, become tools of the
turn. The model calls one like any other tool; the run ends with the call, the
client runs it and starts a run with the result as a tool message, which
resolves the signal the turn waits on.
The handler polls the object's state, which keeps the lesson small. It ignores
the client's context and state: the object holds the conversation.
A skill is instructions for one kind of task, such as writing release notes.
agent({skills}) takes a SkillSource: the run lists its skills once, puts
each name and description after the agent's instructions, and gives the model
load_skill to load one when a task needs it, and read_skill_file for the
files a skill refers to. Every source call is journaled, and the list holds
for the whole run, so the cached prompt prefix does too.
The SDK does not read files itself. This lesson's folderSkills reads
skills/, laid out like Anthropic's Agent Skills: one folder per
skill, with a SKILL.md whose frontmatter gives its name and description.
Skills defined in code need no folder: use skills([{name, description, instructions, files}]) from @restatedev/agentlib.
curl "$RESTATE_CALL_URL/SkilledWriter/write" --json '{"request":"Release notes for 2.4.0: added CSV export, fixed the login redirect loop, bumped lodash."}'A skill's scripts are out of scope: the SDK offers no shell to run them. An agent with a sandbox, as in lesson 11, can copy a skill in and run them.
Each lesson defines its own model with aiModel, so the provider and retry
policy are visible alongside the agent. The examples use OpenAI Responses
and read OPENAI_API_KEY when the first call is made; importing a lesson
requires no credentials.
To choose another model or provider, edit that lesson's definition. For
example, use Anthropic with ANTHROPIC_API_KEY:
import {anthropic} from "@ai-sdk/anthropic";
import {aiModel} from "@restatedev/agentlib/provider";
const model = aiModel({
model: anthropic("claude-sonnet-5-5"),
retry: {maxAttempts: 3},
});For Bedrock, use createAmazonBedrock({region: "us-east-1"})("<model id>")
from @ai-sdk/amazon-bedrock with AWS credentials. For a Chat Completions-compatible
server, use createOpenAI({baseURL: "https://your-server/v1", apiKey: "..."}).chat("<model id>")
from @ai-sdk/openai. Pass provider-specific settings through
aiModel({providerOptions, ...}).
There is no shared AGENT_MODEL selector; each example's model configuration
is independent.
Configure retries at the operation that can fail; there is no agent-wide retry
default. maxSteps limits the model/tool loop, not retries.
| Operation | Where to configure it | Example |
|---|---|---|
| Ordinary async tool | asyncTool({retry, ...}) |
02_tools.ts |
| Model call | aiModel({retry, ...}) |
01_hello.ts |
| Individual action | restate.run(action, {name, retry}) |
Below |
| Handler-backed tool | retryPolicy on the called service or handler |
— |
Lesson 02 uses this policy; the same shape works for model adapters and individual actions:
const retry = {
maxAttempts: 5, // Includes the initial attempt.
initialInterval: {milliseconds: 250},
exponentiationFactor: 2,
maxInterval: {seconds: 5},
maxDuration: {seconds: 30},
};
// Compose this operation from a handler or tool.
function* performWithRetry() {
return yield* restate.run(() => performAction(), {name: "Perform action", retry});
}Without an explicit policy, asyncTool and the model adapters use three
attempts. A supplied policy replaces that default; specify maxAttempts when
you want a fixed attempt limit. The retry duration limits retries, not the
execution time of one attempt. Pass callId as an idempotency key for external
writes, since a retried attempt can repeat an effect.
A throttled model call waits as long as the provider asks before its next
attempt: aiModel reads the retry-after-ms and retry-after response
headers, and for Gemini the RetryInfo delay in the error body. That wait
replaces the backoff for that retry; the attempt limit and retry duration still
apply.
For asyncTool, an ordinary thrown Error follows the retry policy.
ToolError is a business error returned to the model without retrying the
callback; TerminalError stops retrying. The policy covers execute; SDK
operations in before and after configure their own retries. Exhausting an
action's retry policy raises a TerminalError.
A tool that ends with a TerminalError (thrown directly, after exhausted
retries, or from a failed child handler) does not fail the agent. The model
receives {status: "error", code: "tool_failed", message} and decides what to
do next. Restate cancellation and agent limits still end the run.
handlerTool relies on the called handler's invocation policy: set
retryPolicy on the called service or handler, with onMaxAttempts: "kill"
to terminate after exhaustion or "pause" to leave the invocation paused for
manual intervention.
The advanced workflow lessons adapt patterns from
restatedev/ai-examples, inspected at
commit 60d1eda2e5f3aae96db2efeba498e52367a01648:
| Local example | Upstream pattern | Changes in this implementation |
|---|---|---|
| Research report | workflow-orchestrator.ts | Typed bounded plan, supplied evidence, source tools and guards, cancellation bridge |
| Code review | workflow-evaluator-optimizer.ts | Structured decisions, previous draft carried forward, explicit exhaustion with the last draft retained |
| Racing agents | racing-agents.ts | Embedded operations, first completed success, cancellation with joined cleanup |
The later goal-based lessons draw on the same patterns described by Anthropic's workflow guide and OpenAI's tool guardrails. They use this library's Restate handlers and tools rather than copying another framework's control flow.
Lesson 15 applies Restate's service-to-service calls to a bounded author-reviewer exchange. It combines durable RPC, parallel feedback, and one explicit revision without extra messaging infrastructure.
pnpm test:integration registers these exported services with the official
RestateTestEnvironment and a real RestateContainer().alwaysReplay().disableRetries(). Tests
replace the model adapter import with fixture models using Node's module mocking.
They verify the introductory lessons, approval and denial before effects, rejection of stale decisions,
conversation isolation, parallel RPC execution, committed effects occurring once,
feedback propagation, bounded exhaustion, first-success racing, and loser cleanup.
The tests need Docker and make no paid model calls.
To modify a workflow, edit its agents and handlers directly. The example integration tests invoke those same exported services through the Restate client and check returned results and observable effects.