Skip to content

Latest commit

 

History

History

examples

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

README.md

Agent tutorial

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

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 dev

In another terminal, register or rediscover the endpoint:

pnpm dlx @restatedev/restate deployments register http://localhost:9080
RESTATE_CALL_URL=http://localhost:8080

If your server uses a scoped call prefix, use that instead, for example:

RESTATE_CALL_URL='http://[fd07:b51a:cc66::2]:8080/restate/call'

01 — Run your first agent

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;

02 — Give the agent a tool

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.

03 — Remember earlier requests

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.

04 — Return structured data

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.

05 — Compose a draft-and-review loop

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.

06 — Wait for human approval

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.

07 — Race concurrent agents

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.

08 — Delegate to parallel workers

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.

09 — Run a controlled support session

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.

10 — Expose a service as one tool

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.

11 — Code in a Modal sandbox

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."}'

12 — Route a request to a specialist

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.

13 — Enforce a policy before an action

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 — Search the web with a custom tool

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."}'

15 — Let agents exchange feedback over RPC

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

16 — Follow an agent's progress

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

17 — Use tools from an MCP server

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?"}'

18 — Serve an AG-UI client

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.

19 — Give an agent skills from a folder

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.

Model configuration

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.

Retry policies

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.

Provenance and validation

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.