# Run a durable browser agent with Restate
URL: /cookbook/restate-agent

---
title: Run a durable browser agent with Restate
description: Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
---

<RecipeJsonLd slug="restate-agent" title={"Run a durable browser agent with Restate"} description={"Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions."} authors={[{"handle":"junhsss","name":"Jun Ryu"}]} datePublished="2026-06-29" dateModified="2026-06-29" sourceUrl="https://github.com/steel-dev/steel-cookbook/tree/e86cfbf8ba715cdbbc49fc2ef13e9fd7798695dc/examples/restate-agent-ts" />

<Tabs items={['TypeScript', 'Python', 'Rust', 'Go']} groupId="lang" persist updateAnchor className="cookbook-concept-tabs">

<Tab id="typescript" className="cookbook-concept-tab">

<RecipeMeta href="https://github.com/steel-dev/steel-cookbook/tree/e86cfbf8ba715cdbbc49fc2ef13e9fd7798695dc/examples/restate-agent-ts" path="examples/restate-agent-ts" authors={[{"handle":"junhsss","name":"Jun Ryu","avatar":"https://github.com/junhsss.png?size=40"}]} updated="2026-06-29" />

<RecipeQuickstart slug="restate-agent-ts" />

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. Ask the model whether to scrape another URL or finish.
2. Scrape the chosen URL with Steel and store a compact markdown observation.
3. Repeat 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:

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

Start Restate in one terminal:

```bash
restate-server
```

Start the TypeScript service in a second terminal:

```bash
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:

```bash
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](https://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:

```json
{
  "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.

## Related

[restate-agent-py](/cookbook/restate-agent), [restate-agent-go](/cookbook/restate-agent), and [restate-agent-rs](/cookbook/restate-agent) implement the same durable research session in other languages. Restate's [AI overview](https://docs.restate.dev/ai), [Durable Agents](https://docs.restate.dev/ai/patterns/durable-agents), and [Durable Sessions](https://docs.restate.dev/ai/patterns/sessions) pages cover the primitives used here.

</Tab>

<Tab id="python" className="cookbook-concept-tab">

<RecipeMeta href="https://github.com/steel-dev/steel-cookbook/tree/e86cfbf8ba715cdbbc49fc2ef13e9fd7798695dc/examples/restate-agent-py" path="examples/restate-agent-py" authors={[{"handle":"junhsss","name":"Jun Ryu","avatar":"https://github.com/junhsss.png?size=40"}]} updated="2026-06-29" />

<RecipeQuickstart slug="restate-agent-py" />

`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:

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

Start Restate:

```bash
restate-server
```

In another terminal, run the Python service:

```bash
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:

```bash
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:

```json
{
  "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:

```bash
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.

## Related

[restate-agent-ts](/cookbook/restate-agent), [restate-agent-go](/cookbook/restate-agent), and [restate-agent-rs](/cookbook/restate-agent) show the same loop in other SDKs. Restate documents the underlying patterns in [Durable Agents](https://docs.restate.dev/ai/patterns/durable-agents) and [Durable Sessions](https://docs.restate.dev/ai/patterns/sessions). The Python service is served as ASGI with [Hypercorn](https://hypercorn.readthedocs.io/).

</Tab>

<Tab id="rust" className="cookbook-concept-tab">

<RecipeMeta href="https://github.com/steel-dev/steel-cookbook/tree/e86cfbf8ba715cdbbc49fc2ef13e9fd7798695dc/examples/restate-agent-rs" path="examples/restate-agent-rs" authors={[{"handle":"junhsss","name":"Jun Ryu","avatar":"https://github.com/junhsss.png?size=40"}]} updated="2026-06-29" />

<RecipeQuickstart slug="restate-agent-rs" />

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:

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

Run the Rust service in another terminal:

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

Register and invoke it:

```bash
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:

```json
{
  "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.

## Related

[restate-agent-ts](/cookbook/restate-agent), [restate-agent-py](/cookbook/restate-agent), and [restate-agent-go](/cookbook/restate-agent) cover the same idea in other languages. Restate's [Rust SDK docs](https://docs.rs/restate-sdk/latest/restate_sdk/) describe the macros, `Json<T>`, and `HttpServer` used in this recipe.

</Tab>

<Tab id="go" className="cookbook-concept-tab">

<RecipeMeta href="https://github.com/steel-dev/steel-cookbook/tree/e86cfbf8ba715cdbbc49fc2ef13e9fd7798695dc/examples/restate-agent-go" path="examples/restate-agent-go" authors={[{"handle":"junhsss","name":"Jun Ryu","avatar":"https://github.com/junhsss.png?size=40"}]} updated="2026-06-29" />

<RecipeQuickstart slug="restate-agent-go" />

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:

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

Run the Go service in another terminal:

```bash
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:

```bash
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:

```json
{
  "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.

## Related

[restate-agent-ts](/cookbook/restate-agent), [restate-agent-py](/cookbook/restate-agent), and [restate-agent-rs](/cookbook/restate-agent) implement the same durable agent loop. For the Restate APIs used here, see [Go services](https://docs.restate.dev/develop/go/services), [durable steps](https://docs.restate.dev/develop/go/durable-steps), and [state](https://docs.restate.dev/develop/go/state).

</Tab>

</Tabs>

## Related recipes

<RecipeGrid>
<RecipeCard slug="mcp" title={"Expose a Steel browser to any MCP client"} description={"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."} topics={['Agents', 'MCP']} languages={['TypeScript', 'Python', 'Rust', 'Go']} date="2026-06-24" />
<RecipeCard slug="genkit" title={"Build a browser agent with Genkit"} description={"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."} topics={['Agents']} languages={['Go']} date="2026-06-23" />
<RecipeCard slug="eino" title={"Build a browser agent with Eino"} description={"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."} topics={['Agents']} languages={['Go']} date="2026-06-23" />
</RecipeGrid>
