Run a durable browser agent with Restate

Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.

examples/restate-agent-ts
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

This recipe runs a Restate Virtual Object named ResearchSession. Its answer handler keeps scraped observations in object state, wraps OpenAI planning calls in durable ctx.run steps, and calls Steel's scrape API as the browser tool. If the service process crashes after Steel has fetched a page, Restate replays the journal entry instead of scraping the same page again.

The agent loop is deliberately small:

  1. 1Ask the model whether to scrape another URL or finish.
  2. 2Scrape the chosen URL with Steel and store a compact markdown observation.
  3. 3Repeat up to maxSteps, then ask the model for a cited answer.

history is a shared handler, so you can inspect the object state without blocking the exclusive answer handler.

Run it

Install the Restate server and CLI if you do not already have them:

npm install --global @restatedev/restate-server@latest @restatedev/restate@latest

Start Restate in one terminal:

restate-server

Start the TypeScript service in a second terminal:

cd examples/restate-agent-ts
cp .env.example .env # set STEEL_API_KEY and OPENAI_API_KEY
npm install
npm start

Register the service and invoke a session from a third terminal:

restate deployments register http://localhost:9080 --force --yes
curl localhost:8080/restate/call/ResearchSession/demo/answer \
--json '{"question":"Summarize the main stories on this page and cite the source URL.","seedUrl":"https://news.ycombinator.com","maxSteps":2}'

Get a Steel key at app.steel.dev/settings/api-keys. OPENAI_MODEL defaults to gpt-5.5; change it in .env if your account uses a different model.

Your output varies. Structure looks like this:

{
"answer": "The page is a ranked list of current Hacker News stories...",
"sources": ["https://news.ycombinator.com/"],
"observations": 1
}

Open the Restate UI at http://localhost:9070 and inspect the invocation journal. You should see separate entries for plan step 1, scrape https://news.ycombinator.com/, and the final model call.

Make it yours

  • Use another start page. Set SEED_URL in .env or pass seedUrl in the request body.
  • Cap browser spend. Keep maxSteps low. Each step can call OpenAI once and Steel once.
  • Persist richer state. Add links, screenshots, or extracted fields to the Observation type and save them through ctx.set("state", ...).
  • Add a reset handler. Add an exclusive handler that calls ctx.clear("state") when you want a fresh session key.

restate-agent-py, restate-agent-go, and restate-agent-rs implement the same durable research session in other languages. Restate's AI overview, Durable Agents, and Durable Sessions pages cover the primitives used here.

examples/restate-agent-py
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

ResearchSession is a Restate Virtual Object whose state is the set of pages already scraped for that session key. The answer handler uses Pydantic models for request and response payloads, ctx.run_typed for the model planner and Steel scrape tool, and ctx.set to persist observations after every successful page fetch.

The browser tool is Steel's direct scrape endpoint, not a local browser driver. The agent sees markdown observations, chooses whether another scrape is useful, and returns a cited answer once the stored observations are enough.

Run it

Install the Restate server and CLI:

npm install --global @restatedev/restate-server@latest @restatedev/restate@latest

Start Restate:

restate-server

In another terminal, run the Python service:

cd examples/restate-agent-py
cp .env.example .env # set STEEL_API_KEY and OPENAI_API_KEY
python -m venv .venv
source .venv/bin/activate
pip install -e .
python main.py

Register and call the object:

restate deployments register http://localhost:9080 --force --yes
curl localhost:8080/restate/call/ResearchSession/demo/answer \
--json '{"question":"Summarize the main stories on this page and cite the source URL.","seedUrl":"https://news.ycombinator.com","maxSteps":2}'

Your output varies. Structure looks like this:

{
"answer": "The page lists current Hacker News submissions and discussion links...",
"sources": ["https://news.ycombinator.com/"],
"observations": 1
}

The same session key keeps its observations list. Call the shared history handler to inspect it:

curl localhost:8080/restate/call/ResearchSession/demo/history --json '{}'

Make it yours

  • Swap the target. Change SEED_URL or send a different seedUrl in the request.
  • Use a different model. Set OPENAI_MODEL in .env; the code uses the OpenAI Responses API directly.
  • Change the state shape. Extend Observation with fields you want to reuse across calls, then keep writing ResearchState.model_dump() to Restate.
  • Make failures terminal. If a bad user URL should not retry forever, catch it and raise a Restate terminal error before calling Steel.

restate-agent-ts, restate-agent-go, and restate-agent-rs show the same loop in other SDKs. Restate documents the underlying patterns in Durable Agents and Durable Sessions. The Python service is served as ASGI with Hypercorn.

examples/restate-agent-rs
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

The Rust variant uses Restate's macro-based service definition. The ResearchSession trait declares an exclusive answer handler and a shared history handler, then ResearchSessionImpl supplies the agent loop. Values that cross Restate's journal use Json<T>, which keeps the typed structs local while letting the SDK serialize durable step results and object state.

Steel does the page fetch. The agent stores a compact markdown observation for each scraped URL, so a repeated call with the same session key can reuse prior context.

Run it

Start Restate:

npm install --global @restatedev/restate-server@latest @restatedev/restate@latest
restate-server

Run the Rust service in another terminal:

cd examples/restate-agent-rs
cp .env.example .env # set STEEL_API_KEY and OPENAI_API_KEY
cargo run

Register and invoke it:

restate deployments register http://localhost:9080 --force --yes
curl localhost:8080/restate/call/ResearchSession/demo/answer \
--json '{"question":"Summarize the main stories on this page and cite the source URL.","seedUrl":"https://news.ycombinator.com","maxSteps":2}'

Your output varies. Structure looks like this:

{
"answer": "The page contains current Hacker News story links and metadata...",
"sources": ["https://news.ycombinator.com/"],
"observations": 1
}

The first build pulls restate-sdk, steel-rs, reqwest, and their transitive dependencies. Later runs start quickly.

Make it yours

  • Return stricter evidence. Add fields to Observation and let Serde carry them through Json<Observation>.
  • Bound the loop. MAX_STEPS defaults to 2, and the handler clamps request values to 1 through 4.
  • Treat user errors differently. Convert invalid URLs to TerminalError when you want Restate to stop retrying instead of treating them as transient failures.
  • Compose with workflows. Keep this Virtual Object as the session store, then call it from a longer Restate workflow that schedules or fans out research.

restate-agent-ts, restate-agent-py, and restate-agent-go cover the same idea in other languages. Restate's Rust SDK docs describe the macros, Json<T>, and HttpServer used in this recipe.

examples/restate-agent-go
Contributors: Updated
Terminal

Scaffolds a starter project locally. Requires the Steel CLI.

The Go version exposes ResearchSession through the Restate SDK's reflection API. Answer is an exclusive Virtual Object handler, so calls for the same session key are serialized while it reads and writes state. The durable work happens in restate.Run: one step asks OpenAI for the next action, another step calls Steel Scrape, and the result is written back to object state.

That shape matters for browser jobs. A successful Steel scrape is a side effect with cost and latency. Once Restate journals the scrape <url> step, a process restart resumes from the recorded observation instead of repeating the HTTP call.

Run it

Install and start Restate:

npm install --global @restatedev/restate-server@latest @restatedev/restate@latest
restate-server

Run the Go service in another terminal:

cd examples/restate-agent-go
cp .env.example .env # set STEEL_API_KEY and OPENAI_API_KEY
go mod tidy
go run .

Register the deployment and call the exported Answer handler:

restate deployments register http://localhost:9080 --force --yes
curl localhost:8080/restate/call/ResearchSession/demo/Answer \
--json '{"question":"Summarize the main stories on this page and cite the source URL.","seedUrl":"https://news.ycombinator.com","maxSteps":2}'

Your output varies. Structure looks like this:

{
"answer": "Hacker News is showing a ranked feed of current submissions...",
"sources": ["https://news.ycombinator.com/"],
"observations": 1
}

Use the Restate UI at http://localhost:9070 to inspect the journal. The handler names are capitalized because Go reflection exposes exported methods.

Make it yours

  • Tune retries. Add restate.WithMaxRetryDuration or restate.WithInitialRetryInterval to the restate.Run calls when an external API should stop retrying.
  • Keep more evidence. Extend Observation with extracted links or screenshot URLs, then persist them in ResearchState.
  • Split tools out. Move Steel scraping into another Restate service if several agents should reuse the same browser primitive.
  • Use session keys intentionally. demo, customer-123, and incident-456 each get isolated state.

restate-agent-ts, restate-agent-py, and restate-agent-rs implement the same durable agent loop. For the Restate APIs used here, see Go services, durable steps, and state.

Build a browser agent with Eino

Use Steel with the ByteDance Eino framework to build a ReAct agent that calls Steel's scrape API as a tool to research and answer a web question.