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.
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:
- 1Ask the model whether to scrape another URL or finish.
- 2Scrape the chosen URL with Steel and store a compact markdown observation.
- 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-tscp .env.example .env # set STEEL_API_KEY and OPENAI_API_KEYnpm installnpm start
Register the service and invoke a session from a third terminal:
restate deployments register http://localhost:9080 --force --yescurl 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_URLin.envor passseedUrlin the request body. - Cap browser spend. Keep
maxStepslow. Each step can call OpenAI once and Steel once. - Persist richer state. Add links, screenshots, or extracted fields to the
Observationtype and save them throughctx.set("state", ...). - Add a reset handler. Add an exclusive handler that calls
ctx.clear("state")when you want a fresh session key.
Related
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.
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-pycp .env.example .env # set STEEL_API_KEY and OPENAI_API_KEYpython -m venv .venvsource .venv/bin/activatepip install -e .python main.py
Register and call the object:
restate deployments register http://localhost:9080 --force --yescurl 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_URLor send a differentseedUrlin the request. - Use a different model. Set
OPENAI_MODELin.env; the code uses the OpenAI Responses API directly. - Change the state shape. Extend
Observationwith fields you want to reuse across calls, then keep writingResearchState.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.
Related
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.
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@latestrestate-server
Run the Rust service in another terminal:
cd examples/restate-agent-rscp .env.example .env # set STEEL_API_KEY and OPENAI_API_KEYcargo run
Register and invoke it:
restate deployments register http://localhost:9080 --force --yescurl 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
Observationand let Serde carry them throughJson<Observation>. - Bound the loop.
MAX_STEPSdefaults to2, and the handler clamps request values to1through4. - Treat user errors differently. Convert invalid URLs to
TerminalErrorwhen 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.
Related
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.
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@latestrestate-server
Run the Go service in another terminal:
cd examples/restate-agent-gocp .env.example .env # set STEEL_API_KEY and OPENAI_API_KEYgo mod tidygo run .
Register the deployment and call the exported Answer handler:
restate deployments register http://localhost:9080 --force --yescurl 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.WithMaxRetryDurationorrestate.WithInitialRetryIntervalto therestate.Runcalls when an external API should stop retrying. - Keep more evidence. Extend
Observationwith extracted links or screenshot URLs, then persist them inResearchState. - 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, andincident-456each get isolated state.
Related
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.
Related recipes
Expose a Steel browser to any MCP client
Build a Model Context Protocol server in Go with the official SDK and chromedp that hands any MCP client a Steel cloud browser through explicit session-handle tools.
Build a browser agent with Genkit
Use Steel with Genkit Go to build a tool-calling agent that navigates and extracts from a chromedp-backed browser and completes a web task.
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.