# Steel Documentation

> Steel is the open-source browser API for AI agents — managed cloud browsers with stealth,
> residential proxies, CAPTCHA solving, persistent profiles, session replays, and agent observability.
> Use Steel to launch cloud browsers, scrape content, and automate web tasks.

## Quick Reference

- Install: `npm install steel-sdk` (Node.js) or `pip install steel-sdk` (Python)
- CLI: `curl -sSf https://setup.steel.dev | sh`
- Auth header: `steel-api-key: <your-key>`
- Auth env var: `STEEL_API_KEY`
- API base URL: `https://api.steel.dev`
- WebSocket: `wss://connect.steel.dev?apiKey=<key>&sessionId=<id>`
- API reference: https://steel.apidocumentation.com/api-reference
- Brand design language (DESIGN.md): https://docs.steel.dev/DESIGN.md

## Agent Instructions

- For the simplest one-liner scrape, use the CLI:
  ```bash
  steel scrape https://example.com
  ```
- For simple scraping without a browser session, use the REST scrape endpoint:
  ```
  curl -X POST https://api.steel.dev/v1/scrape \
    -H "steel-api-key: YOUR_KEY" \
    -H "Content-Type: application/json" \
    -d '{"url": "https://example.com"}'
  ```
- For browser automation, connect Puppeteer or Playwright via WebSocket:
  ```js
  import Steel from 'steel-sdk';
  import puppeteer from 'puppeteer-core';

  const client = new Steel({ steelAPIKey: process.env.STEEL_API_KEY });
  const session = await client.sessions.create();
  const browser = await puppeteer.connect({
    browserWSEndpoint: `wss://connect.steel.dev?apiKey=${process.env.STEEL_API_KEY}&sessionId=${session.id}`,
  });
  // ... use browser ...
  await browser.close();
  await client.sessions.release(session.id);
  ```
- Python SDK:
  ```python
  from steel import Steel
  client = Steel(steel_api_key="YOUR_KEY")  # or set STEEL_API_KEY env var
  result = client.scrape(url="https://example.com")
  print(result.content.html)
  ```
- `client.scrape()` returns a `ScrapeResponse` with:
  - `result.content.html` — full HTML string
  - `result.content.markdown` — markdown version
  - `result.content.cleaned_html` — cleaned HTML
  - `result.content.readability` — readability text
  - `result.metadata.status_code` — HTTP status (int)
  - `result.metadata.title` — page title
  - `result.links` — list of extracted links
- Always release sessions when done: `client.sessions.release(sessionId)`
- Do NOT use `session.websocketUrl` directly — construct the WSS URL as shown above
- The Node SDK constructor param is `steelAPIKey` (not `apiKey`)
- The Python SDK constructor param is `steel_api_key` (not `api_key`)
- The Python package installs as `pip install steel-sdk` but imports as `from steel import Steel`
- `sessions.create()` accepts an optional `sessionId` (Node) / `session_id` (Python) UUID when you need the ID before the session exists; omit it and Steel generates one
- The Python `sessions.create()` session timeout param is `api_timeout` (not `timeout`, which is the HTTP request timeout)
- Any docs page is available as markdown by appending `.md` to its URL, for example `https://docs.steel.dev/overview/steel-cli.md`; AI user agents (Claude, Cursor, GPT) receive markdown automatically at the canonical URL
- `/llms-full.txt` concatenates every page into one file (large, roughly 230k tokens); prefer fetching the individual `.md` pages you need over reading the whole bundle

# Build a browser agent with Inngest AgentKit
URL: https://docs.steel.dev/cookbook/agentkit


Agent Kit is Inngest's framework for [multi-agent systems](/cookbook/topics/agents): a **tool** is a typed function the model can call, an **agent** bundles a system prompt with a tool set, and a **network** groups agents so they can collaborate on a task. Agent Kit handles the routing between them and wraps each call in Inngest's `step.run` checkpoint, so a network that crashes mid-flight resumes from the last finished step.

This starter wires Agent Kit to Steel through a single tool, `browse_hacker_news`, that opens a Steel session, drives it with Playwright over CDP, and returns structured rows. The [Inngest AgentKit integration](/integrations/agentkit) covers install and configuration.

```typescript
const browseHackerNews = createTool({
  name: "browse_hacker_news",
  description:
    "Fetch Hacker News stories (top/best/new) and optionally filter by topics",
  parameters: z.object({
    section: z.enum(["top", "best", "new"]).default("top"),
    topics: z.array(z.string()).optional(),
    limit: z.number().int().min(1).max(20).default(5),
  }),
  handler: async ({ section, topics, limit }, { step }) => {
    return await step?.run("browse-hn", async () => {
      const session = await client.sessions.create({});
      const browser = await chromium.connectOverCDP(
        `${session.websocketUrl}&apiKey=${STEEL_API_KEY}`
      );
      try {
        // navigate, evaluate, filter, dedupe
      } finally {
        await client.sessions.release(session.id);
      }
    });
  },
});
```

The `step.run("browse-hn", ...)` wrapper is Agent Kit's checkpoint boundary, inherited from Inngest: completed steps are cached by name, so a rerun skips a browser call that already succeeded.

```typescript
const hnAgent = createAgent({
  name: "hn_curator",
  description: "Curates interesting Hacker News stories by topic",
  system:
    "Surface novel, high-signal Hacker News stories. Favor technical depth, originality, and relevance to requested topics...",
  tools: [browseHackerNews],
});

const hnNetwork = createNetwork({
  name: "hacker-news-network",
  agents: [hnAgent],
  maxIter: 2,
  defaultModel: openai({ model: "gpt-5-nano" }),
});

const run = await hnNetwork.run(
  "Curate 5 interesting Hacker News stories about AI, TypeScript, and tooling. Prefer 'best' if relevant. Return title, url, points."
);
```

## Run it

```bash
cd examples/agentkit
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
npm install
npm start
```

Get keys at [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). Each tool invocation creates a fresh session.

Your output varies. Structure looks like this:

```text
Steel + Agent Kit Starter
============================================================

Running HN curation...

Results:
[
  {
    "type": "tool_call",
    "tool": "browse_hacker_news",
    "input": { "section": "best", "topics": ["AI", "TypeScript"], "limit": 5 },
    "output": [
      { "rank": 3, "title": "Claude 4.7 Opus released today", "points": 892, ... },
      ...
    ]
  },
  {
    "type": "text",
    "content": "Here are 5 high-signal stories..."
  }
]
Done!
```

A run lands in the ~20-40 second range.

## Make it yours

- **Add tools.** Drop another `createTool` (a form filler, a screenshot-and-describe, a per-site scraper) into `hnAgent.tools`.
- **Add agents.** Split the work: one agent browses, another summarizes, a third writes to a database. Put them in the same network and let Agent Kit route based on each agent's `description`.
- **Swap the model.** `gpt-5-nano` is cheap and fast; `gpt-5` handles longer reasoning chains. Agent Kit also ships Anthropic and Gemini adapters.
- **Reuse sessions.** The current handler creates and releases per call. For a chain of tool calls hitting the same site, hoist session creation out of the handler and pass the session ID through the network's shared state.

## Related

[Agent Kit docs](https://agentkit.inngest.com) · [Playwright version](/cookbook/playwright) · [Stagehand version](/cookbook/stagehand)

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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 Agno
URL: https://docs.steel.dev/cookbook/agno


Agno's primitive is `Agent(model=..., tools=[...])`. Tools are typed Python methods grouped into a `Toolkit` subclass; docstrings and type hints become the JSON schema the model sees.

This recipe wraps a Steel browser in a `SteelTools` toolkit, hands it to an Agent, and lets the model drive the session by picking which method to call and filling in arguments on its own. The [Agno integration](/integrations/agno) covers install and configuration.

```python
tools = SteelTools(api_key=STEEL_API_KEY)
agent = Agent(
    name="Web Scraper",
    model=OpenAIChat(id="gpt-5-nano", api_key=OPENAI_API_KEY),
    tools=[tools],
    instructions=[
        "Extract content clearly and format nicely",
        "Always close sessions when done",
    ],
    markdown=True,
)

response = agent.run(TASK)
```

`SteelTools` subclasses `agno.tools.Toolkit` and registers four bound methods (`navigate_to`, `screenshot`, `get_page_content`, `close_session`). `_ensure_session` and `_initialize_browser` defer the Steel session and Playwright connection until the first tool call, so a task that doesn't need a browser never creates one.

## Run it

```bash
cd examples/agno
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
uv run main.py
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). The default task scrapes [quotes.toscrape.com](https://quotes.toscrape.com) across two pages.

Your output varies. Structure looks like this:

```text
Steel + Agno Starter
============================================================

Results:

**Page 1:**
1. "The world as we have created it is a process of our thinking..." - Albert Einstein
2. "It is our choices, Harry, that show what we truly are..." - J.K. Rowling
3. "There are only two ways to live your life..." - Albert Einstein

**Page 2:**
4. "This life is what you make it..." - Marilyn Monroe
5. "It takes courage to grow up and become who you really are." - E.E. Cummings

Done!
```

The `finally` block in `main()` calls `tools.close_session()`, which releases the session even when the agent crashes mid-run.

## Make it yours

- **Change the task.** Set `TASK` in `.env` or edit the default in `main.py`.
- **Add tools.** Append any method to the `tools` list in `SteelTools.__init__`: `click_selector(selector)`, `fill_form(field, value)`, `wait_for_text(text)`. Typed signature plus docstring, Agno handles the rest.
- **Turn on stealth.** Pass flags to `self.client.sessions.create()` inside `_ensure_session`: `use_proxy=True`, `solve_captcha=True`, `session_timeout=600000`.
- **Swap the model.** `OpenAIChat(id="gpt-5-nano", ...)` is cheap and fast. Agno also ships `agno.models.anthropic.Claude` and others; the toolkit stays the same.

## Related

[Agno docs](https://docs.agno.com) · [Agno on GitHub](https://github.com/agno-agi/agno)

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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.


# Reuse authenticated sessions across browsers
URL: https://docs.steel.dev/cookbook/auth-context


**TypeScript**

An auth context is a snapshot of a browser's cookies and local storage at a point in time. Steel exposes one endpoint to read it and one session option to restore it:

```typescript
// Capture: pull the current cookies + localStorage off a live session
const sessionContext = await client.sessions.context(session.id);

// Restore: hand the snapshot to a new session on create
const next = await client.sessions.create({ sessionContext });
```

The snapshot is plain JSON you can store, ship between machines, or diff. Restoring it into a fresh session means the new browser starts already signed in. No login flow, no password prompt, no captcha. Other recipes link here as the primitive for "start already authenticated."

## How the demo works

`index.ts` runs the full round-trip against [practice.expandtesting.com](https://practice.expandtesting.com/login), a public login test site:

1. Create session #1, connect Playwright over CDP, run the `login` helper to submit the form, run `verifyAuth` to confirm the welcome text.
2. Call `client.sessions.context(session.id)` to pull the snapshot, then release session #1.
3. Create session #2 with `sessionContext` set to that snapshot. Connect Playwright, run `verifyAuth` again without logging in. The welcome text is already there.

The second session is a brand new browser on Steel's fleet. It has the auth state because the snapshot restored it, not because anything is shared between sessions on the backend.

## Run it

```bash
cd examples/auth-context-ts
cp .env.example .env          # set STEEL_API_KEY
npm install
npm start
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The script prints two session viewer URLs as it runs. Open them in other tabs to watch each browser.

Your output varies. Structure looks like this:

```text
Creating initial Steel session...
Steel Session #1 created!
View session at https://app.steel.dev/sessions/ab12cd34…

Initial authentication successful
Session #1 released

Steel Session #2 created!
View session at https://app.steel.dev/sessions/ef56gh78…

Authentication successfully transferred!
Session #2 released
```

A run takes ~20 seconds and costs a few cents of Steel session time. Both sessions go through `client.sessions.release()` in the `finally` block. Skipping it keeps browsers running until the 5-minute default timeout.

## What's inside the snapshot

The shape returned from `sessions.context()` is an object keyed by origin, with cookies and storage entries for each. Treat it as opaque JSON for transport, and treat it as sensitive: it holds session tokens. Anyone with the blob can impersonate the logged-in user until those tokens expire.

Cookies expire. A snapshot captured today may not work next week, and rarely works next month. If you're persisting contexts to disk or a vault, refresh them on a schedule or re-authenticate on failure.

## When to reach for this

Auth context fits one-shot flows where you already have a way to log in and just want to move the resulting state forward:

- Log in once interactively, capture the context, run headless jobs against it.
- Run an agent that signs in, snapshot at the end, hand the snapshot to the next agent in the pipeline.
- Keep a single "warm" context in memory and spawn short-lived workers from it.

If you want Steel to store credentials and handle the login itself, see [credentials](/cookbook/credentials). If you need a long-lived named identity that accumulates state across runs (history, extensions, preferences), that's a different primitive. The [authentication topic](/cookbook/topics/authentication) collects the related recipes.

## Make it yours

- **Swap the target site.** Replace the URLs and selectors in `login` and `verifyAuth`. Everything between the `sessions.context()` capture and the `sessions.create({ sessionContext })` restore stays identical regardless of site.
- **Persist the snapshot.** Write `sessionContext` to a file or secret store after capture. Load it on the next run and pass it straight into `sessions.create()`. Treat the file like a password.
- **Re-auth on failure.** Wrap `verifyAuth` on the restored session in a check: if it returns false, fall back to a fresh login and capture a new snapshot.

## Related

[credentials](/cookbook/credentials) · [Playwright docs](https://playwright.dev)

**Python**

Logging in is the expensive part of browser automation: forms, redirects, sometimes a captcha. Steel lets you do it once, freeze the result, and pour it into a brand new browser. `main.py` runs that whole loop with Playwright's sync API: log in on session #1, snapshot the auth state, throw session #1 away, then prove a fresh session #2 is already signed in without ever touching the login form.

The two calls that matter are a read and a write:

```python
session_context = client.sessions.context(session.id)
session = client.sessions.create(session_context=session_context)
```

## The round-trip is a no-op in Python

`client.sessions.context()` returns a Pydantic `SessionContext` model: `cookies`, `local_storage`, `session_storage`, `indexed_db`. The keyword `session_context` on `create()` wants a typed dict shaped the same way. You might expect to unpack and remap fields between them, but the SDK transforms the response model on the way in, so the object you capture goes straight back without a single field touched. Capture into a variable, hand the variable to `create()`, done. (The Go and Rust ports do have to copy fields between distinct read and write types. Python does not.)

That model is plain data. `session_context.model_dump(by_alias=True)` gives you JSON you can write to disk, push to a secret store, or move between machines. Treat it like a password: it holds live session tokens, and anyone holding the blob is the logged-in user until those tokens expire.

## Browser lifecycle

The script starts one `sync_playwright()` driver and reuses it across both sessions, calling `browser.close()` after each so the CDP socket from the released session does not linger. The driver itself is stopped in `finally` alongside the session release, so a failure mid-run still tears everything down. Each session is reached through `browser.contexts[0].pages[0]`, the page Steel opens for you, rather than `new_page()`.

## Run it

```bash
cd examples/auth-context-py
cp .env.example .env          # set STEEL_API_KEY
uv run main.py
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). `uv sync` runs automatically on first `uv run`, so there is no separate install step. Prefer pip? `pip install -e .` then `python main.py`. Playwright needs its browser binaries once: `playwright install chromium`.

The script prints two session viewer URLs. Open them in other tabs to watch each browser live.

Your output varies. Structure looks like this:

```text
Steel + Reuse Auth Context Example
============================================================

Creating initial Steel session...
Steel Session #1 created!
View session at https://app.steel.dev/sessions/ab12cd34...
Initial authentication successful
Session #1 released

Creating second Steel session with the captured context...
Steel Session #2 created!
View session at https://app.steel.dev/sessions/ef56gh78...
Authentication successfully transferred!
Session #2 released
```

A run takes about 20 seconds and costs a few cents of session time. Both sessions go through `client.sessions.release()`; session #2 is released in the `finally` block. Skip the release and the browser idles until the default timeout.

## Make it yours

- **Swap the target site.** Change the URLs and selectors in `login` and `verify_auth`. The capture and restore between them stay identical no matter the site.
- **Persist the snapshot.** `json.dump(session_context.model_dump(by_alias=True), f)` after capture, load it next run, and pass the dict straight into `session_context=`. The keyword accepts the dict form too.
- **Re-auth on failure.** If `verify_auth` on the restored session returns `False`, fall back to a fresh `login` and capture a new context. Cookies expire, so a snapshot from last week may already be dead.

## Related

[TypeScript version](/cookbook/auth-context) covers the same flow as a reusable primitive. [Go version](/cookbook/auth-context) and [Rust version](/cookbook/auth-context) map fields between the read and write context types. If you want Steel to store credentials and run the login itself, see [credentials](/cookbook/credentials). For the Playwright sync API, see the [Playwright docs](https://playwright.dev/python/).

**Rust**

A Steel auth context is the cookies and storage that make a browser "logged in." This recipe reads that snapshot off one session and hands it to the next, so the second browser starts already signed in. There is no login form on the second run.

One detail matters in Rust that the dynamic SDKs hide: the snapshot you read back is not the same type you write on create. `client.sessions().context(&id)` returns a `SessionContext` (its cookies are `Vec<SessionContextCookie>`), but `SessionCreateParams::session_context` wants a `SessionCreateParamsSessionContext` (cookies are `Vec<SessionCreateParamsSessionContextCookie>`). The two cookie structs carry the same fields under different struct names, so `to_write_context` in `main.rs` maps one into the other field by field. The compiler will not let you skip this.

## What the demo does

`main.rs` drives [practice.expandtesting.com](https://practice.expandtesting.com/login), a public login test site, over CDP with chromiumoxide:

1. Create session #1, connect, and run `login`: type `practice` / `SuperSecretPassword!` into the form and submit. `verify_auth` then loads `/secure` and checks that `#username` reads `Hi, practice!`.
2. Read the snapshot with `client.sessions().context(&session.id)`, then release session #1.
3. Map the read snapshot into a `SessionCreateParamsSessionContext`, create session #2 with `session_context` set, connect, and call `verify_auth` again without logging in.

Each chromiumoxide connection spawns a handler task (`tokio::spawn`) to pump CDP events and `handle.abort()`s it before the session is released. The cookie map copies `name` and `value` (the required fields) plus the optional `domain`, `path`, `expires`, `http_only`, `secure`, `same_site`, `priority`, `source_scheme`, `url`, and `session` directly, since those types are shared between the read and write cookie structs; only `partition_key` is dropped. The `local_storage` and `session_storage` maps move across unchanged.

## Run it

```bash
cd examples/auth-context-rs
cp .env.example .env          # set STEEL_API_KEY
cargo run
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The run prints both session viewer URLs. Open them to watch each browser.

```text
Creating Steel session #1...
Session #1 live at https://app.steel.dev/sessions/ab12cd34...
Logging in...
Initial authentication confirmed
Session #1 released

Creating Steel session #2 from the captured context...
Session #2 live at https://app.steel.dev/sessions/ef56gh78...
Session #2 released

Authentication successfully transferred without logging in
```

A run takes ~20 seconds. Both sessions go through `client.sessions().release(...)` before the program exits; skip it and the browsers idle until the 5-minute default timeout.

## Make it yours

- **Swap the target.** Change `LOGIN_URL`, `SECURE_URL`, and the selectors in `login` and `verify_auth`. The capture and replay around them stay the same for any site.
- **Persist the snapshot.** `SessionContext` derives `Serialize`, so you can write it to disk or a vault after capture and load it on the next run. Treat the file like a password: it holds live session tokens.
- **Re-auth on failure.** If `verify_auth` on the restored session returns false, fall back to a fresh `login` and capture a new snapshot. Cookies expire, so a snapshot from last week may already be dead.

## Related

[auth-context-ts](/cookbook/auth-context) · [auth-context-py](/cookbook/auth-context) · [auth-context-go](/cookbook/auth-context) · [credentials-rs](/cookbook/credentials) · [chromiumoxide](https://github.com/mattsse/chromiumoxide)

**Go**

A Steel session can hand you a snapshot of its browser state: cookies, localStorage, sessionStorage, indexedDB. Steel exposes that as one read call and one create option, so you log in once, pull the snapshot, and start a second browser that is already signed in.

```go
// Capture the live cookies + storage off session #1
captured, _ := client.Sessions.Context(ctx, first.ID)

// Restore them into a brand new session #2
second, _ := client.Sessions.Create(ctx, steel.SessionCreateParams{
    SessionContext: restoreContext(captured),
})
```

`main.go` drives both browsers with [chromedp](https://github.com/chromedp/chromedp) over CDP. It connects with `chromedp.NewRemoteAllocator(ctx, cdpURL, chromedp.NoModifyURL)` so the websocket URL Steel returns is used verbatim, then runs the login form on [practice.expandtesting.com](https://practice.expandtesting.com/login) and reads the `#username` welcome text to confirm auth.

## The read type is not the write type

This is the one sharp edge in the Go SDK. `Sessions.Context` returns a `*steel.SessionContext` with plain Go values: `Cookies []steel.SessionContextCookie`, `LocalStorage map[string]map[string]string`, and so on. The create side wants a `steel.SessionCreateParamsSessionContext`, where every field is wrapped in `param.Field[...]` and built with `steel.F(...)`. So you cannot pass the captured value straight back in: you read concrete values and you write wrapped ones.

`restoreContext` does that bridge. It rebuilds each cookie into a `steel.SessionCreateParamsSessionContextCookie`, wrapping `Name`, `Value`, `Domain`, `Path`, `Expires`, `HTTPOnly`, and `Secure` with `steel.F`. The `SameSite` enum is the same named type on both sides (`CreateSessionRequestSessionContextCookiesItemSameSite`), so it just gets wrapped, not converted. `LocalStorage` and `SessionStorage` are the same map type on each side and pass through `steel.F` unchanged. If you only need cookies for your target site, you can skip storage entirely.

## Run it

```bash
cd examples/auth-context-go
cp .env.example .env          # set STEEL_API_KEY
go mod tidy
go run .
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The run prints two viewer URLs. Open them to watch each browser; the second one lands on the secure page without ever touching the login form.

```text
Creating Steel session #1...
Session #1 live at https://app.steel.dev/sessions/ab12cd34...
Authenticated on session #1
Session #1 released

Creating Steel session #2 from the captured context...
Session #2 live at https://app.steel.dev/sessions/ef56gh78...
Authenticated on session #2

Authentication successfully transferred.
Releasing session #2...
```

Session #1 is released as soon as its context is captured. Session #2 is released by a `defer` on the way out, so a verify failure still cleans up. A full run is about 20 seconds.

## Make it yours

- **Swap the target.** Change the URLs and selectors in `login` and `verifyAuth`. The capture/restore path in `restoreContext` does not care what site you used.
- **Persist the snapshot.** `*steel.SessionContext` marshals to JSON. Write it after capture, load it next run, feed it through `restoreContext`, and skip the login entirely. Treat the file like a password: it carries live session tokens.
- **Re-auth on failure.** Cookies expire. If `verifyAuth` on session #2 returns an error, fall back to a fresh `login` and capture a new snapshot.

## Related

[auth-context-ts](/cookbook/auth-context) · [auth-context-py](/cookbook/auth-context) · [auth-context-rs](/cookbook/auth-context) · [credentials-go](/cookbook/credentials) · [chromedp docs](https://github.com/chromedp/chromedp)

## Related recipes

- [Persist authenticated sessions with Profiles](/cookbook/profiles): Maintain authenticated sessions across Steel browser instances using profiles.
- [Automate logins with the Credentials API](/cookbook/credentials): Use the Steel Credentials API with Playwright to automate flows with stored credentials.
- [Scrape a page to Markdown, screenshot, and PDF](/cookbook/scrape): Use the Steel TypeScript SDK's direct API to scrape a page to clean Markdown for LLM context, plus screenshot and PDF, with no browser library.


# Solve CAPTCHAs automatically in a Browser Use agent
URL: https://docs.steel.dev/cookbook/browser-use-captcha-auto


Steel can solve [CAPTCHAs](/cookbook/topics/captchas) inside a session without the agent lifting a finger. This recipe wires that into Browser Use: one flag at session creation, one custom tool the agent calls to block until the solver reports done. Everything else is the same perception-plan-act loop as the base [Browser Use](/cookbook/browser-use) recipe.

```python
session = client.sessions.create(solve_captcha=True)
```

`solve_captcha=True` turns on Steel's solver in the session's browser. Steel detects reCAPTCHA, hCaptcha, and similar widgets as the page loads them, drives the challenge transparently, and exposes progress through a status endpoint. From Browser Use's side, nothing changes: it still connects via `BrowserSession(cdp_url=...)` and runs the same agent loop. The solver lives below the CDP layer, invisible to the framework.

The only hand-off the agent needs is a way to wait. That's a tool registered with Browser Use's `Tools` decorator:

```python
tools = Tools()

@tools.action(description="Wait for CAPTCHA to be solved by Steel. ...")
async def wait_for_captcha_solution() -> str:
    ...
```

The description is what the LLM reads when deciding to call the tool, so it spells out the trigger: "Call this tool when you encounter any CAPTCHA challenge." The body polls `client.sessions.captchas.status(session_id)` once per second and returns once no page is still solving.

`wait_for_captcha_solution` runs with a 60-second deadline. Each tick fetches CAPTCHA state for every open tab and calls `_has_active_captcha`, which checks whether any state still has `isSolvingCaptcha=True`. Once that flips false, `_summarize_states` collapses per-page task counts (total, solving, solved, failed) into one dict and the tool returns a success string like "All CAPTCHAs have been solved after 4120ms. ..." so the agent has something to reason about on its next step.

The `TASK` string closes the loop. It tells the agent to navigate, call `wait_for_captcha_solution` when a challenge appears, then submit. Without that instruction the agent would try to click the checkbox itself and race the auto-solver.

## Run it

```bash
cd examples/browser-use-captcha-auto
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
uv run main.py
```

Keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). The default `TASK` points at a public reCAPTCHA v2 checkbox demo so you can verify the flow end-to-end without touching a real target.

A session viewer URL prints as the script starts. Open it in another tab to watch Steel click the checkbox while the agent idles inside `wait_for_captcha_solution`.

Your output varies. Structure looks like this:

```text
Starting Steel browser session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...

Executing task:
1. Navigate to https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php
2. When you encounter a CAPTCHA ... call the wait_for_captcha_solution tool
...
============================================================
 INFO     [Agent] Step 1: navigate to recaptcha demo
 INFO     [Agent] Step 2: call wait_for_captcha_solution

Waiting for CAPTCHA to be solved...
All CAPTCHAs solved in 4120ms
 INFO     [Agent] Step 3: click submit
 INFO     [Agent] Step 4: done
============================================================
TASK EXECUTION COMPLETED

Releasing Steel session...
Session completed. View replay at https://app.steel.dev/sessions/ab12cd34...
```

A run usually finishes in under a minute: a few cents of Steel session time plus OpenAI tokens for each agent step. The `finally` block that calls `client.sessions.release()` still matters. Steel bills per session-minute and CAPTCHA-enabled sessions count the same, so skipping release keeps the browser running until the default 5-minute timeout.

## Reading the status endpoint

`client.sessions.captchas.status(session_id)` returns one entry per open page. Each entry carries:

- `pageId` and `url` for the tab.
- `isSolvingCaptcha`: true while Steel is actively working on that page.
- `tasks`: per-challenge status strings like `solving`, `solved`, `failed_to_detect`, `failed_to_solve`.

`_summarize_states` rolls those per-page counts into totals so the agent sees a compact summary instead of raw page objects. The only invariant the tool itself depends on is `_has_active_captcha`: return once no page is still solving. If you want richer logic (fail early on `failed_to_solve`, surface the specific page URL, give up after N failures), extend the summary helper.

## Make it yours

- **Point at a real target.** Replace the `TASK` string with the actual flow: "sign up at example.com with these details". Keep the instruction to call `wait_for_captcha_solution` when a CAPTCHA appears; everything else, the agent figures out.
- **Tune the poll loop.** `wait_for_captcha_solution` uses `timeout_ms=60000` and `poll_interval_ms=1000`. Image grids and audio fallbacks sometimes need 90-120 seconds. A 2-3 second poll cuts API calls without noticeable delay.
- **Fail loud on solver failure.** The current summary counts `failed` tasks but the tool does not short-circuit on them. Check `summary["failed_tasks"] > 0` inside the loop and return an error string so the agent can retry or abort.
- **Combine with stealth.** `sessions.create()` accepts `use_proxy=True` and `session_timeout=1800000` alongside `solve_captcha=True`. Sites that CAPTCHA you aggressively usually want all three.

## Related

- [Manual variant](/cookbook/browser-use-captcha-manual): trigger solve requests explicitly instead of letting Steel detect challenges on its own.
- [Browser Use base](/cookbook/browser-use): the minimal wiring without any CAPTCHA handling.
- [Browser Use docs](https://docs.browser-use.com)

## Related recipes

- [Solve reCAPTCHA v2 manually with Browser Use](/cookbook/browser-use-captcha-manual): Manually solve reCAPTCHA v2 using Steel's CAPTCHA API with the browser-use framework.
- [Build a browser agent with Browser Use](/cookbook/browser-use): Integrate Steel with the browser-use framework for AI-driven web automation.
- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.


# Solve reCAPTCHA v2 manually with Browser Use
URL: https://docs.steel.dev/cookbook/browser-use-captcha-manual


Steel can solve [CAPTCHAs](/cookbook/topics/captchas) for you in the background, or it can hand you the status API and let you drive. This recipe picks the second path. The session is created with auto-solving explicitly off, a custom Browser Use tool polls `client.sessions.captchas.status()`, and it calls `client.sessions.captchas.solve()` only for the CAPTCHA type you care about. Every state transition (detected, solving, validating, solved) is yours to read and react to.

```python
session = client.sessions.create(
    timeout=300000,
    solve_captcha=True,
    stealth_config={"auto_captcha_solving": False},
)
```

`solve_captcha=True` turns on Steel's CAPTCHA subsystem so the status endpoint has data to return. `auto_captcha_solving: False` tells Steel not to act on what it sees. Detection without intervention. You pick up the loop from there.

The default task opens two tabs, a reCAPTCHA v2 demo and a reCAPTCHA v3 demo, then delegates to a tool registered on the agent (`solve_recaptcha_v2_manual`). The tool polls until it finds work, requests a solve for v2 tasks only, and returns once the session reports it is no longer solving. The agent then clicks Submit and reads the result off the page.

## The polling loop

`solve_recaptcha_v2_manual` is registered with Browser Use via `@tools.action(...)` and runs in a 60-attempt, 3-second-interval loop (`MAX_POLL_ATTEMPTS`, `POLL_INTERVAL_SECS`). Each tick calls the status endpoint, iterates every page in the response, and dispatches on `task.status`:

```python
status_response = client.sessions.captchas.status(session_id)
states = [s.to_dict() if hasattr(s, "to_dict") else dict(s) for s in status_response]

for page_data in states:
    for task in page_data.get("tasks") or []:
        task_id = task.get("id", "")
        task_status = task.get("status", "")
        ...
```

A task with `status == "detected"` and `type == "recaptchaV2"` triggers a solve request:

```python
if task_status == "detected" and task_id not in solve_requested:
    if task.get("type") == SOLVE_CAPTCHA_TYPE:  # "recaptchaV2"
        client.sessions.captchas.solve(session_id, task_id=task_id)
        solve_requested.add(task_id)
```

Other types (`recaptchaV3`, `turnstile`, `image_to_text`) are logged and skipped. To solve every detected CAPTCHA regardless of type, drop the `task_id` arg: `client.sessions.captchas.solve(session_id)`. `solve_requested` is a set, so each task gets one request even as the poll loop revisits it.

## When is a solve actually done

`solved` is not the finish line. Steel marks a task `validating` after the answer is submitted so it can watch the site's response for a few seconds and confirm the solve was not rejected. The reliable signal for "stop polling" is the per-page `isSolvingCaptcha` flag:

```python
has_active_recaptcha_v2 = any(
    task.get("id") in detected_recaptcha_v2
    and task.get("status") not in ("detected", "undetected")
    for task in page_tasks
)

if has_active_recaptcha_v2:
    if not page_data.get("isSolvingCaptcha", False):
        recaptcha_pages_done = True
    else:
        all_pages_checked = False
        break
```

The tool tracks reCAPTCHA v2 task IDs in `detected_recaptcha_v2`, then for each page that holds one of those tasks past the `detected` state, waits for `isSolvingCaptcha` to flip to `False`. When every relevant page reports quiet, the tool returns a success string to the agent.

## Run it

```bash
cd examples/browser-use-captcha-manual
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
uv run main.py
```

Keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). The session viewer URL prints as the script starts. Open it in another tab to watch the reCAPTCHA checkbox tick over in real time.

Your output varies. Structure looks like this:

```text
Creating Steel session with CAPTCHA solving enabled...
Session created!
   Session ID: ab12cd34...
   Viewer:     https://app.steel.dev/sessions/ab12cd34...

Task: Open 2 CAPTCHA pages, solve reCAPTCHA v2 only
============================================================
 INFO     [Agent] Step 1: open reCAPTCHA v2 and v3 demo tabs
 INFO     [Agent] Step 2: call solve_recaptcha_v2_manual

Starting manual reCAPTCHA v2 solve polling...
   Max attempts: 60 | Interval: 3.0s

Poll attempt 1/60
   Page: https://www.google.com/recaptcha/api2/demo
   Task status: detected
   reCAPTCHA v2 detected (type=recaptchaV2)! Requesting solve...
   Page: https://2captcha.com/demo/recaptcha-v3
   Task status: detected
   Non-reCAPTCHA v2 task (type=recaptchaV3, ...), skipping.

Poll attempt 3/60
   Task status: solving
   CAPTCHA is being solved...

Poll attempt 5/60
   Task status: validating
   CAPTCHA is being validated...

reCAPTCHA v2 solved! (1 task(s) in 18.4s)
 INFO     [Agent] Step 3: click Submit
 INFO     [Agent] Step 4: done
============================================================
TASK EXECUTION COMPLETED
```

A run takes ~60 seconds and costs Steel session time plus OpenAI tokens for each agent step. The `finally` block that calls `client.sessions.release()` isn't optional. Without it the browser stays up until the 5-minute timeout, whether the solve finished or not.

## Make it yours

- **Solve a different CAPTCHA type.** Change `SOLVE_CAPTCHA_TYPE` to `"recaptchaV3"`, `"turnstile"`, or `"image_to_text"`. The dispatch in `solve_recaptcha_v2_manual` already filters by `task.get("type")`, so the rest of the loop is type-agnostic.
- **Solve everything.** Replace `client.sessions.captchas.solve(session_id, task_id=task_id)` with `client.sessions.captchas.solve(session_id)` to solve every detected task regardless of type. Drop the type filter and the `detected_recaptcha_v2` set at the same time.
- **Retune the loop.** `MAX_POLL_ATTEMPTS` and `POLL_INTERVAL_SECS` gate how long the tool will wait. 60 x 3s (3 minutes) is generous for a single solve. Shorten both for smoke tests, or stretch `MAX_POLL_ATTEMPTS` for pages that queue many challenges.
- **Swap the target.** Replace the entries in `CAPTCHA_PAGES`. The agent builds its tab list and prompt from that array, so the tool will poll and solve whatever you point it at.

## Related

- [Auto variant](/cookbook/browser-use-captcha-auto): flip `solve_captcha: True` and let Steel detect, solve, and submit without any tool plumbing.
- [Browser Use base](/cookbook/browser-use): base recipe without CAPTCHA handling.
- [Browser Use docs](https://docs.browser-use.com)

## Related recipes

- [Solve CAPTCHAs automatically in a Browser Use agent](/cookbook/browser-use-captcha-auto): Build an AI agent with browser-use and Steel that solves CAPTCHAs automatically.
- [Build a browser agent with Browser Use](/cookbook/browser-use): Integrate Steel with the browser-use framework for AI-driven web automation.
- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.


# Build a browser agent with Browser Use
URL: https://docs.steel.dev/cookbook/browser-use


[Browser Use](/cookbook/topics/browser-use) is an agent framework: you give it an LLM, a browser, and a natural-language `task`, and it runs a perception-plan-act loop until the task is done. It doesn't need selectors or scripted steps. The model reads the page, decides the next action, and executes it against the browser you give it. The browser in this recipe is a Steel session, so the agent runs on managed cloud Chrome with stealth, proxies, and a live viewer instead of local Chromium. The [Browser Use integration](/integrations/browser-use) covers install and setup.

```python
session = client.sessions.create()
cdp_url = f"{session.websocket_url}&apiKey={STEEL_API_KEY}"

model = ChatOpenAI(model="gpt-5", api_key=OPENAI_API_KEY)
agent = Agent(
    task=TASK,
    llm=model,
    browser_session=BrowserSession(cdp_url=cdp_url),
)

result = await agent.run()
```

`BrowserSession(cdp_url=...)` is the entire integration. Browser Use attaches to whatever Chrome is speaking the Chrome DevTools Protocol at that URL. No launcher, no `playwright.chromium.launch()`, no local browser binary.

## Run it

```bash
cd examples/browser-use
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
uv run main.py
```

Keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). A session viewer URL prints as the script starts. Open it in another tab to watch the agent click through the task in real time.

Your output varies. Structure looks like this:

```text
Starting Steel browser session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34…

Executing task: Go to Wikipedia and search for machine learning
============================================================
 INFO     [Agent] Step 1: navigate to https://www.wikipedia.org
 INFO     [Agent] Step 2: input "machine learning" into search box
 INFO     [Agent] Step 3: click search button
 INFO     [Agent] Step 4: done
============================================================
TASK EXECUTION COMPLETED
Duration: 38.2 seconds

Releasing Steel session...
Session completed. View replay at https://app.steel.dev/sessions/ab12cd34…
```

A run costs a few cents of Steel session time plus OpenAI tokens for each step the agent takes (screenshots go to the model on every iteration, so token usage scales with task length). The `finally` block that calls `client.sessions.release()` isn't optional. Steel bills per session-minute and skipping release keeps the browser running until the default 5-minute timeout.

## Make it yours

- **Change the task.** Set `TASK` in `.env` or edit the default in `main.py`. Any sentence works: "log in to example.com and download the latest invoice PDF", "compare prices for the top 3 vacuum cleaners on Amazon", "fill out the contact form at acme.com with these fields". Long tasks are fine; the agent breaks them into steps on its own.
- **Turn on stealth.** Add `use_proxy=True`, `solve_captcha=True`, or `session_timeout=1800000` to the `sessions.create()` call for sites with anti-bot. Tasks that navigate logged-in areas usually need longer timeouts than the 5-minute default.
- **Swap the model.** `ChatOpenAI(model="gpt-5", ...)` is the default; Browser Use also ships `ChatAnthropic`, `ChatGoogle`, and others. Change the import and the `model` arg passed to `Agent`.
- **Persist login.** Reuse cookies and local storage across runs via [credentials](/cookbook/credentials) so the agent doesn't have to sign in every time.

## Related

[Browser Use docs](https://docs.browser-use.com)

## Related recipes

- [Solve reCAPTCHA v2 manually with Browser Use](/cookbook/browser-use-captcha-manual): Manually solve reCAPTCHA v2 using Steel's CAPTCHA API with the browser-use framework.
- [Solve CAPTCHAs automatically in a Browser Use agent](/cookbook/browser-use-captcha-auto): Build an AI agent with browser-use and Steel that solves CAPTCHAs automatically.
- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.


# Automate a cloud browser with chromedp
URL: https://docs.steel.dev/cookbook/chromedp


chromedp speaks the Chrome DevTools Protocol over a websocket and never shells out to a local Chrome. A Steel session exposes exactly that websocket, so `chromedp.NewRemoteAllocator` points at the remote browser and every `chromedp.Run` step executes in the cloud, behind Steel's stealth, proxies, and live viewer. No browser on your machine, just the same [browser automation](/cookbook/topics/browser-automation) over CDP the other driver recipes use.

```go
cdpURL := fmt.Sprintf("%s&apiKey=%s", sess.WebsocketURL, apiKey)

allocCtx, cancelAlloc := chromedp.NewRemoteAllocator(ctx, cdpURL, chromedp.NoModifyURL)
defer cancelAlloc()

browserCtx, cancelBrowser := chromedp.NewContext(allocCtx)
defer cancelBrowser()
```

`NoModifyURL` is the one detail that matters here. By default chromedp probes `/json/version` and rewrites the websocket it gets back. Steel already hands you the exact browser endpoint with its auth query string attached, so rewriting it breaks the connection. The flag tells chromedp to dial the URL verbatim.

After that it is plain chromedp. `run` builds one task list and ships it in a single `chromedp.Run`: navigate, wait for the story rows, pull data out, screenshot.

```go
err = chromedp.Run(runCtx,
    chromedp.Navigate("https://news.ycombinator.com"),
    chromedp.WaitVisible("tr.athing", chromedp.ByQuery),
    chromedp.Evaluate(extractTopStories, &raw),
    chromedp.FullScreenshot(&screenshot, 90),
)
```

The extraction step is the part worth reading. chromedp's `Evaluate` decodes a JS return value into a Go variable, but a list of structs does not map cleanly across that boundary. The reliable pattern is to have the page-side script `JSON.stringify` its result into a string, then `json.Unmarshal` it into a typed `[]story` on the Go side. The `extractTopStories` constant holds that script: it reads the top five `tr.athing` rows and returns title, link, and points for each.

## Run it

```bash
cd examples/chromedp
cp .env.example .env          # set STEEL_API_KEY
go mod tidy
go run .
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The program prints a session viewer URL as it starts. Open it in another tab to watch the run live. It writes `hackernews.png` to the working directory on the way out.

Your output varies. Structure looks like this:

```text
Creating Steel session...
Session created. Watch it live at https://app.steel.dev/sessions/ab12cd34
Navigating to Hacker News...

Top 5 Hacker News Stories:

1. A tiny font renderer that fits in your CPU cache
   Link: https://example.com/font-renderer
   Points: 642

2. Show HN: I rebuilt my home network on a single Raspberry Pi
   Link: https://news.ycombinator.com/item?id=43990011
   Points: 318

Saved screenshot to hackernews.png
Releasing session...
```

A run costs a few cents of browser time. Steel bills per session-minute, so the deferred `client.Sessions.Release` is not optional. The `defer` sits right after the create call, which means the session is released whether `run` returns clean or errors out partway through. Drop it and the browser stays up until the default five-minute timeout, on your dime.

## Make it yours

- **Swap the target.** Change the `chromedp.Navigate` URL, the `WaitVisible` selector, and the `extractTopStories` script. Session setup and cleanup stay identical. The JSON-string bridge works for any shape: define a matching Go struct and unmarshal.
- **Add steps.** chromedp tasks compose, so append `chromedp.Click`, `chromedp.SendKeys`, or `chromedp.SetValue` to the `Run` list to fill forms or paginate before you extract.
- **Turn on stealth.** `SessionCreateParams` takes pointers like `BlockAds`, `SolveCaptcha`, and `UseProxy` for sites with anti-bot, plus `Timeout` to extend the session past five minutes. Set the field to the address of a value (`v := true; params.BlockAds = &v`) since they are all optional.
- **Tune the screenshot.** `FullScreenshot` captures the whole scroll height at the given JPEG quality (0 to 100). Swap it for `chromedp.CaptureScreenshot` to grab only the viewport.

## Related

[Playwright version](/cookbook/playwright) and [Python Playwright](/cookbook/playwright) connect over CDP the same way with a different driver. [Rod](/cookbook/rod) is the other Go option, with a fluent page API instead of a task list. chromedp's own [examples](https://github.com/chromedp/chromedp/tree/master/examples) cover clicks, downloads, and network interception.

## Related recipes

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
- [Automate a cloud browser with headless_chrome](/cookbook/headless-chrome): Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.


# Automate a cloud browser with chromiumoxide
URL: https://docs.steel.dev/cookbook/chromiumoxide


chromiumoxide speaks the Chrome DevTools Protocol over a websocket, which is exactly what a Steel session exposes. `Browser::connect` takes the session's websocket URL and hands back a connected browser plus a `Handler`. From there you get plain async chromiumoxide: `new_page`, `content`, `get_title`, `find_elements`, `evaluate`, `screenshot`. No local Chrome, no `chromedriver`, no display, just [browser automation](/cookbook/topics/browser-automation) over CDP against a cloud browser.

The connection is one line, but it returns a tuple, and the second half is the part that trips everyone up:

```rust
let (browser, mut handler) = Browser::connect(cdp_url).await?;

let handle = tokio::spawn(async move { while let Some(_) = handler.next().await {} });
```

chromiumoxide splits the API surface (`browser`, `page`) from the connection's event loop (`handler`). The `browser` handle only queues CDP commands. Nothing is sent, and no response ever comes back, until something polls `handler` to completion. If you skip the spawn, `browser.new_page(...)` does not error: it hangs forever, because the future that would resolve it is never driven. This is the single most common chromiumoxide mistake. Spawn the drain loop right after `connect`, keep the `JoinHandle`, and abort it on the way out. `run` does exactly that.

One build-time gotcha that follows from the same design. chromiumoxide is runtime-agnostic and defaults to the `async-std` runtime, so a tokio program must opt in explicitly. The dependency in `Cargo.toml` is:

```toml
chromiumoxide = { version = "0.7", default-features = false, features = ["tokio-runtime"] }
```

Leave `default-features` on and the spawned handler silently runs on the wrong reactor, which surfaces as the same hang. Turn them off and name `tokio-runtime`.

Everything after the spawn is ordinary scraping. `run` opens Hacker News, waits for navigation, reads the title and full HTML, then pulls the top five stories with one `page.evaluate` call. The browser returns JSON, and chromiumoxide's `into_value` deserializes it straight into a `Vec<Story>`, so the extraction stays typed rather than a pile of per-element awaits:

```rust
let stories: Vec<Story> = page.evaluate(EXTRACT_STORIES).await?.into_value()?;
```

The screenshot uses `page.screenshot`, which returns the PNG as `Vec<u8>` directly from CDP. This example writes those bytes to `screenshot.png`, but the same bytes go just as easily into an upload, a vision model prompt, or a diff against a baseline.

## Run it

```bash
cd examples/chromiumoxide
cp .env.example .env          # set STEEL_API_KEY
cargo run
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The first build pulls chromiumoxide and tokio and takes a minute or two; later runs are quick. As the program starts it prints a session viewer URL. Open it in a second tab to watch the remote browser load the page live.

Your output varies. Structure looks like this:

```text
Creating Steel session...
Session live at https://app.steel.dev/sessions/ab12cd34
Connected over CDP, opening page...
Title: Hacker News
HTML length: 38214 bytes

Top 5 Hacker News stories:

1. Writing a Chrome DevTools Protocol client in Rust
   https://example.com/cdp-rust
   312 points

2. Show HN: I built a headless browser farm on a Raspberry Pi
   https://github.com/user/project
   188 points

...

Saved screenshot.png (245118 bytes)
Releasing session...
Session released
```

A run costs a few cents of browser time. Steel bills per session-minute, so the `client.sessions().release()` call after `run` returns is not optional: `main` captures the result, releases the session, and only then propagates any error, so a failed scrape still tears the session down instead of leaving it to idle until the default 5-minute timeout.

## Make it yours

- **Swap the target.** Replace the URL in `new_page` and the `EXTRACT_STORIES` expression with your own selectors. The JS runs in the page and returns any JSON-serializable shape; widen the `Story` struct to match. Session setup and teardown stay the same.
- **Prefer typed element queries.** If you would rather not write JS, `page.find_elements("tr.athing")` returns chromiumoxide `Element` handles with `inner_text` and `attribute("href")`. It is more Rust, more awaits, and easier to debug one node at a time.
- **Harden for anti-bot.** `SessionCreateParams` carries the same knobs as the other SDKs. Set `block_ads`, `solve_captcha`, `use_proxy`, or a custom `dimensions` on the struct you pass to `sessions().create()` for sites that fingerprint or challenge headless traffic.
- **Keep the page bytes in memory.** Drop the `std::fs::write` and feed the `Vec<u8>` from `page.screenshot` straight to whatever consumes it.

## Related

- [scrape-rs](/cookbook/scrape) reaches the same page without a browser library, through Steel's `scrape` and `screenshot` endpoints. Start there if you only need content or an image and never touch the DOM.
- [Selenium](/cookbook/selenium) drives Steel over WebDriver instead of CDP.
- [playwright-py](/cookbook/playwright) is the same connect-over-CDP shape in Python, useful for comparing the handler model against Playwright's.
- [chromiumoxide docs](https://docs.rs/chromiumoxide) cover the `Page`, `Element`, and `ScreenshotParams` APIs in full.

## Related recipes

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
- [Automate a cloud browser with headless_chrome](/cookbook/headless-chrome): Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.


# Build a browser agent with the Claude Agent SDK
URL: https://docs.steel.dev/cookbook/claude-agent-sdk


**TypeScript**

`@anthropic-ai/claude-agent-sdk` is the engine behind the Claude Code CLI, exposed as a Node library. You get the CLI's [agent loop](/cookbook/topics/agents), hooks, subagents, MCP support, and built-in tool catalog (`Read`, `Edit`, `Bash`, `Grep`, ...) without spawning the CLI yourself.

This recipe disables those built-ins and attaches a Steel cloud browser instead; the [Claude Agent SDK integration](/integrations/claude-agent-sdk) covers install and setup. Four MCP tools (`openSession`, `navigate`, `snapshot`, `extract`) sit in front of Playwright; the agent calls them by name and streams back typed messages.

```typescript
const navigate = tool(
  "navigate",
  "Navigate the open session to a URL and wait for it to load.",
  { url: z.string().describe("Absolute URL to navigate to") },
  async ({ url }) => {
    await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
    return {
      content: [
        { type: "text", text: JSON.stringify({ url: page.url(), title: await page.title() }) },
      ],
    };
  },
);

const steelServer = createSdkMcpServer({
  name: "steel",
  version: "1.0.0",
  tools: [openSession, navigate, snapshot, extract],
});

for await (const message of query({
  prompt: PROMPT,
  options: {
    model: "claude-sonnet-4-6",
    systemPrompt: SYSTEM_PROMPT,
    mcpServers: { steel: steelServer },
    allowedTools: ["mcp__steel__*"],
    tools: [],
    settingSources: [],
    maxTurns: 20,
    permissionMode: "bypassPermissions",
  },
})) {
  if (message.type === "assistant") {
    for (const block of message.message.content) {
      if (block.type === "tool_use") {
        const name = block.name.replace(/^mcp__steel__/, "");
        console.log(`  -> ${name}(${JSON.stringify(block.input).slice(0, 120)})`);
      }
    }
  } else if (message.type === "result") {
    if (message.subtype === "success") finalText = message.result ?? "";
  }
}
```

`tools: []` drops the entire Claude Code built-in catalog: no filesystem reads, no `Bash`, no `WebFetch`. `settingSources: []` skips loading `.claude/` from your working directory or home, so the recipe behaves the same on every machine.

## Run it

```bash
cd examples/claude-agent-sdk-ts
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
npm install
npx playwright install chromium
npm start
```

Get keys at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). A Steel session viewer URL prints when `openSession` runs; open it in another tab to watch the browser live.

Your output varies. Structure looks like this:

```text
Steel + Claude Agent SDK (TypeScript) Starter
============================================================
Sure, let me open a browser session and pull that page.
  -> open_session({})
    open_session: 1747ms
  -> navigate({"url":"https://github.com/trending/python?since=daily"})
    navigate: 2007ms
  -> snapshot({})
    snapshot: 272ms (4000 chars, 49 links)
I have everything I need. Top three trending repos ...

--- Final answer ---
Top 3 AI/ML-related repos:
1. owner/repo - description (X stars)
...

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A run takes ~25 to 45 seconds and 3 to 6 turns. Cost is Steel session-minutes plus Anthropic tokens. The `finally` block calls `steel.sessions.release()`.

## Make it yours

- **Swap the task.** Change `PROMPT` and (optionally) `SYSTEM_PROMPT`. The four tools are task-agnostic.
- **Reach for Opus 4.7.** Set `model: "claude-opus-4-7"` for harder reasoning.
- **Add a tool.** Define another `tool()`, append it to the `tools` array in `createSdkMcpServer`. A `click(selector)` tool that calls `page.click` is the most common fifth one.
- **Hook the lifecycle.** Pass a `hooks` option with callbacks for `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart` to audit, log, or block individual tool calls.
- **Resume sessions.** Capture `session_id` from the first `system`/`init` message, pass `resume: sessionId` on the next `query()` call to keep agent memory across runs.
- **Persist a login.** Pair with [credentials](/cookbook/credentials) or [auth-context](/cookbook/auth-context) so Steel sessions start already authenticated.

## Related

[Anthropic Agent SDK docs](https://platform.claude.com/docs/en/agent-sdk/overview) · [Python version](/cookbook/claude-agent-sdk) · [Claude Computer Use (TypeScript)](/cookbook/claude-computer-use)

**Python**

The Claude Agent SDK is the agent loop that powers Claude Code, packaged as a library. Tools are async functions decorated with `@tool`, bundled into an in-process MCP server with `create_sdk_mcp_server`, and registered through the `mcp_servers` option.

This recipe wires four browser tools (`open_session`, `navigate`, `snapshot`, `extract`) into one Steel session and points the agent at GitHub Trending.

```python
@tool(
    "navigate",
    "Navigate the open session to a URL and wait for the page to load.",
    {"url": str},
)
async def navigate(args: dict[str, Any]) -> dict[str, Any]:
    await _page.goto(args["url"], wait_until="domcontentloaded", timeout=45_000)
    return {
        "content": [
            {"type": "text", "text": json.dumps({"url": _page.url, "title": await _page.title()})}
        ]
    }

steel_server = create_sdk_mcp_server(
    name="steel",
    version="1.0.0",
    tools=[open_session, navigate, snapshot, extract],
)

options = ClaudeAgentOptions(
    model="claude-sonnet-4-6",
    system_prompt=SYSTEM_PROMPT,
    mcp_servers={"steel": steel_server},
    allowed_tools=["mcp__steel__*"],
    tools=[],
    setting_sources=[],
    max_turns=20,
    permission_mode="bypassPermissions",
)
```

`tools=[]` drops the SDK's built-ins (`Read`, `Write`, `Edit`, `Bash`, `Grep`, `WebFetch`). `setting_sources=[]` skips loading `.claude/` from your working directory or home, so the recipe runs identically everywhere.

`query()` returns an async iterator over typed messages:

```python
async for message in query(prompt=PROMPT, options=options):
    if isinstance(message, AssistantMessage):
        for block in message.content:
            if isinstance(block, ToolUseBlock):
                name = block.name.removeprefix("mcp__steel__")
                print(f"  -> {name}({json.dumps(block.input)[:120]})")
    elif isinstance(message, ResultMessage):
        if message.subtype == "success":
            final_text = message.result or ""
```

## Run it

```bash
cd examples/claude-agent-sdk-py
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
uv run playwright install chromium
uv run main.py
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/).

Your output varies. Structure looks like this:

```text
Steel + Claude Agent SDK (Python) Starter
============================================================
Sure, let me open a browser session and pull that page.
  -> open_session({})
    open_session: 1840ms
  -> navigate({"url": "https://github.com/trending/python?since=daily"})
    navigate: 2484ms
  -> snapshot({})
    snapshot: 487ms (4000 chars, 49 links)
I have everything I need. Here are the top 3 ...

--- Final answer ---
Top 3 AI/ML-related Python repos on today's trending list:
1. owner/repo - <description> (X stars)
...

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A run takes ~30 to 50 seconds and 3 to 6 turns. Cost is Steel session-minutes plus Anthropic tokens. The `finally` block closes Playwright and calls `steel.sessions.release()`.

## Make it yours

- **Swap the task.** Change `PROMPT` and, if useful, `SYSTEM_PROMPT`. The four tools are task-agnostic.
- **Use Opus 4.7 for harder pages.** Set `model="claude-opus-4-7"` in `ClaudeAgentOptions`.
- **Add a tool.** Decorate a new async function with `@tool`, append it to the `tools` list passed to `create_sdk_mcp_server`. A `click(selector)` tool that calls `page.click` is a useful fifth one.
- **Hook the lifecycle.** Pass `hooks={"PostToolUse": [...]}` on `ClaudeAgentOptions` to log every tool call, validate arguments, or veto destructive actions. Hook events: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`.
- **Resume sessions.** Capture `SystemMessage.data["session_id"]` from the first run, pass `resume=session_id` on the next `ClaudeAgentOptions` to continue with full context.
- **Hand off auth.** Pair with [credentials](/cookbook/credentials) or [auth-context](/cookbook/auth-context) so the Steel session starts already logged in.

## Related

[Anthropic Agent SDK docs](https://platform.claude.com/docs/en/agent-sdk/overview) · [TypeScript version](/cookbook/claude-agent-sdk) · [Claude Computer Use (Python)](/cookbook/claude-computer-use)

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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.


# Drive a mobile browser with Claude Computer Use
URL: https://docs.steel.dev/cookbook/claude-computer-use-mobile


Mobile-emulated Chrome, driven by Claude's computer-use tool. The agent loop is the same as the [Desktop TS](/cookbook/claude-computer-use) recipe (screenshot in, coordinate out, next screenshot back), but three things change when the surface is a [phone](/cookbook/topics/mobile): Steel allocates the viewport instead of you, Playwright drives the page over CDP instead of Steel's Input API, and every coordinate is clamped before it touches the browser. The [Claude Computer Use integration](/integrations/claude-computer-use) covers the base setup.

## Mobile session, Playwright driver

`SteelBrowser.initialize` asks Steel for a mobile device and connects Playwright to the returned CDP socket:

```typescript
this.session = await this.client.sessions.create({
  apiTimeout: 900000,
  solveCaptcha: false,
  deviceConfig: { device: "mobile" },
});

const cdpUrl = `${this.session.websocketUrl}&apiKey=${STEEL_API_KEY}`;
this.browser = await chromium.connectOverCDP(cdpUrl, { timeout: 60000 });
this.page = this.browser.contexts()[0].pages()[0];
```

`deviceConfig.device: "mobile"` tells Steel to spin up Chrome with a mobile user agent, mobile viewport, and touch-capable device metrics.

The action switch in `executeComputerAction` maps Claude's computer-use vocabulary onto Playwright calls: `left_click` becomes `page.mouse.click(x, y)`, `type` chunks through `page.keyboard.type` with a 12 ms delay per keystroke, `scroll` multiplies `scroll_amount` by 100 and calls `page.mouse.wheel`, `key` splits on `+` so `ctrl+a` routes through `CUA_KEY_TO_PLAYWRIGHT_KEY` into real modifier-plus-key presses.

## Dimensions come from the session

Desktop recipes hard-code the viewport in the constructor. Mobile inverts that: the constructor holds a placeholder until Steel says what device it allocated.

```typescript
constructor(startUrl: string = "https://amazon.com") {
  this.dimensions = [1920, 1080]; // placeholder
  // ...
}

async initialize() {
  this.session = await this.client.sessions.create(sessionParams);
  this.dimensions = [
    this.session.dimensions.width,
    this.session.dimensions.height,
  ];
  await this.page.setViewportSize({ width, height });
}
```

`ClaudeAgent` reads those dimensions back through `computer.getDimensions()` and threads them into both the system prompt and the `computer_20251124` tool definition. Order matters. Instantiate `ClaudeAgent` before `computer.initialize()` completes and the agent captures the placeholder while the real page is rendered at the mobile size Steel picked.

## Clamping coordinates

Phone targets are small. `clampCoordinates` pins every incoming coordinate to `[0, width - 1] x [0, height - 1]` and logs when it has to:

```typescript
private clampCoordinates(x: number, y: number): [number, number] {
  const clampedX = Math.max(0, Math.min(x, width - 1));
  const clampedY = Math.max(0, Math.min(y, height - 1));
  if (x !== clampedX || y !== clampedY) {
    console.log(`Coordinate clamped: (${x}, ${y}) → (${clampedX}, ${clampedY})`);
  }
  return [clampedX, clampedY];
}
```

Frequent clamps mean the tool definition and the session's real dimensions have drifted.

## Completion markers

The system prompt instructs Claude to end every run with `TASK_COMPLETED:`, `TASK_FAILED:`, or `TASK_ABANDONED:`. `isTaskComplete` scans each assistant turn for those tokens first, then falls back to natural-language patterns. The loop also exits on `detectRepetition` and a 50-iteration cap.

## Run it

```bash
cd examples/claude-computer-use-mobile
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
npm install
npm start
```

Keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). Override the task inline:

```bash
TASK="Open amazon.com and find the price of an iPhone 16 Pro Max" npm start
```

Your output varies. Structure looks like this:

```text
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34...

Executing task: Go to amazon.com, search for 'iPhone 16 Pro Max'...
============================================================
I'll start by taking a screenshot to see the current state.
computer({"action":"screenshot"})
Taking screenshot with dimensions: 390x844
computer({"action":"left_click","coordinate":[195,120]})
computer({"action":"type","text":"iPhone 16 Pro Max"})
...
TASK_COMPLETED: iPhone 16 Pro Max is $1,199 and in stock.

TASK EXECUTION COMPLETED
Duration: 96.4 seconds
```

Expect ~60-180 seconds and 15-40 iterations for a typical mobile browse.

## Make it yours

- **Change the start URL.** `SteelBrowser` defaults to `https://amazon.com`. Pass a different URL to the constructor in `main`.
- **Tighten or loosen the blocklist.** `BLOCKED_DOMAINS` flows through `context.route`.
- **Tune the system prompt.** `SYSTEM_PROMPT` teaches Claude the mobile conventions. The `<COORDINATE_SYSTEM>` block is rewritten at runtime with the live viewport numbers.
- **Persist a login.** Pass `sessionContext` into `sessions.create` to resume with cookies and local storage. See [credentials](/cookbook/credentials).
- **Raise the iteration cap.** `maxIterations = 50` in `executeTask` is conservative for long mobile flows.

## Related

[Desktop TS](/cookbook/claude-computer-use) · [Desktop Python](/cookbook/claude-computer-use) · [Computer use docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool) · [Playwright docs](https://playwright.dev)

## Related recipes

- [Drive a browser with Gemini Computer Use](/cookbook/gemini-computer-use): Connect Google's Gemini Computer Use to a Steel browser session for autonomous web interactions.
- [Drive a browser with Claude Computer Use](/cookbook/claude-computer-use): Connect Claude to a Steel browser session for autonomous web interactions.
- [Drive a browser with OpenAI Computer Use](/cookbook/openai-computer-use): Connect OpenAI's Computer Use Assistant to a Steel browser session for autonomous web interactions.


# Drive a browser with Claude Computer Use
URL: https://docs.steel.dev/cookbook/claude-computer-use


**TypeScript**

Claude sees the screen as an image and returns concrete actions at pixel coordinates: `left_click [640, 412]`, `type "claude 4.7 opus"`, `scroll down 3`. Something has to execute those actions against a real browser and send the next screenshot back. That "something" is the agent loop in `index.ts`, and the browser is a Steel session. The [Claude Computer Use integration](/integrations/claude-computer-use) covers install and setup.

## The loop

The whole thing fits in one `while` block inside `Agent.executeTask`. Each iteration sends the growing message history plus the `computer` tool definition to Claude:

```typescript
const response = await this.client.beta.messages.create({
  model: this.model,
  max_tokens: 4096,
  messages: this.messages,
  tools: this.tools,
  betas: ["computer-use-2025-11-24"],
});
```

The tool definition declares `computer_20251124` with the viewport's `display_width_px` and `display_height_px`. Keep it consistent with the Steel session's `dimensions` (1280x768 here) or clicks land in the wrong place.

`executeComputerAction` is the translation layer. Claude emits [computer-use actions](/cookbook/topics/computer-use) (`left_click`, `type`, `key`, `scroll`, `screenshot`, ...); Steel's Input API speaks a parallel vocabulary (`click_mouse`, `type_text`, `press_key`, `scroll`, `take_screenshot`):

```typescript
case "left_click":
case "right_click":
case "middle_click":
case "double_click":
case "triple_click": {
  body = {
    action: "click_mouse",
    button: buttonMap[action],
    coordinates: coords,
    screenshot: true,
  };
  break;
}
```

Every action sets `screenshot: true`, so Steel returns a fresh base64 PNG after each interaction. That PNG becomes the content of a `tool_result` block in the next user message.

A few translation details:

- **Keys get normalized.** `normalizeKey` maps synonyms (`CTRL` to `Control`, `CMD` to `Meta`, `ENTER` to `Enter`) before sending to Steel.
- **Scroll is delta-based.** Claude says `scroll_direction: "down", scroll_amount: 3`; Steel expects `delta_x`/`delta_y` in pixels. The code multiplies by 100 per step.
- **Drags default from center.** `left_click_drag` only gives an end coordinate, so the start is the viewport center.

## Stop conditions

- **No tool calls.** Claude wrote only text. Task is complete.
- **Repetition.** `detectRepetition` compares the last assistant message against the previous three by word overlap (>80%).
- **Iteration cap.** 50 iterations by default.

The `finally` block in `main` always calls `agent.cleanup()`, which releases the Steel session.

## Run it

```bash
cd examples/claude-computer-use-ts
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
npm install
npm start
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). Override the task inline:

```bash
TASK="Find the current weather in New York City" npm start
```

Your output varies. Structure looks like this:

```text
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34...

Executing task: Go to Steel.dev and find the latest news
============================================================
I'll navigate to Steel.dev and look for the latest news.
computer({"action":"screenshot"})
computer({"action":"left_click","coordinate":[640,48]})
computer({"action":"type","text":"https://steel.dev"})
computer({"action":"key","text":"Return"})
...
Task complete - no further actions requested

TASK EXECUTION COMPLETED
Duration: 84.3 seconds
Result: Steel's latest news includes ...
```

Expect ~60-120 seconds and 15-40 iterations for a simple browsing task.

## Make it yours

- **Change the viewport.** `viewportWidth` and `viewportHeight` in the `Agent` constructor set both the Steel session dimensions and the tool definition's `display_width_px`/`display_height_px`. Keep them in sync.
- **Tune the system prompt.** `BROWSER_SYSTEM_PROMPT` is where the browsing conventions live: date injection, screenshot-after-submit rule, black-screen recovery.
- **Raise the ceiling.** Long tasks bump against the 50-iteration default in `executeTask`.
- **Hand off auth.** Pair this recipe with Steel's [credentials](/cookbook/credentials) or [auth contexts](/cookbook/auth-context) to start the session authenticated.

## Related

[Computer use docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool) · [Python version](/cookbook/claude-computer-use) · [Mobile variant](/cookbook/claude-computer-use-mobile)

**Python**

Computer use is Anthropic's primitive for giving Claude direct control of a screen. You declare a `computer` tool with a viewport size; Claude replies with actions like `left_click` at `(x, y)`, `type` with text, `scroll`, `key`. You execute each one and hand back a screenshot.

Steel supplies the screen. A Steel session is a headful Chromium in a VM reachable over HTTPS, and the Input API (`sessions.computer`) executes mouse and keyboard actions and returns a PNG in the same call.

## The loop

Everything in `main.py` hangs off a single loop in `Agent.execute_task`. Seed the conversation with a system prompt and the task, then on each turn:

```python
response = self.client.beta.messages.create(
    model=self.model,
    max_tokens=4096,
    messages=self.messages,
    tools=self.tools,
    betas=["computer-use-2025-11-24"],
)

text, has_actions = self.process_response(response)

if not has_actions:
    break
```

`tools` declares the computer tool Claude is allowed to call:

```python
self.tools = [
    {
        "type": "computer_20251124",
        "name": "computer",
        "display_width_px": self.viewport_width,
        "display_height_px": self.viewport_height,
        "display_number": 1,
    }
]
```

The viewport (1280x768) has to match what Steel renders or clicks land in the wrong place.

`tool_use` blocks go to `execute_computer_action`, which maps each Anthropic action name onto a Steel Input API call:

```python
elif action in ("left_click", "right_click", "middle_click",
                "double_click", "triple_click"):
    body = {
        "action": "click_mouse",
        "button": button_map[action],
        "coordinates": [coords[0], coords[1]],
        "screenshot": True,
    }
```

`screenshot: True` tells Steel to attach a base64 PNG to the response, so a click and the screenshot that proves it landed are one round-trip. The PNG goes back into `messages` as a `tool_result` with the matching `tool_use_id`.

Two normalization details: `key` / `hold_key` run names like `CTRL+A` through `normalize_key` (`CTRL` to `Control`, `ESC` to `Escape`, `UP` to `ArrowUp`), and `scroll_amount` is multiplied by 100 pixels per step.

Two things end the loop: Claude responds with only text (task done), or the last two assistant messages overlap 80%+ on word content (`detect_repetition`). A hard cap of 50 iterations catches anything that slips past both.

## Run it

```bash
cd examples/claude-computer-use-py
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
uv run main.py
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). Default task lives in `.env` as `TASK`; you can override per-run:

```bash
TASK="Find the current weather in New York City" python main.py
```

Your output varies. Structure looks like this:

```text
Starting Steel session...
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34…

Executing task: Go to Steel.dev and find the latest news
============================================================
I'll navigate to Steel.dev and look for the latest news.
computer({"action": "key", "text": "ctrl+l"})
computer({"action": "type", "text": "https://steel.dev"})
computer({"action": "key", "text": "Return"})
computer({"action": "screenshot"})
…
Task complete - no further actions requested

TASK EXECUTION COMPLETED
Duration: 74.3 seconds
Result: Steel just shipped …

Releasing Steel session...
```

A run typically takes 60-180 seconds and 10-30 loop iterations.

## Make it yours

- **Change the task.** Edit `TASK` in `.env` or pass it per-run.
- **Tune the viewport.** `viewport_width` / `viewport_height` in `Agent.__init__`.
- **Rework the system prompt.** `BROWSER_SYSTEM_PROMPT` is where site-specific knowledge lives.
- **Persist a login.** Pass `session_context` to `sessions.create` to resume with cookies and local storage. See [credentials](/cookbook/credentials).
- **Raise the ceiling.** `max_iterations=50` in `execute_task` is the safety net.

## Related

[Anthropic computer use docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool) · [TypeScript version](/cookbook/claude-computer-use)

**Rust**

There is no first-party Anthropic SDK for Rust, so the Messages API here is exactly what it is on the wire: one `POST https://api.anthropic.com/v1/messages` with `reqwest`, three headers, and a JSON body you assemble yourself. That turns out to be an advantage for computer use. The request body is dynamic (a growing transcript of text, `tool_use`, and screenshot `tool_result` blocks), so you build it with `serde_json::json!`; the response shape is fixed, so you decode it into a typed `enum`. The half that benefits from types gets them, the half that does not stays loose.

The other half of the loop is the browser. A Steel session is a headful Chromium in a VM, and `client.sessions().computer(&id, action)` runs one mouse or keyboard action server-side and returns a base64 PNG in the same call. The `steel` crate models the action set as a `SessionComputerParams` enum, so the actions you send Steel are fully typed even though the actions you receive from Claude arrive as untyped JSON.

## Two type boundaries

This recipe straddles two APIs with opposite typing stories, and `main.rs` leans into both.

Claude's reply decodes into an internally tagged enum on the block's `type` field:

```rust
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ContentBlock {
    Text { text: String },
    ToolUse { id: String, name: String, input: Value },
    #[serde(other)]
    Other,
}
```

`input` stays a `serde_json::Value` on purpose: it is the computer tool's arguments (`action`, `coordinate`, `text`, ...), and those vary per action. The `#[serde(other)]` arm means a new block type in a future API version deserializes instead of panicking.

Going the other direction, `execute_computer_action` reads that loose `input` and constructs a typed Steel action. Claude's vocabulary (`left_click`, `type`, `scroll`, `key`) does not match Steel's (`click_mouse`, `type_text`, `scroll`, `press_key`), so the function is the translation layer:

```rust
"left_click" | "right_click" | "middle_click" | "double_click" | "triple_click" => {
    SessionComputerParams::ClickMouse(ComputerActionRequestClickMouse {
        button: Some(button),
        coordinates: Some(vec![coords.0, coords.1]),
        num_clicks,
        screenshot: Some(true),
        ..
    })
}
```

`screenshot: Some(true)` tells Steel to attach a fresh PNG to the action's response, so the click and the screenshot that proves it landed are a single round-trip. That PNG goes straight back into the next `tool_result` as a base64 `image` source.

Two translation details worth knowing. Keys run through `normalize_key` before they reach Steel (`CTRL` to `Control`, `ESC` to `Escape`, `UP` to `ArrowUp`), and `scroll_amount` is converted to a pixel delta at 100px per step, with direction mapped onto `delta_x` / `delta_y`. Both mirror the Python recipe so behavior stays identical across languages.

## The loop

`Agent::execute_task` seeds the transcript with the system prompt and the task, then repeats: call Anthropic, run any actions, append results.

```rust
let response = self.call_anthropic().await?;
let (text, has_actions) = self.process_response(response).await?;

if !has_actions {
    println!("Task complete - no further actions requested");
    final_text = text;
    break;
}
```

The tool definition declares `computer_20251124` with `display_width_px` and `display_height_px`. Those must match the Steel session's `dimensions` (1280x768 here) or Claude's coordinates point at the wrong pixels. Both read from the same `VIEWPORT_WIDTH` / `VIEWPORT_HEIGHT` constants so they cannot drift.

Three things end the loop: Claude replies with text and no `tool_use` (done), the last assistant message overlaps a recent one by more than 80% on word content (`detect_repetition`, a cheap stall guard), or the hard `MAX_ITERATIONS` cap of 50 trips. The beta is opt-in per request through the `anthropic-beta: computer-use-2025-11-24` header in `call_anthropic`.

## Run it

```bash
cd examples/claude-computer-use-rs
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
cargo run
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). The default `TASK` lives in `.env`; override it per run:

```bash
TASK="Find the current weather in New York City" cargo run
```

Your output varies. Structure looks like this:

```text
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34...

Executing task: Go to Steel.dev and find the latest news
============================================================
I'll navigate to Steel.dev and look for the latest news.
computer({"action":"key","text":"ctrl+l"})
computer({"action":"type","text":"https://steel.dev"})
computer({"action":"key","text":"Enter"})
computer({"action":"screenshot"})
...
Task complete - no further actions requested

TASK EXECUTION COMPLETED
Duration: 78.4 seconds
Result: Steel's latest news includes ...

Releasing Steel session...
```

Expect 60 to 180 seconds and 10 to 30 iterations for a simple browse, plus Anthropic token cost. A run also spends a few cents of browser time. Steel bills per session-minute, so the `cleanup` call that releases the session is not optional: `main` runs the task inside an `async` block and calls `agent.cleanup().await` afterward whether it returned `Ok` or an error, so a failed task still frees the browser.

## Make it yours

- **Change the task.** Edit `TASK` in `.env` or pass it inline.
- **Tune the viewport.** `VIEWPORT_WIDTH` / `VIEWPORT_HEIGHT` feed both the Steel `dimensions` and the tool definition. Keep them together.
- **Rework the prompt.** `browser_system_prompt` holds the browsing conventions: date injection, the clear-then-type rule, black-screen recovery.
- **Raise the ceiling.** `MAX_ITERATIONS` is the safety net for long tasks.
- **Persist a login.** Pass a session context to `sessions().create` to resume with cookies and local storage. See [credentials](/cookbook/credentials).

## Related

[Anthropic computer use docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool) · [Python version](/cookbook/claude-computer-use) · [Go version](/cookbook/claude-computer-use) · [scrape-rs](/cookbook/scrape)

**Go**

Two typed unions meet in this recipe. Claude's Beta Messages API returns a `computer` tool call (`left_click` at `[640, 412]`, `type "claude opus"`, `scroll down 3`); Steel's Sessions Computer endpoint accepts a discriminated union of actions (`click_mouse`, `type_text`, `scroll`) and returns a screenshot. `main.go` is the agent loop that translates one into the other and feeds the screenshot back, using the official `anthropic-sdk-go` and `steel-go` SDKs end to end with no hand-rolled HTTP.

A Steel session is a headful Chromium in a VM. The Computer endpoint (`client.Sessions.Computer`) runs a mouse or keyboard action server-side and, when you pass `Screenshot: true`, returns a base64 PNG in the same call. So one round-trip both acts and observes.

## Constructing a Steel action

Steel models its action request as a tagged union. In Go that is `SessionComputerParams`: a discriminator `Action` plus one pointer field per variant, all marshaled by the SDK based on the tag. You set the string and the matching struct, and leave the rest nil:

```go
req := &steel.ComputerActionRequestClickMouse{
    Action:      "click_mouse",
    Button:      &button,
    Coordinates: &coords,
    Screenshot:  ptr(true),
}
resp, err := a.steelClient.Sessions.Computer(ctx, a.session.ID,
    steel.SessionComputerParams{Action: "click_mouse", ComputerActionRequestClickMouse: req})
img := resp.Base64Image // *string, base64 PNG
```

`executeComputerAction` is one big `switch` over Claude's action names that builds the right variant for each: `left_click` and friends become a `ComputerActionRequestClickMouse` (with `NumClicks` 2 or 3 for double and triple), `type` becomes `ComputerActionRequestTypeText`, `scroll` becomes a `ComputerActionRequestScroll` with pixel deltas. Two translation details carry over from the Python and TypeScript versions: `scroll_amount` is multiplied by 100 pixels per step, and key names like `CTRL+A` run through `normalizeKey` (`CTRL` to `Control`, `ESC` to `Escape`, `UP` to `ArrowUp`) before they reach `press_key`.

Most coordinate and key fields on these structs are pointers (`*[]float64`, `*bool`), so the `ptr` generic helper near the top of the file keeps the construction readable.

## Reading Claude's turn

The response side is the other union. `BetaMessage.Content` is a slice of `BetaContentBlockUnion`; `block.AsAny()` returns the concrete variant for a type switch:

```go
for _, block := range msg.Content {
    switch v := block.AsAny().(type) {
    case anthropic.BetaTextBlock:
        // narration; print it and echo it back as a text block
    case anthropic.BetaToolUseBlock:
        // v.Input is the action; execute it, return a screenshot
    }
}
```

`BetaToolUseBlock.Input` arrives as `any`. `processResponse` marshals it to JSON and unmarshals into a small `computerAction` struct to read `action`, `coordinate`, `text`, and the rest. The same `Input` value goes straight back into `NewBetaToolUseBlock` when echoing the assistant turn, so you never reconstruct it field by field.

Screenshots return to Claude as a `tool_result` whose content is a base64 image, built in `screenshotResult`. The `anthropic-sdk-go` ships `NewBetaToolResultBlock` for text results, but an image result needs the explicit struct: a `BetaToolResultBlockParam` whose `Content` holds a `BetaImageBlockParam` with a `BetaBase64ImageSourceParam`. The `ToolUseID` ties the screenshot back to the call that produced it.

## The loop

`executeTask` seeds the history with the system prompt and the task, then on each turn calls the Beta Messages API and processes the response:

```go
resp, err := a.anthropicClient.Beta.Messages.New(ctx, anthropic.BetaMessageNewParams{
    Model:     anthropic.ModelClaudeOpus4_7,
    MaxTokens: 4096,
    Messages:  a.messages,
    Tools:     a.tools,
    Betas:     []string{"computer-use-2025-11-24"},
})
```

The tool is declared once in `NewAgent` with `anthropic.BetaToolUnionParamOfComputerUseTool20251124(viewportHeight, viewportWidth)`, which builds the `computer_20251124` definition. Keep the 1280x768 viewport in sync with the Steel session's `Dimensions` or clicks land in the wrong place. Three conditions end the loop: Claude returns only text (task done), the last assistant messages overlap more than 80% by word content (`wordOverlap`, a cheap stall detector), or the iteration count hits `maxIterations` (50).

One SDK note worth its own line: `anthropic-sdk-go` v1.51.1 has no named constant for the `computer-use-2025-11-24` beta yet (its newest is `computer-use-2025-01-24`). Because `AnthropicBeta` is a string alias, the raw string in `Betas` is correct and type-checks. Swap in the constant if a later SDK release adds one.

## Run it

```bash
cd examples/claude-computer-use-go
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
go run .
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). The default task lives in `.env` as `TASK`; override it per run:

```bash
TASK="Find the current weather in New York City" go run .
```

Your output varies. Structure looks like this:

```text
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34...

Executing task: Go to Steel.dev and find the latest news
============================================================
I'll navigate to Steel.dev and look for the latest news.
computer({"action":"key","text":"ctrl+l"})
computer({"action":"type","text":"https://steel.dev"})
computer({"action":"key","text":"Return"})
computer({"action":"screenshot"})
...
Task complete - no further actions requested

============================================================
TASK EXECUTION COMPLETED
Duration: 78.4 seconds
Releasing Steel session...
```

Expect roughly 60 to 180 seconds and 10 to 40 loop iterations for a simple browsing task. A run costs a few cents of browser time plus the Anthropic tokens for each screenshot. Steel bills per session-minute, so the `defer agent.cleanup(ctx)` in `main` that releases the session is not optional: skip it and the browser runs until the 900000 ms timeout set in `initialize`.

## Make it yours

- **Change the task.** Edit `TASK` in `.env` or pass it inline.
- **Tune the viewport.** `viewportWidth` and `viewportHeight` set both the Steel `Dimensions` and the tool's `display_*_px`. Keep them equal.
- **Rework the system prompt.** `browserSystemPrompt` is where the browsing conventions live: date injection, the screenshot-after-submit rule, black-screen recovery.
- **Raise the ceiling.** `maxIterations` is the safety net for long tasks.
- **Hand off auth.** Pass `SessionContext` to `Sessions.Create` to start authenticated. See [credentials](/cookbook/credentials) and [auth-context](/cookbook/auth-context).

## Related

[Python version](/cookbook/claude-computer-use) · [TypeScript version](/cookbook/claude-computer-use) · [OpenAI computer use in Go](/cookbook/openai-computer-use) · [Anthropic computer use docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool)

## Related recipes

- [Drive a browser with Gemini Computer Use](/cookbook/gemini-computer-use): Connect Google's Gemini Computer Use to a Steel browser session for autonomous web interactions.
- [Drive a mobile browser with Claude Computer Use](/cookbook/claude-computer-use-mobile): Claude Computer Use with Steel for autonomous task execution in mobile browser environments.
- [Drive a browser with OpenAI Computer Use](/cookbook/openai-computer-use): Connect OpenAI's Computer Use Assistant to a Steel browser session for autonomous web interactions.


# Chat with any webpage on Convex
URL: https://docs.steel.dev/cookbook/convex-chat-with-page


A [Convex](/cookbook/topics/convex) app where the user pastes a URL, asks a question, and an [AI agent](/cookbook/topics/agents) answers from the live page. The agent runs server-side as a `pageAgent` defined in `convex/agent.ts`, with a single tool, `scrapePage`, that fetches the URL through the `@steel-dev/convex` component and serves the markdown back in chunks. Tokens stream into the UI over websockets via `@convex-dev/agent`'s delta sync.

```
convex/
├── convex.config.ts    mounts steel + agent components
├── schema.ts           scrapeCache table
├── scrape.ts           cache helpers (getCached / putCached / latestForOwner)
├── agent.ts            pageAgent + scrapePage tool
└── chat.ts             createThread / sendMessage / listThreadMessages
src/
├── App.tsx             two-pane chat UI
└── components/         Markdown, Spinner, ScrapedPagePane, ui/*
```

The agent's loop runs entirely on Convex. The browser only renders messages and the scraped markdown pane.

## Run it

Set up environment variables on your shell, scaffold a deployment, then push the keys to it. Convex actions don't inherit shell env, so they have to be set on the deployment.

```bash
cd examples/convex-chat-with-page
npm install
cp .env.example .env       # fill in STEEL_API_KEY and OPENAI_API_KEY
npx convex dev             # creates a dev deployment on first run
```

In a second terminal:

```bash
npx convex env set STEEL_API_KEY "$STEEL_API_KEY"
npx convex env set OPENAI_API_KEY "$OPENAI_API_KEY"
npm run dev                # vite frontend
```

Get keys at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and [platform.openai.com/api-keys](https://platform.openai.com/api-keys).

Open the Vite URL, paste a URL into the input, and ask a question:

```text
https://en.wikipedia.org/wiki/Steel

When was stainless steel invented? Quote a phrase from the article.
```

A typical first run goes:

1. The user message appears.
2. The "thinking..." bubble shows with a spinner.
3. `scrapePage` fires. Steel opens a session, fetches the page, writes one row to `scrapeCache` and one to the Steel component's `sessions` table.
4. The right pane slides in with the rendered markdown.
5. Tokens stream into the assistant bubble on the left.

A second send of the same URL within 10 minutes hits the cache and skips the Steel call. The session count in the Convex dashboard stays flat.

## How streaming wires together

Three pieces have to line up or no tokens flow.

**Action.** `sendMessage` in `convex/chat.ts` calls `thread.streamText({ prompt }, { saveStreamDeltas: true })` and `await result.consumeStream()`. `saveStreamDeltas: true` writes each delta to the database as it arrives.

**Query.** `listThreadMessages` returns the merged result of `listMessages` (persisted history) and `syncStreams` (in-flight deltas). Without `syncStreams`, the client never sees the incremental rows.

**Hook.** `App.tsx` calls `useThreadMessages(api.chat.listThreadMessages, args, { stream: true })`. The `stream: true` flag is what tells the hook to subscribe to deltas in addition to persisted messages.

Drop any one of the three and the answer arrives all at once at the end. Drop the action's `consumeStream`, and it never arrives at all.

## HTML to markdown locally

`convex/agent.ts` fetches HTML from Steel and converts it on the Convex side using `node-html-markdown`, instead of asking Steel to return markdown directly:

```ts
const result = await steel.steel.scrape(
  ctx,
  { url, commandArgs: { format: ["html"], delay: 100 } },
  { ownerId },
);
const html = result?.content?.html ?? "";
const markdown = htmlToMarkdown.translate(html);
```

Steel's built-in markdown extractor drops the article body on some sites (LessWrong returned title plus footnotes only). HTML is consistent across sites, so the recipe takes the conversion hit and stays predictable.

The result is then chunked at paragraph boundaries into ~25k-character pieces (`chunkMarkdown`) and stored in `scrapeCache` keyed by `(url, ownerId)`. The model paginates by calling `scrapePage` again with `chunkIndex: 1`, `chunkIndex: 2`, etc. `stopWhen: stepCountIs(8)` on the `Agent` constructor lets it page through long articles and still answer.

## Make it yours

- **Plug in real auth.** `ownerId` is a single string. Replace the hardcoded `alice` / `bob` toggle in `App.tsx` with the user id from Clerk, WorkOS, or your auth provider, and the app becomes multi-tenant against real users.
- **Add login walls.** Compose with the [`credentials`](/cookbook/credentials) recipe to log in to a real account before scraping, and the [`profiles`](/cookbook/profiles) recipe to keep cookies across sessions.
- **Solve captchas in-session.** Steel's `solveCaptcha` flag handles the common challenges. Pass it through `commandArgs` on `steel.steel.scrape`.
- **Swap models.** `openai.chat("gpt-5.4-mini")` is one line in `convex/agent.ts`. Any `@ai-sdk/openai` model that supports tool calls works.
- **Adjust the cache TTL.** `CACHE_TTL_MS` is 10 minutes in `scrape.ts`. Lower for fast-moving content, raise for static articles.

## Related

- [`@steel-dev/convex` component](https://www.convex.dev/components/steel-dev)
- [`@convex-dev/agent` component](https://www.convex.dev/components/agent)
- [Sibling recipe: convex-price-watch](/cookbook/convex-price-watch) (scheduled scraping, no LLM)

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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.


# Watch Claude pricing for divergent A/B variants
URL: https://docs.steel.dev/cookbook/convex-price-watch


A scheduled monitor: scrape `https://claude.com/pricing` every 10 minutes from two parallel proxy probes, store one row per (region, tier, capturedAt) in [Convex](/cookbook/topics/convex), and surface tiers where the probes disagree. No LLM, no streaming. Just `@steel-dev/convex`, a cron, and a reactive dashboard.

```
convex/
├── convex.config.ts    mounts the steel component
├── schema.ts           priceSnapshots table
├── scraper.ts          captureFromRegion / captureAll / snapshotNow
├── crons.ts            10-minute schedule
└── prices.ts           current / history / recent / recentDivergences
src/
└── App.tsx             tiers × regions grid + divergence callout
```

The scraper fans out across two Steel deployment regions (`lax`, `iad`) with `useProxy: true`. Each scrape exits through a random residential IP from Steel's pool, so two parallel calls exercise different IPs and pick up A/B pricing experiments that depend on the visitor bucket.

## Run it

```bash
cd examples/convex-price-watch
npm install
cp .env.example .env       # fill in STEEL_API_KEY
npx convex dev             # creates a dev deployment on first run
```

In a second terminal:

```bash
npx convex env set STEEL_API_KEY "$STEEL_API_KEY"
npm run dev                # vite frontend
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys).

Open the Vite URL and click **Snapshot now**. Within ~15-30 seconds the grid populates with the latest prices. The cron also runs `captureAll` every 10 minutes once the deployment is live.

```text
Claude pricing watch
            LAX             IAD
Free        $0     just now $0     just now
Pro         $20    just now $20    just now
Max         $100   just now $100   just now

Proxy-routed via Steel. Cron runs every 10 minutes.
```

If the probes disagree, a yellow-bordered "Divergence detected" card appears above the grid. The cell whose amount differs from the per-tier majority gets a yellow tint.

## Why two parallel probes

`useProxy` on `ScrapeParams` is a boolean. Setting it to `true` routes the request through a random residential IP each time, but you don't get to pick the country from the scrape API. The country-pinned form (`{ geolocation: { country } }`) lives on `steel.sessions.create`, not `steel.steel.scrape`.

Two probes running in different deployment regions (`region: "lax"` and `region: "iad"`) give you two different IPs per tick. That's enough to surface visitor-bucket A/B variance: if Anthropic serves $17 to one bucket and $20 to another, the probes have a real chance of landing in different buckets and the grid lights up.

True per-country routing is a one-line extension: open a session with `useProxy: { geolocation: { country: "DE" } }`, then scrape through the session id. Listed under [Make it yours](#make-it-yours).

Each probe is wrapped in `try`/`catch` inside `captureAll`. A 503 from one Steel region doesn't blank the others.

## The delay is load-bearing

`claude.com/pricing` ships a small nav-only stub on the first response and hydrates the actual tier prices client-side. Without `delay: 5000` on the scrape call, the probe grabs the stub, the tier regex finds nothing, and the row count is zero.

`captureFromRegion` retries once if the markdown is empty or doesn't contain any tier name. The retry covers the residual hydration flake on residential IPs without inflating latency on good runs.

```ts
const result = await steel.steel.scrape(
  ctx,
  {
    url: TARGET_URL,
    delay: 5000,
    commandArgs: { format: ["markdown"], useProxy: true, region },
  },
  { ownerId: "monitor" },
);
```

`extractTierPrice` then walks the markdown for each of `Free`, `Pro`, `Max`, finds the first mention case-insensitively, and matches `($|€|£)N` in the next 600 characters. Fragile to layout changes; good enough for a known target.

## Make it yours

- **Country-pinned probes.** Swap `steel.steel.scrape` for `steel.sessions.create({ sessionArgs: { useProxy: { geolocation: { country: "DE" } } } })` and scrape through that session. Repeat for each country you want to watch.
- **Alert on divergence.** Add an HTTP action that posts to Slack or Discord whenever `recentDivergences` returns a non-empty array. Schedule it on the same cron, after `captureAll`.
- **Watch more sites.** `TARGET_URL` and `TIERS` are constants at the top of `scraper.ts`. Add a second target with its own tier list and a second cron.
- **Store screenshots alongside prices.** Pass `format: ["markdown", "screenshot"]` to `commandArgs` and write the screenshot to `ctx.storage`. Useful when a layout change breaks the regex and you want to see what the page actually looked like.
- **Diff Pro/Max/Team additions.** Persist the full `TIERS` array per snapshot and compare the latest captured set against the prior one. New or removed tiers are first-class signal.

## Related

- [`@steel-dev/convex` component](https://www.convex.dev/components/steel-dev)
- [Convex crons](https://docs.convex.dev/scheduling/cron-jobs)
- [Sibling recipe: convex-chat-with-page](/cookbook/convex-chat-with-page) (interactive agent, streamed)

## Related recipes

- [Scrape a page to Markdown, screenshot, and PDF](/cookbook/scrape): Use the Steel TypeScript SDK's direct API to scrape a page to clean Markdown for LLM context, plus screenshot and PDF, with no browser library.
- [Chat with any webpage on Convex](/cookbook/convex-chat-with-page): Convex app that streams an AI agent's answer about any URL. The agent runs server-side with one Steel-backed scrape tool and pages through long articles via a chunked cache.
- [Persist authenticated sessions with Profiles](/cookbook/profiles): Maintain authenticated sessions across Steel browser instances using profiles.


# Automate logins with the Credentials API
URL: https://docs.steel.dev/cookbook/credentials


**TypeScript**

Steel's credentials vault stores usernames and passwords against an origin. When a session opts in, Steel watches for login forms on that origin and fills them for you. No login code in your automation, no plaintext passwords in env vars, no custom storage for cookies. It's one of Steel's [authentication](/cookbook/topics/authentication) primitives.

Two API calls wire it up. First, save the credential once:

```typescript
await client.credentials.create({
  origin: "https://demo.testfire.net",
  value: { username: "admin", password: "admin" },
});
```

Then opt the session in:

```typescript
session = await client.sessions.create({
  credentials: {},
});
```

That empty object is the opt-in. Without it, the vault exists but the session ignores it. With it, Steel matches the page's origin against stored credentials and types them in when a login form appears.

After that, drive the browser with Playwright as usual. The demo navigates to the Altoro Mutual test site, clicks `#AccountLink` to open the login form, and checks the heading to confirm the fill worked:

```typescript
await page.goto("https://demo.testfire.net", { waitUntil: "networkidle" });
await page.click("#AccountLink");
await setTimeout(2000);

const headingText = await page.textContent("h1");
if (headingText?.trim() === "Hello Admin User") {
  console.log("Success, you are logged in");
}
```

The `setTimeout(2000)` gives Steel room to fill and submit the form. In a real script you would swap that for `page.waitForURL` or a selector wait tied to a post-login element.

Credentials are per-origin. Create one per site you automate. Re-calling `credentials.create` for an origin that already has a credential throws `Credential already exists`, which the demo swallows so the script is idempotent.

## Run it

```bash
cd examples/credentials-ts
cp .env.example .env          # set STEEL_API_KEY
npm install
npm start
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The script prints a session viewer URL as it starts. Open it in another tab to watch the auto-fill happen.

Your output varies. Structure looks like this:

```text
Creating credential...
Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...

Connected to browser via Playwright
Success, you are logged in
Releasing session...
Session released
Done!
```

On a second run the credential already exists, so you see `Credential already exists, moving on.` before the session starts. The behavior is otherwise identical.

## Make it yours

- **Swap the target site.** Change the `origin` and `value` in `credentials.create`, then update `page.goto` and the login-trigger click in `index.ts`. Steel handles the form detection as long as the page exposes a standard username/password input pair.
- **Manage credentials out of band.** `client.credentials.list()`, `client.credentials.retrieve(id)`, and `client.credentials.delete(id)` let you rotate or audit stored creds without touching automation code. Create credentials from a setup script and keep `index.ts` focused on the workflow.
- **Combine with stealth.** Pass `useProxy`, `solveCaptcha`, or `sessionTimeout` alongside `credentials: {}` in `sessions.create()`. The vault works with every other session option.

## When to use this vs. auth-context

Both recipes persist login across runs. They solve it differently:

- **Credentials (this recipe)** stores username and password. Steel re-authenticates each session by filling the login form. Works for any site with a standard form; the login UI runs every time.
- **[auth-context](/cookbook/auth-context)** captures cookies and localStorage from an already-authenticated session and replays them into the next one. Skips the login form entirely, but the context expires when the site's session does and needs to be recaptured.

Reach for credentials when you want a stable, long-lived setup tied to an account. Reach for auth-context when the site uses flows the vault cannot drive (SSO, MFA prompts, magic links) and you only need the resulting cookies.

## Related

[auth-context](/cookbook/auth-context) (cookie and localStorage replay) · [Playwright docs](https://playwright.dev)

**Python**

Look at `main.py` and notice what is missing: there is no `page.fill("#username", ...)`, no password typed into a selector, no submit click. The automation navigates to the login page and reads the result. Steel handles the form in between. The credential lives in Steel's vault, the session opts into it, and when a matching login form renders, Steel types the username and password for you. Your script never sees the password after it is stored.

Setup is two calls. Store the credential against an origin once:

```python
client.credentials.create(
    origin="https://demo.testfire.net",
    value={"username": "admin", "password": "admin"},
)
```

Then opt the session in by passing an empty `credentials` dict:

```python
session = client.sessions.create(
    credentials={},
)
```

The empty dict is the switch. Leave it off and the vault still holds the credential, but the session ignores it. Pass it and Steel matches each page's origin against what is stored and fills the form when one appears.

## The two-second wait

After clicking `#AccountLink` the script calls `time.sleep(2)`. That is deliberate slack: the click opens the login form, Steel detects it, fills the fields, and submits. The sleep gives that round trip room before the script reads the `h1` to confirm `"Hello Admin User"`. It is the blunt version. In a real workflow swap it for `page.wait_for_url(...)` or `page.wait_for_selector(...)` keyed to something that only exists once you are logged in, so you wait exactly as long as you need to.

Credentials are scoped per origin, so create one per site. Calling `credentials.create` again for an origin that already has one raises a `steel.APIError` whose message contains `Credential already exists`. The script catches that case and keeps going, which is why a second run behaves the same as the first.

## Run it

```bash
cd examples/credentials-py
cp .env.example .env          # set STEEL_API_KEY
uv run main.py
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). `uv sync` runs automatically on first `uv run`, so there is no separate install step. The script prints a session viewer URL on startup. Open it in another tab to watch the auto-fill happen live. If you prefer pip, `pip install -e .` then `python main.py` works too.

Your output varies. Structure looks like this:

```text
Steel + Credentials Starter
============================================================

Creating credential...
Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...
Connected to browser via Playwright
Success, you are logged in
Releasing session...
Session released
Done!
```

On a second run the credential already exists, so you see `Credential already exists, moving on.` after the `Creating credential...` line. Everything else is identical.

## Make it yours

- **Swap the target site.** Change `origin` and `value` in `credentials.create`, then update the `page.goto` URL and the login-trigger click in `main.py`. Steel detects the form as long as the page uses a standard username and password input pair.
- **Manage credentials separately.** `client.credentials.list()`, `client.credentials.update(...)`, and `client.credentials.delete(...)` let you rotate or audit stored logins without touching the automation. Seed credentials from a one-off setup script and keep `main.py` about the workflow.
- **Stack it with other session options.** `use_proxy`, `solve_captcha`, and `session_timeout` slot in next to `credentials={}` in `sessions.create()`. The vault coexists with every other knob.

## When to use this vs. auth-context

Both persist a login across runs, by different means. Credentials stores a username and password, and Steel re-authenticates by filling the login form on every session. It works for any site with a standard form, but the login UI runs each time. [auth-context-py](/cookbook/auth-context) instead captures cookies and localStorage from an already-authenticated session and replays them, skipping the form entirely, though that context expires when the site's session does. Reach for credentials when you want a stable, long-lived setup tied to an account; reach for auth-context when the site uses flows the vault cannot drive (SSO, MFA, magic links) and you only need the resulting cookies.

## Related

[credentials-ts](/cookbook/credentials) (TypeScript port of this recipe) · [credentials-go](/cookbook/credentials) · [credentials-rs](/cookbook/credentials) · [auth-context-py](/cookbook/auth-context) (cookie and localStorage replay) · [Playwright docs](https://playwright.dev/python/)

**Rust**

Steel's credentials vault stores a username and password against an origin. Opt a session in, and Steel watches for the login form on that origin and types the stored values for you. The automation never sees the password, holds no cookies, and contains no login code, just navigation and a check that the fill landed.

This recipe wires it up with two SDK calls, then connects [chromiumoxide](https://github.com/mattsse/chromiumoxide) over CDP to drive the resulting page.

## How it fits together

`main` stores the credential once with `client.credentials().create(...)`. Credentials are per-origin, so a re-run hits "Credential already exists"; the recipe matches that text on the returned `steel::Error` and continues, which keeps the script idempotent:

```rust
match create {
    Ok(_) => println!("Credential stored"),
    Err(err) if err.to_string().contains("already exists") => {
        println!("Credential already exists, moving on");
    }
    Err(err) => return Err(err.into()),
}
```

The opt-in is a default `SessionCreateParamsCredentials` on session create. Present, it tells Steel to match the page origin against the vault and fill the form when one appears; absent, the vault is ignored:

```rust
client.sessions().create(SessionCreateParams {
    credentials: Some(Box::new(SessionCreateParamsCredentials::default())),
    ..Default::default()
}).await?
```

From there it is ordinary chromiumoxide: open the Altoro Mutual login page and poll the username field (`#uid`) until Steel injects the vaulted value, which is the proof the fill landed. With the default `auto_submit` Steel also submits the form, so the filled field is only briefly visible — polling catches it as soon as it appears. The test site currently serves an expired certificate, so the page first sends `SetIgnoreCertificateErrorsParams`; drop that for a site with a valid certificate.

## Run it

```bash
cd examples/credentials-rs
cp .env.example .env          # set STEEL_API_KEY
cargo run
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The run prints a session viewer URL up front. Open it in another tab to watch Steel auto-fill the form live.

Output looks like this:

```text
Storing credential for https://demo.testfire.net...
Credential stored
Creating Steel session with credentials enabled...
Session live at https://app.steel.dev/sessions/ab12cd34...
Opening the login page; Steel auto-fills it from the vault...
Success: Steel auto-filled the login form with "admin" from the vault, no credentials in this code.
Releasing session...
Session released
```

On a second run the first lines read `Credential already exists, moving on`; the rest is identical.

## Make it yours

- **Swap the target site.** Change `ORIGIN` and the `value` map in `credentials().create`, then point the navigation and the polled field selector at your site. Steel handles detection as long as the page exposes a standard username/password input pair.
- **Tune the fill.** `SessionCreateParamsCredentials` carries `auto_submit`, `blur_fields`, and `exact_origin`. Set them on the struct instead of taking the default to control whether Steel presses submit, blurs filled fields, or matches the origin exactly.
- **Manage credentials out of band.** `credentials().list`, `update`, and `delete` let a setup script rotate or audit stored creds while `main.rs` stays focused on the workflow.

## Related

- [credentials-ts](/cookbook/credentials), [credentials-py](/cookbook/credentials), [credentials-go](/cookbook/credentials): the same recipe in TypeScript, Python, and Go.
- [auth-context-rs](/cookbook/auth-context): replay captured cookies and localStorage instead of refilling a login form.
- [chromiumoxide](https://github.com/mattsse/chromiumoxide): the async Rust CDP client used here.

**Go**

The automation in `main.go` never types a username or a password. It opens the site's login page and confirms that Steel auto-filled the form from the vault. The login itself happens server-side: Steel keeps the credential in a vault, watches the page for a matching form, and fills it. Your chromedp code stays a plain navigation script.

Wiring it up is two API calls. Store the credential against an origin:

```go
client.Credentials.Create(ctx, steel.CredentialCreateParams{
	Origin: steel.F("https://demo.testfire.net"),
	Value:  steel.F(map[string]string{"username": "admin", "password": "admin"}),
})
```

Then opt the session into the vault with an empty config struct:

```go
client.Sessions.Create(ctx, steel.SessionCreateParams{
	Credentials: steel.F(steel.SessionCreateParamsCredentials{}),
})
```

`SessionCreateParamsCredentials{}` is the opt-in. Leave it off and the vault is ignored for that session. The zero value uses the defaults; its fields (`AutoSubmit`, `BlurFields`, `ExactOrigin`) tune whether Steel presses submit for you, masks the typed values, and matches the origin exactly.

## Confirming the fill

After navigating to `/login.jsp`, the script polls the username field (`#uid`) until Steel injects the vaulted value, then reports success. With the default `AutoSubmit`, Steel also presses submit, so the filled field is only briefly visible — polling catches it as soon as it lands. The demo site currently serves an expired certificate, so the run first sends `Security.setIgnoreCertificateErrors`; drop that for a site with a valid certificate.

Re-running `Credentials.Create` for an origin that already has a stored credential returns an error whose message contains `already exists`. The script checks for that string and continues, so repeat runs are idempotent.

## Run it

```bash
cd examples/credentials-go
cp .env.example .env          # set STEEL_API_KEY
go mod tidy
go run .
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The session viewer URL prints as the run starts. Open it in another tab to watch the auto-fill land.

Output looks like this:

```text
Storing credential...
Credential stored.
Creating Steel session with credentials enabled...
Session created. Watch it live at https://app.steel.dev/sessions/ab12cd34...
Opening the login page; Steel auto-fills it from the vault...
Success: Steel auto-filled the login form with "admin" from the vault, no credentials in this code.
Releasing session...
```

On a second run the credential is already in the vault, so the first lines read `Credential already exists, moving on.` and the rest is identical.

## Make it yours

- **Target another site.** Change `origin` and the `Value` map, then point the navigation and the polled field selector at the new login form. Steel handles detection for any standard username/password form.
- **Tune the fill.** Set `AutoSubmit`, `BlurFields`, or `ExactOrigin` on `SessionCreateParamsCredentials` to control submit behavior, value masking, and origin matching.
- **Manage creds out of band.** `Credentials.List`, `Credentials.Update`, and `Credentials.Delete` let a setup script rotate or audit stored values while `main.go` stays focused on the workflow.

## Related

[credentials-ts](/cookbook/credentials) (TypeScript) · [credentials-py](/cookbook/credentials) (Python) · [credentials-rs](/cookbook/credentials) (Rust) · [auth-context-go](/cookbook/auth-context) (cookie and localStorage replay) · [chromedp docs](https://github.com/chromedp/chromedp)

## Related recipes

- [Persist authenticated sessions with Profiles](/cookbook/profiles): Maintain authenticated sessions across Steel browser instances using profiles.
- [Reuse authenticated sessions across browsers](/cookbook/auth-context): Maintain authenticated sessions across Steel browser instances by capturing and reusing cookies and local storage.
- [Scrape a page to Markdown, screenshot, and PDF](/cookbook/scrape): Use the Steel TypeScript SDK's direct API to scrape a page to clean Markdown for LLM context, plus screenshot and PDF, with no browser library.


# Build a multi-agent browser workflow with CrewAI
URL: https://docs.steel.dev/cookbook/crewai


CrewAI composes LLM work out of three primitives: an `Agent` (role, goal, tools), a `Task` (description + expected output), and a `Crew` that runs them in order. This recipe wires two [agents](/cookbook/topics/agents), a `researcher` and a `reporting_analyst`, to a single custom tool that calls Steel's scrape API. The researcher gathers sources; the analyst turns them into `report.md`. The [CrewAI integration](/integrations/crewai) covers install and setup.

```python
@agent
def researcher(self) -> Agent:
    return Agent(
        role="Instruction-Following Web Researcher",
        goal="Understand and execute: {task}. Find, verify, and extract ...",
        backstory="You specialize in decomposing and executing complex ...",
        tools=[SteelScrapeWebsiteTool()],
        llm="gpt-5-nano",
        verbose=True,
    )
```

The `{task}` placeholder is interpolated from the `inputs` dict passed to `kickoff()`, so the same crew runs against any research prompt without a code edit.

`SteelScrapeWebsiteTool` subclasses `BaseTool`, declares `args_schema = SteelScrapeWebsiteToolSchema` (a single `url: str` field), and implements `_run`:

```python
class SteelScrapeWebsiteTool(BaseTool):
    name: str = "Steel web scrape tool"
    description: str = "Scrape webpages using Steel and return the contents"
    args_schema: Type[BaseModel] = SteelScrapeWebsiteToolSchema

    def _run(self, url: str):
        return self._steel.scrape(
            url=url, use_proxy=self.proxy, format=self.formats, region="iad",
        )
```

No session lifecycle to manage: `scrape()` is one-shot and returns markdown by default.

## Run it

```bash
cd examples/crewai
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
uv run main.py
```

Get keys from [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). Default `TASK` is `"Research AI LLMs and summarize key developments"`; override via the `TASK` env var or edit `main.py`.

Your output varies. Structure looks like this:

```text
Steel + CrewAI Starter
============================================================
Running crew...

# Agent: Instruction-Following Web Researcher
## Task: Interpret and execute the following instruction...
## Using tool: Steel web scrape tool
## Tool Input: {"url": "https://..."}
## Tool Output: # Page title ... (markdown)
## Final Answer: - Finding 1 ... - Finding 2 ...

# Agent: Instruction-Following Reporting Analyst
## Task: Review the research context and produce a complete report...
## Final Answer: # AI LLMs ... (full markdown report)

Report written to report.md
```

A run takes ~60-90 seconds. `report.md` is overwritten each run.

## Make it yours

- **Change the task.** Set `TASK="Find the top 3 open-source vector databases and compare licensing"` in `.env` and rerun.
- **Add an agent.** Slot a fact-checker between researcher and analyst with a new `@agent` and `@task`. `Process.sequential` picks them up in declaration order.
- **Mix models.** The researcher can stay on `gpt-5-nano` while the analyst runs `gpt-5` or `claude-sonnet-4-6`. Set `llm=` independently on each `Agent`.
- **Tighten the scraper.** Pass `proxy=True` to `SteelScrapeWebsiteTool()` for sites that block datacenter IPs, or `formats=["html"]` if the markdown conversion strips something you need.

## Related

[CrewAI docs](https://docs.crewai.com) · [CrewAI tools reference](https://docs.crewai.com/en/concepts/tools)

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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.


# Deep research with Claude Agent SDK subagents
URL: https://docs.steel.dev/cookbook/deep-research


**TypeScript**

`@anthropic-ai/claude-agent-sdk` exposes typed `AgentDefinition` values that you pass through the `agents` field on `query()`. The lead agent invokes them through the built-in `Agent` tool. Multiple `Agent` calls fired in a single assistant turn run in parallel, and each [subagent](/cookbook/topics/subagents) starts in fresh context.

This recipe wires that pattern to Steel, built on the [Claude Agent SDK integration](/integrations/claude-agent-sdk). The lead "orchestrator" never opens a browser. It splits a research question into focused sub-questions, hands one to each `researcher` subagent, and synthesizes their cited findings into a Markdown report. Every researcher gets its own Steel session, so three browsers run side by side without stomping on each other's address bar.

```ts
const researcher: AgentDefinition = {
  description:
    "Focused web researcher. Drives a private Steel browser session to " +
    "answer one sub-question with cited findings. Use one per sub-question.",
  prompt: RESEARCHER_PROMPT,
  tools: ["mcp__steel__web_search", "mcp__steel__read_url"],
  mcpServers: ["steel"],
  model: "sonnet",
  maxTurns: 14,
};

for await (const message of query({
  prompt: PROMPT,
  options: {
    model: "claude-opus-4-7",
    systemPrompt: ORCHESTRATOR_PROMPT,
    mcpServers: { steel: steelServer },
    allowedTools: ["Agent"],
    agents: { researcher },
    tools: ["Agent"],
    settingSources: [],
    maxTurns: 20,
    permissionMode: "bypassPermissions",
  },
})) { ... }
```

`tools: ["Agent"]` is non-obvious. The empty-array form (`tools: []`) drops every built-in including `Agent`, which silently demotes the orchestrator to using the Steel tools directly instead of dispatching subagents. With `["Agent"]`, the orchestrator gets the dispatch primitive and nothing else. `mcpServers: ["steel"]` on the subagent reuses the parent's MCP server by name; the subagent's `tools` allowlist intentionally drops `Agent`, since subagents cannot dispatch their own subagents.

## One Steel session per researcher

Both `tool()` calls take a `researcher_id` argument (validated by Zod), which the orchestrator threads into every dispatched task. The first time a new id appears, the recipe creates a Steel session for it; later calls with the same id reuse it.

```ts
const webSearch = tool(
  "web_search",
  "Search the open web. Returns the first 10 results...",
  { researcher_id: z.string(), query: z.string() },
  async ({ researcher_id, query: q }) => { ... },
);
```

Because `query()` may stream parallel tool calls, two coordination layers keep things sane. A promise-chain mutex around `ensureResearcher` serializes session creation across researcher_ids, so two concurrent first-calls don't both spin up a browser. A second per-researcher chain wraps each tool body, so two tool calls on the same researcher serialize on the same Playwright `page`. The `finally` block walks the `researchers` map, closes every browser, and calls `steel.sessions.release()` on each one.

## Layered `read_url`: cheap fetch first, Steel when needed

Each read isn't a raw scrape — it's a focused extraction shaped like Claude Code's built-in `WebFetch`. `read_url(url, prompt)` takes the *specific* question the researcher wants answered ("which solid-state cells shipped in production cars in 2026?") and returns a tight answer, not a 30k-char dump. Two layers:

1. **`fetch()` + cheerio** for static HTML. Most primary sources resolve here in under a second.
2. **Steel browser fallback** when the plain fetch returns non-2xx, comes back with under 500 characters of body text, or matches a list of bot-block markers (`"just a moment"`, `"verifying you are human"`, ...). The same `ensureResearcher` path opens or reuses the researcher's existing Steel browser.

Either way, the extracted page text + the researcher's `prompt` go through one `claude-haiku-4-5` pass that returns the answer (or `NOT IN PAGE` if the URL turns out not to contain it). The researcher gets a compressed return that doesn't bloat its context — exactly the trick that makes Claude Code's `WebFetch` cheap.

```ts
const readUrl = tool(
  "read_url",
  "Fetch a URL and answer a focused extraction prompt about its content...",
  { researcher_id: z.string(), url: z.string(), prompt: z.string() },
  async ({ researcher_id, url, prompt }) => {
    const fast = await fastFetch(url);
    let tier: "fetch" | "steel" = "fetch";
    let title = "", text = "";
    if (!fast || !fast.ok || fast.text.length < 500 || looksBlocked(fast.text)) {
      tier = "steel";
      const snap = await browserFetch(researcher_id, url);
      title = snap.title; text = snap.text;
    } else {
      title = fast.title; text = fast.text;
    }
    const extraction = await extractWithHaiku({ url, title, text, prompt });
    return { content: [{ type: "text", text: JSON.stringify({ url, tier, extraction }) }] };
  },
);
```

`web_search` stays Steel-only — DuckDuckGo's HTML endpoint bot-challenges anonymous HTTP clients aggressively, and that's exactly where a real browser earns its keep.

## Iterative researcher with a midway RECAP

The researcher isn't one-shot. The `RESEARCHER_PROMPT` codifies a loop: search → read 2–3 pages → reflect on coverage → refine and search again, capped at ~8 tool calls (`maxTurns: 14`). This is what makes "deep research" deep — the iteration, not just the fan-out. Compare an SDK like [jina-ai/node-DeepResearch](https://github.com/jina-ai/node-DeepResearch), which runs the same search → read → reason loop until a token budget exhausts.

The prompt also asks the researcher to pause after ~5–6 tool calls and emit a compact `RECAP:` block — its current cited claims in 3-5 lines. From that point on, the researcher cites from the RECAP rather than from older raw extractions, and updates the RECAP as new pages come in. This is a prompt-only echo of the recency-biased context retention used by RL-trained deep-research models like [MiroThinker](https://github.com/MiroMindAI/MiroThinker): older tool outputs stay in context but the model's working state lives in a small, refreshed summary, so reasoning stays compact even as the loop extends.

## Run it

```bash
cd examples/deep-research-ts
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
npm install
npx playwright install chromium
npm start
```

Keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/).

Your output varies. Structure looks like this:

```text
Steel + Claude Agent SDK Deep Research
============================================================
Question: What is the current state of solid-state battery commercialization...
============================================================
-> dispatch researcher: Research which companies are actually shipping...
-> dispatch researcher: Research the technical state of solid-state battery...
-> dispatch researcher: Research what is blocking mass-market EV adoption...
    [r1] opened session 95b93573-...
    [r1] web_search 'solid-state battery EV production shipments 2026': 10 results (2989ms)
    [r2] opened session b44ccc8f-...
    [r2] web_search 'solid-state battery technology readiness 2026': 10 results (1748ms)
    [r1] read_url(fetch) 'https://www.intelligentliving.co/solid-state-battery-': 412 chars (1843ms)
    [r3] read_url(steel) 'https://www.idtechex.com/en/research-article/solid-st': 287 chars (4621ms)
    [r2] read_url(fetch) 'https://www.trendforce.com/news/2026/...': 380 chars (1156ms)
    [r1] web_search 'NIO ET9 semi-solid battery production 2026': 10 results (1972ms)
    ...

============================================================
FINAL REPORT
============================================================
# Solid-State Battery Commercialization for EVs in 2026

## Summary
As of early-to-mid 2026, the long-promised technology has *partially* arrived ... [r1:2]

## Which Companies Are Shipping Product
NIO is the only company with semi-solid cells in customer-driven vehicles ... [r1:1][r1:2]
...

## Sources
- [r1:1] Solid-State Battery Scoreboard 2025-2026 - https://www.intelligentliving.co/...
- [r2:1] Sulfide-Based Electrolytes (TrendForce) - https://www.trendforce.com/...
- [r3:2] Solid State Batteries: Hype to Adoption (IDTechEx) - https://...

[r1] released session. Replay: https://app.steel.dev/sessions/95b93573-...
[r2] released session. Replay: https://app.steel.dev/sessions/b44ccc8f-...
[r3] released session. Replay: https://app.steel.dev/sessions/349ffae8-...
```

A run takes ~4 to 6 minutes wall-clock with 3 Steel sessions in parallel. Cost is Steel session-minutes (mostly for `web_search` and bot-blocked reads) plus Anthropic tokens. Three model tiers in play: Opus for orchestrator synthesis, Sonnet for researcher reasoning, Haiku for the per-page extraction pass.

## Make it yours

- **Swap the question.** Edit `PROMPT`. The orchestrator decomposes whatever you hand it.
- **Tune fan-out.** Edit `ORCHESTRATOR_PROMPT` to ask for 2 sub-questions or 6. More researchers means more parallel Steel sessions and more tokens.
- **Tune iteration depth.** Bump or shrink the "about 8 tool calls" budget in `RESEARCHER_PROMPT` and the matching `maxTurns: 14`. More turns = more thorough but slower; fewer = closer to the original one-shot recipe.
- **Skip the Haiku pass.** Drop `extractWithHaiku` and have `read_url` return the raw extracted text. Cheaper per call, but the researcher's context fills up much faster.
- **Tighten the fallback.** Add domains you know are JS-heavy (Twitter, LinkedIn, ...) to a "always Steel" allowlist, or relax the 500-char threshold if you read a lot of short reference pages.
- **Cheaper researchers.** Drop the researcher's `model: "sonnet"` to `"haiku"` for faster, lighter passes. The orchestrator stays on Opus.
- **Different search engine.** `web_search` drives DuckDuckGo's no-JS HTML endpoint. Swap the URL and the Zod-typed return shape inside the tool body for Bing, a vertical search, or a domain-restricted Google query — or wire in a paid search API and skip Steel for search entirely.
- **Persist sources.** Add a tool that appends `{ researcher_id, url, extraction }` to a JSONL file before returning. The orchestrator stays unchanged; you get a citable archive of every page each researcher read.
- **Hand off auth.** For sub-questions behind a login, pair with [credentials](/cookbook/credentials) or [auth-context](/cookbook/auth-context) so each Steel session starts already signed in.

## Related

[Subagents in the SDK](https://platform.claude.com/docs/en/agent-sdk/subagents) · [Python version](/cookbook/deep-research) · [Claude Agent SDK minimal wiring](/cookbook/claude-agent-sdk)

**Python**

The Claude Agent SDK exposes named subagents through the `agents` parameter on `ClaudeAgentOptions`. The lead agent invokes them through the built-in `Agent` tool; each subagent runs in fresh context and only its final message returns to the parent. Multiple Agent calls fired in a single turn run in parallel.

This recipe wires that pattern to Steel. The lead "orchestrator" never touches a browser. It splits the research question into sub-questions, dispatches one `researcher` subagent per sub-question, and synthesizes their findings into a Markdown report with citations the reader can trace back to a specific researcher and source. Each researcher gets its own Steel session, so three browsers run side by side without trampling each other's address bar.

```python
options = ClaudeAgentOptions(
    model="claude-opus-4-7",
    system_prompt=ORCHESTRATOR_PROMPT,
    mcp_servers={"steel": steel_server},
    allowed_tools=["Agent"],   # the orchestrator only dispatches
    agents={
        "researcher": AgentDefinition(
            description=(
                "Focused web researcher. Drives a private Steel browser "
                "session to answer one sub-question with cited findings."
            ),
            prompt=RESEARCHER_PROMPT,
            tools=["mcp__steel__web_search", "mcp__steel__read_url"],
            mcpServers=["steel"],
            model="sonnet",
            maxTurns=14,
        ),
    },
    tools=["Agent"],   # dispatch primitive only; no Read/Bash/Edit
    setting_sources=[],
    max_turns=20,
    permission_mode="bypassPermissions",
)
```

`tools=["Agent"]` is the gotcha worth memorizing. The empty-list form (`tools=[]`) drops every built-in, including `Agent`, which silently demotes the orchestrator to calling Steel tools directly instead of dispatching subagents. With `["Agent"]`, the orchestrator gets the dispatch primitive and nothing else.

`mcpServers=["steel"]` on the subagent reuses the parent's MCP server by name, so the same in-process tools wire into the subagent's context. The researcher's `tools` allowlist drops `Agent`, since subagents cannot dispatch their own subagents.

## One Steel session per researcher

`web_search` and `read_url` both take a `researcher_id`. The orchestrator hands each subagent a unique id (`r1`, `r2`, ...) inside the dispatch prompt and instructs it to pass that id to every tool call. The MCP server lazy-allocates a fresh Steel session the first time a new id appears.

```python
async def _ensure_session(researcher_id: str) -> dict[str, Any]:
    async with _session_lock:
        if researcher_id in _sessions:
            return _sessions[researcher_id]
        sess = steel.sessions.create()
        browser = await _playwright.chromium.connect_over_cdp(
            f"{sess.websocket_url}&apiKey={STEEL_API_KEY}"
        )
        ctx = browser.contexts[0]
        page = ctx.pages[0] if ctx.pages else await ctx.new_page()
        _sessions[researcher_id] = {"session": sess, "browser": browser, "page": page}
        return _sessions[researcher_id]
```

Three Steel browsers run concurrently inside one Python process. The `finally` block at the bottom of `main` walks `_sessions`, closes every browser, and calls `steel.sessions.release()` on each one, so a crash mid-research still tears the cloud sessions down.

## Layered `read_url`: cheap fetch first, Steel when needed

Each read isn't a raw scrape — it's a focused extraction shaped like Claude Code's built-in `WebFetch`. `read_url(url, prompt)` takes the *specific* question the researcher wants answered ("which solid-state cells shipped in production cars in 2026?") and returns a tight answer, not a 30k-char dump. Two layers:

1. **`httpx.AsyncClient` + BeautifulSoup** for static HTML. Most primary sources resolve here in under a second.
2. **Steel browser fallback** when the plain fetch returns non-2xx, comes back with under 500 characters of body text, or matches a list of bot-block markers (`"just a moment"`, `"verifying you are human"`, ...). The same `_ensure_session` path opens or reuses the researcher's existing Steel browser.

Either way, the extracted page text + the researcher's `prompt` go through one `claude-haiku-4-5` pass that returns the answer (or `NOT IN PAGE` if the URL turns out not to contain it). The researcher gets a compressed return that doesn't bloat its context — exactly the trick that makes Claude Code's `WebFetch` cheap.

```python
@tool("read_url", "...", {"researcher_id": str, "url": str, "prompt": str})
async def read_url(args):
    fast = await fast_fetch(url)
    if not fast or not fast["ok"] or len(fast["text"]) < 500 or looks_blocked(fast["text"]):
        tier = "steel"
        snap = await browser_fetch(rid, url)        # Tier 2
    else:
        tier = "fetch"
        snap = fast                                  # Tier 1
    extraction = await extract_with_haiku(           # Haiku pass
        url=url, title=snap["title"], text=snap["text"], prompt=prompt,
    )
    return {"content": [{"type": "text",
                         "text": json.dumps({"url": url, "tier": tier, "extraction": extraction})}]}
```

`web_search` stays Steel-only — DuckDuckGo's HTML endpoint bot-challenges anonymous HTTP clients aggressively, and that's exactly where a real browser earns its keep.

## Iterative researcher with a midway RECAP

The researcher isn't one-shot. The `RESEARCHER_PROMPT` codifies a loop: search → read 2–3 pages → reflect on coverage → refine and search again, capped at ~8 tool calls (`maxTurns=14`). This is what makes "deep research" deep — the iteration, not just the fan-out. Compare an SDK like [jina-ai/node-DeepResearch](https://github.com/jina-ai/node-DeepResearch), which runs the same search → read → reason loop until a token budget exhausts.

The prompt also asks the researcher to pause after ~5–6 tool calls and emit a compact `RECAP:` block — its current cited claims in 3-5 lines. From that point on, the researcher cites from the RECAP rather than from older raw extractions, and updates the RECAP as new pages come in. This is a prompt-only echo of the recency-biased context retention used by RL-trained deep-research models like [MiroThinker](https://github.com/MiroMindAI/MiroThinker): older tool outputs stay in context but the model's working state lives in a small, refreshed summary, so reasoning stays compact even as the loop extends.

## Run it

```bash
cd examples/deep-research-py
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
uv run playwright install chromium
uv run main.py
```

Keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/).

Your output varies. Structure looks like this:

```text
Steel + Claude Agent SDK Deep Research
============================================================
Question: What is the current state of solid-state battery commercialization...
============================================================
-> dispatch researcher: Research which companies are actually shipping...
-> dispatch researcher: Research the technical state of solid-state battery...
-> dispatch researcher: Research what is blocking mass-market EV adoption...
    [r1] opened session 95b93573-...
    [r1] web_search 'solid-state battery EV production shipments 2026': 10 results (2989ms)
    [r2] opened session b44ccc8f-...
    [r2] web_search 'solid-state battery sulfide oxide electrolyte 2026': 10 results (1748ms)
    [r1] read_url(fetch) 'https://www.intelligentliving.co/solid-state-battery-': 412 chars (1843ms)
    [r3] read_url(steel) 'https://www.idtechex.com/en/research-article/solid-st': 287 chars (4621ms)
    [r2] read_url(fetch) 'https://www.trendforce.com/news/2026/...': 380 chars (1156ms)
    [r1] web_search 'NIO ET9 semi-solid battery production 2026': 10 results (1972ms)
    ...

============================================================
FINAL REPORT
============================================================
# Solid-State Battery Commercialization for EVs in 2026

## Summary
As of early-to-mid 2026, the long-promised technology has *partially* arrived ... [r1:2]

## Which Companies Are Shipping Product
NIO is the only company with semi-solid cells in customer-driven vehicles ... [r1:1][r1:2]
QuantumScape is shipping QSE-5 B-samples to OEMs ... [r1:2]
...

## Sources
- [r1:1] Solid-State Battery Scoreboard 2025-2026 - https://www.intelligentliving.co/...
- [r1:2] $10 Billion, 7 Companies, 0 All-Solid Cells - https://liveinthefuture.org/...
- [r2:1] Sulfide-Based Electrolytes (TrendForce) - https://www.trendforce.com/...
- [r3:2] Solid State Batteries in 2026: Hype to Adoption (IDTechEx) - https://...

[r1] released session. Replay: https://app.steel.dev/sessions/95b93573-...
[r2] released session. Replay: https://app.steel.dev/sessions/b44ccc8f-...
[r3] released session. Replay: https://app.steel.dev/sessions/349ffae8-...
```

A run takes ~4 to 6 minutes wall-clock with 3 Steel sessions running in parallel. Cost is Steel session-minutes (mostly for `web_search` and bot-blocked reads) plus Anthropic tokens. Three model tiers in play: Opus for orchestrator synthesis, Sonnet for researcher reasoning, Haiku for the per-page extraction pass.

## Make it yours

- **Swap the question.** Edit `PROMPT`. The orchestrator decomposes whatever you hand it.
- **Tune fan-out.** Edit `ORCHESTRATOR_PROMPT` to ask for 2 sub-questions or 6. More researchers means more parallel sessions and more tokens.
- **Tune iteration depth.** Bump or shrink the "about 8 tool calls" budget in `RESEARCHER_PROMPT` and the matching `maxTurns=14`. More turns = more thorough but slower; fewer = closer to the original one-shot recipe.
- **Skip the Haiku pass.** Drop `extract_with_haiku` and have `read_url` return the raw extracted text. Cheaper per call, but the researcher's context fills up much faster.
- **Tighten the fallback.** Add domains you know are JS-heavy (e.g. Twitter, LinkedIn) to a "always Steel" allowlist, or relax the 500-char threshold if you read a lot of short reference pages.
- **Cheaper researchers.** Drop the researcher's `model="sonnet"` to `model="haiku"` for faster, lighter passes. The orchestrator stays on Opus.
- **Different search engine.** `web_search` drives DuckDuckGo's no-JS HTML endpoint. Swap the URL and selectors in the tool body for Bing, a vertical search, or a domain-restricted Google query — or wire in a paid search API and skip Steel for search entirely.
- **Persist sources.** Add a tool that appends `{researcher_id, url, extraction}` to a JSONL file before returning. The orchestrator stays unchanged; you get a citable archive of every page each researcher read.
- **Hand off auth.** For sub-questions behind a login, pair with [credentials](/cookbook/credentials) or [auth-context](/cookbook/auth-context) so each Steel session starts already signed in.

## Related

[Subagents in the SDK](https://platform.claude.com/docs/en/agent-sdk/subagents) · [TypeScript version](/cookbook/deep-research) · [Claude Agent SDK minimal wiring](/cookbook/claude-agent-sdk)

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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
URL: https://docs.steel.dev/cookbook/eino


[Eino](https://www.cloudwego.io/docs/eino/) is ByteDance's LLM application framework for Go. Its `flow/agent/react` package ships a prebuilt [ReAct agent](/cookbook/topics/agents): give it a tool-calling model and a set of tools, and it runs the reason-act loop for you. This recipe gives that agent two tools backed by Steel's Scrape API and points it at a news front page to write a short research briefing.

Unlike a CDP-driven recipe, there is no browser session to open or release here. `client.Scrape` runs a browser on Steel's side, fetches the page, and returns clean Markdown plus the page's links. The agent reads pages the way an LLM wants to read them (as text, not pixels), so the tools are plain HTTP calls and the whole program is stateless between turns.

```go
chatModel, _ := claude.NewChatModel(ctx, &claude.Config{
    APIKey:    anthropicKey,
    Model:     "claude-sonnet-4-6",
    MaxTokens: 2048,
})

agent, _ := react.NewAgent(ctx, &react.AgentConfig{
    ToolCallingModel: chatModel,
    ToolsConfig: compose.ToolsNodeConfig{
        Tools: []tool.BaseTool{scrapeTool, linksTool},
    },
    MaxStep: 24,
})

out, _ := agent.Generate(ctx, []*schema.Message{schema.UserMessage(task)})
```

`react.NewAgent` binds the tools to the model for you. You do not call a separate `BindTools`: passing tools in `ToolsConfig` is enough, and the agent advertises them to Claude on every turn. `Generate` runs the loop until the model stops calling tools or `MaxStep` is hit, then returns the final assistant message. There is also a `Stream` method with the same arguments if you want tokens as they arrive.

## Tools from a Go struct

`utils.InferTool` turns a typed function into a tool. It reads the input struct's tags to build the JSON schema the model sees, so you describe each argument once, in Go:

```go
type scrapePageArgs struct {
    URL string `json:"url" jsonschema:"required" jsonschema_description:"Absolute http(s) URL of the page to read."`
}

scrapeTool, _ := utils.InferTool(
    "scrape_page",
    "Fetch a web page through Steel and return it as clean Markdown plus title and description.",
    func(ctx context.Context, args scrapePageArgs) (string, error) {
        format := []steel.ScrapeRequestFormatItem{steel.ScrapeRequestFormatItemMarkdown}
        res, err := client.Scrape(ctx, steel.ClientScrapeParams{URL: args.URL, Format: &format})
        // ... marshal title + markdown to a JSON string for the model
    },
)
```

The companion `extract_links` tool calls the same endpoint and returns `res.Links` (text plus absolute URL) so the agent can pick which stories to open from an index page instead of guessing at URLs. Each tool truncates its output (Markdown to ~8k chars, links to 40) so a long page does not blow the model's context window. Both tools return a JSON string, which is what Eino feeds back to the model as the tool result.

## Run it

```bash
cd examples/eino
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
go mod tidy
go run .
```

Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and an Anthropic key at [console.anthropic.com](https://console.anthropic.com/). Each tool call prints its target and latency so you can watch the agent work through the page.

Your output varies. Structure looks like this:

```text
Steel + Eino research agent
============================================================
    extract_links https://news.ycombinator.com -> 40 links in 1840ms
    scrape_page https://news.ycombinator.com -> 5212 chars in 1502ms
    scrape_page https://example.com/post-a -> 4806 chars in 1733ms
    scrape_page https://example.com/post-b -> 3920 chars in 1611ms

Agent finished.
------------------------------------------------------------
1. Title of the first story
   https://example.com/post-a
   Why it matters in two sentences.

2. Title of the second story
   https://example.com/post-b
   ...
```

A run is typically 5 to 9 agent turns and ~15 to 35 seconds against Hacker News. Cost is a few cents: Steel bills the Scrape calls (one short browser fetch each), plus Claude tokens for the loop. Scrape sessions are short-lived and clean themselves up, so there is no `release` call to forget here. A long-lived CDP session is the case where forgetting cleanup keeps the meter running; see the chromedp recipe for that pattern.

## Make it yours

- **Swap the task.** Change the `task` constant. The tools stay the same; the agent re-plans against the new instructions. Try a comparison ("read these two pricing pages and tabulate the differences") or a single-page extraction.
- **Swap the model.** Eino's model components are interchangeable. Replace the `claude` import and `claude.NewChatModel` with `github.com/cloudwego/eino-ext/components/model/openai` and `openai.NewChatModel(ctx, &openai.ChatModelConfig{...})`; the tools and agent wiring do not change because tool schemas are provider-agnostic.
- **Return richer Markdown.** Add `steel.ScrapeRequestFormatItemReadability` or `steel.ScrapeRequestFormatItemCleanedHTML` to the `Format` slice and surface those fields if you want the article body without site chrome.
- **Add a tool.** Write another typed function and pass it through `utils.InferTool`, then add it to the `Tools` slice. A useful third tool is a `screenshot` call backed by `client.Screenshot` when the agent needs to confirm a page rendered.
- **Cap the loop differently.** `MaxStep` bounds how many model-plus-tool rounds run before the agent returns whatever it has. Lower it to fail fast on hard tasks, raise it for multi-page research.

## Related

[Genkit Go agent](/cookbook/genkit) drives a live CDP browser through chromedp tools, the complementary angle to this stateless Scrape agent. [Pydantic AI](/cookbook/pydantic-ai) is the same idea in Python. See the [Eino ReAct agent manual](https://www.cloudwego.io/docs/eino/core_modules/flow_integration_components/react_agent_manual/) for the agent internals and [Eino tools guide](https://www.cloudwego.io/docs/eino/core_modules/components/tools_node_guide/) for `InferTool`.

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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.


# Upload and run browser extensions
URL: https://docs.steel.dev/cookbook/extensions


**TypeScript**

Steel sessions launch a clean Chrome with nothing installed. The [Extensions API](/cookbook/topics/steel-apis) lets you upload a Chrome extension once, get back an ID, and attach it to any future session via `extensionIds` on `sessions.create()`. Content scripts and background workers load before your first `page.goto`, so the extension has already rewritten the DOM by the time Playwright observes it.

```typescript
const extensionExists = (await client.extensions.list()).extensions.find(
  (ext) => ext.name === "Github_Isometric_Contribu",
);

const extension = extensionExists ?? await client.extensions.upload({
  url: "https://chromewebstore.google.com/detail/github-isometric-contribu/mjoedlfflcchnleknnceiplgaeoegien",
});

session = await client.sessions.create({
  extensionIds: extension?.id ? [extension.id] : [],
});
```

Uploads persist on your account, so `extensions.list()` is the lookup that lets repeat runs skip the re-upload. Names come back normalized (truncated, underscored), which is why this one matches `Github_Isometric_Contribu` rather than the full store title.

The demo loads [GitHub Isometric Contributions](https://chromewebstore.google.com/detail/github-isometric-contribu/mjoedlfflcchnleknnceiplgaeoegien), a Chrome extension that replaces GitHub's flat contribution square grid with a 3D isometric version and injects extra panels for streaks, best-day counts, and weekly totals. `scrapeStats` reads those extension-rendered numbers straight off the profile page.

## Run it

```bash
cd examples/extensions-ts
cp .env.example .env          # set STEEL_API_KEY
npm install
npm start
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The script prints a session viewer URL as it starts. Open it in another tab to watch the extension render on a live GitHub profile.

Your output varies. Structure looks like this:

```text
Steel + Extensions API Starter
============================================================

Checking extension...
No existing extension found

Uploading extension...
Extension uploaded: { id: 'ext_...', name: 'Github_Isometric_Contribu', ... }

Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...

Connected to browser via Playwright
Navigating to junhsss's GitHub Profile

GitHub Stats for junhsss

 Stat             Value   Range / Date
 Contributions    1,284   in the last year
 This Week        37      this week
 Best Day         28      on Apr 3
 ...

Releasing session...
Session released
Done!
```

A run takes ~20 seconds and costs a few cents of session time. First run uploads the extension, later runs reuse the ID.

## How scrapeStats proves the extension loaded

`scrapeStats` in `stats.ts` targets markup the extension injects, not GitHub's stock profile. It waits on `div.ic-contributions-wrapper` (the `ic-` prefix is the extension's namespace), then walks nested `div.p-2` blocks to pull `span.f2` values for contributions, this-week totals, best-day counts, and streak ranges. If the extension never loads, none of those selectors resolve and the scrape hangs. That fragility is the demo: it fails loudly when the extension is missing, which is exactly how you confirm the session attached it.

`randomContributor` in `index.ts` fetches the [steel-browser](https://github.com/steel-dev/steel-browser) contributor list from the GitHub API and picks one. The main loop retries three times across different usernames if a profile fails to render, mostly as a hedge against transient rate limits on avatars.

## Make it yours

- **Upload your own extension.** `client.extensions.upload({ url })` accepts any Chrome Web Store listing URL. Swap the URL, and change the name that `extensions.list()` checks for (remember the truncated, underscored form).
- **Target a specific username.** Replace the `randomContributor` call in `index.ts` with a hardcoded string. The scraper works against any public profile.
- **Stack extensions.** `extensionIds` is an array. Upload multiple (ad blocker, cookie consent killer, a helper content script) and attach them together.
- **Combine with stealth.** Uncomment `useProxy` or `solveCaptcha` in the `sessions.create()` call if the sites your extension targets fight bots.

## Related

[Credentials](/cookbook/credentials) (persist cookies across runs) · [auth-context](/cookbook/auth-context) (seed logged-in state) · [profiles](/cookbook/profiles) (reuse a full browser profile) · [Playwright docs](https://playwright.dev)

**Python**

A fresh Steel session boots a clean Chrome with no extensions installed. The Extensions API closes that gap: you upload a Chrome extension once, Steel stores it under your account and hands back an ID, and you pass that ID to `sessions.create(extension_ids=[...])`. Content scripts run before your first `page.goto`, so by the time Playwright attaches the extension has already mutated the DOM.

This port keeps the recipe to its core primitive. It uploads (or reuses) the extension, attaches it, opens a GitHub profile, and waits for the one DOM node the extension injects. It does not scrape and pretty-print the rendered stats. The presence of that node is the whole proof.

```python
existing = next(
    (ext for ext in client.extensions.list().extensions if ext.name == "Github_Isometric_Contribu"),
    None,
)
extension = existing or client.extensions.upload(url=EXTENSION_URL)

session = client.sessions.create(extension_ids=[extension.id])
```

Uploads persist, so `extensions.list()` is the lookup that lets a second run skip the re-upload. Names come back normalized (truncated and underscored), which is why the match is against `Github_Isometric_Contribu` and not the full store title.

## What "confirmed" means here

The demo loads [GitHub Isometric Contributions](https://chromewebstore.google.com/detail/github-isometric-contribu/mjoedlfflcchnleknnceiplgaeoegien), an extension that rebuilds GitHub's flat contribution grid as a 3D isometric chart inside a `div.ic-contributions-wrapper` (the `ic-` prefix is the extension's own namespace). Stock GitHub never renders that node. So the test is simple: navigate to a profile and `page.wait_for_selector("div.ic-contributions-wrapper")`. If the selector resolves, the session attached the extension and it ran. If it times out, the script says so and moves on to release. No scraping, no table, just a yes or no on whether the injected UI showed up.

## Run it

```bash
cd examples/extensions-py
cp .env.example .env          # set STEEL_API_KEY
uv run main.py
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). `uv sync` runs automatically on first `uv run`, so there is no separate install step. The script prints a session viewer URL as it starts. Open it in another tab to watch the extension render on a live GitHub profile.

Your output varies. Structure looks like this:

```text
Steel + Extensions (Python)
============================================================

Checking for an existing extension...
No existing extension found
Uploading extension...
Uploaded extension: ext_...

Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...

Connected to browser via Playwright
Navigating to https://github.com/junhsss ...
Waiting for injected element: div.ic-contributions-wrapper
Injected element appeared: the extension loaded into the page.
Releasing session...
Session released
Done!
```

A run takes ~20 seconds and costs a few cents of session time. The first run uploads the extension, later runs reuse the ID.

## Make it yours

- **Upload your own extension.** `client.extensions.upload(url=...)` accepts any Chrome Web Store listing URL. Swap `EXTENSION_URL`, then update `EXTENSION_NAME` to the truncated, underscored form `extensions.list()` returns.
- **Confirm a different node.** Change `INJECTED_SELECTOR` to whatever your extension adds to the page. The wait is the proof, so pick a selector that only exists when the extension ran.
- **Target a specific profile.** Set `PROFILE_URL` to any public GitHub user, such as `https://github.com/steel-dev`.
- **Stack extensions.** `extension_ids` is a list. Upload several (ad blocker, consent killer, a helper content script) and attach them in one session.

## Related

[extensions-ts](/cookbook/extensions) (same recipe, plus a styled stats table) · [extensions-go](/cookbook/extensions) · [extensions-rs](/cookbook/extensions) · [profiles-py](/cookbook/profiles) (reuse a full browser profile) · [Playwright docs](https://playwright.dev/python)

**Rust**

A Steel session boots a clean Chromium with no extensions installed. The Extensions API closes that gap: upload a Chrome extension once with `client.extensions().upload(...)`, get back an `ext_...` id, and attach it to any later session by setting `extension_ids` on `SessionCreateParams`. Steel loads the content scripts and background workers before the first navigation, so by the time chromiumoxide opens the page the extension has already run.

This recipe uploads [GitHub Isometric Contributions](https://chromewebstore.google.com/detail/github-isometric-contribu/mjoedlfflcchnleknnceiplgaeoegien), which replaces GitHub's flat contribution grid with a 3D isometric one wrapped in `div.ic-contributions-wrapper`. That wrapper is the proof: it does not exist on a stock GitHub profile, so finding it on the page means the session attached and ran the extension.

## Upload once, reuse forever

Uploads persist on your account, so re-running should not re-upload. `resolve_extension` lists what is already there and matches on the name Steel hands back, which is truncated and underscored (`Github_Isometric_Contribu`, not the full store title). A hit reuses the id; a miss uploads from the store URL and uses the fresh id. Either path produces one id, and that single value is all `SessionCreateParams` needs:

```rust
let session = client
    .sessions()
    .create(SessionCreateParams {
        extension_ids: Some(vec![extension_id]),
        ..Default::default()
    })
    .await?;
```

## Confirming the injection

chromiumoxide has no `wait_for_selector`, so `wait_for_selector` here polls the page itself: it runs `!!document.querySelector('div.ic-contributions-wrapper')` through `page.evaluate(...).into_value()` once a second for up to 15 tries and stops on the first `true`. The program prints whether the wrapper showed up rather than scraping the numbers inside it; the goal is to confirm the DOM was rewritten, not to read it. If the extension never attached, the selector stays absent for all 15 attempts and the run says so.

## Run it

```bash
cd examples/extensions-rs
cp .env.example .env          # set STEEL_API_KEY
cargo run
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The first build pulls chromiumoxide and tokio and takes a minute or two. As the program starts it prints a session viewer URL; open it in a second tab to watch the isometric grid render live.

Your output varies. Structure looks like this:

```text
Checking for extension Github_Isometric_Contribu...
Not found, uploading from the Chrome Web Store...
Uploaded Github_Isometric_Contribu (ext_ab12cd34)
Using extension ext_ab12cd34
Creating Steel session...
Session live at https://app.steel.dev/sessions/ab12cd34
Connected over CDP, opening https://github.com/junhsss...
Extension injected div.ic-contributions-wrapper; the contribution grid was rewritten.
Releasing session...
Session released
```

The first run uploads the extension; later runs print `Reusing uploaded extension` and skip straight to the session. `main` captures the run result, releases the session, then returns the error, so a failed check still tears the session down instead of leaving it to idle out.

## Make it yours

- **Upload your own extension.** `upload(...)` takes either a `url` (any Chrome Web Store listing) or a `file` (a `.zip`/`.crx` you supply). Swap `EXTENSION_URL` and update `EXTENSION_NAME` to the truncated, underscored name `extensions().list()` reports back.
- **Target a specific profile.** `PROFILE_URL` is just a constant; point it at any public GitHub profile.
- **Stack extensions.** `extension_ids` is a `Vec`. Upload several (an ad blocker, a consent killer, a helper content script) and pass all their ids together.
- **Assert instead of print.** Turn the `wait_for_selector` boolean into a hard failure if you want the run to exit non-zero when the extension does not load.

## Related

- [extensions-ts](/cookbook/extensions) is the original this ports, driving Playwright and scraping the injected stats into a table.
- [extensions-py](/cookbook/extensions) and [extensions-go](/cookbook/extensions) are the same upload-and-attach flow in Python and Go.
- [profiles-rs](/cookbook/profiles) persists a full browser profile across sessions, the heavier sibling to attaching extensions per run.
- [chromiumoxide docs](https://docs.rs/chromiumoxide) cover `Page`, `evaluate`, and `find_element` in full.

**Go**

A fresh Steel session boots a stock Chrome with no extensions. The Extensions API lets you upload a Chrome extension once, keep the returned ID on your account, and attach it to any session through `ExtensionIDs` on `Sessions.Create`. Content scripts run before chromedp ever issues a `Navigate`, so by the time the page renders the extension has already rewritten the DOM.

This recipe proves that attachment happened by waiting on a selector the extension creates, nothing more. It does not scrape or pretty-print the numbers the extension renders.

```go
list, _ := client.Extensions.List(ctx)
for _, ext := range list.Extensions {
	if ext.Name == "Github_Isometric_Contribu" {
		extID = ext.ID
	}
}

if extID == "" {
	uploaded, _ := client.Extensions.Upload(ctx, steel.ExtensionUploadParams{
		URL: steel.Ptr("https://chromewebstore.google.com/detail/github-isometric-contribu/mjoedlfflcchnleknnceiplgaeoegien"),
	})
	extID = uploaded.ID
}

sess, _ := client.Sessions.Create(ctx, steel.SessionCreateParams{
	ExtensionIDs: steel.F([]string{extID}),
})
```

## How the confirmation works

The demo attaches [GitHub Isometric Contributions](https://chromewebstore.google.com/detail/github-isometric-contribu/mjoedlfflcchnleknnceiplgaeoegien), which swaps GitHub's flat contribution grid for a 3D isometric one under a wrapper element it namespaces with `ic-`. After navigating to a profile, `chromedp.WaitVisible("div.ic-contributions-wrapper", chromedp.ByQuery)` runs against a 30-second timeout context. If the element appears, the extension loaded; if the context expires first, the wait returns an error and the run reports that the UI never showed. That selector belongs to the extension alone, so its presence is the proof.

Uploads persist on your account, which is why `Extensions.List` is the first call: a repeat run finds the existing ID and skips the re-upload. Names come back normalized, truncated and underscored, so the match is against `Github_Isometric_Contribu` rather than the full store title.

## Run it

```bash
cd examples/extensions-go
cp .env.example .env          # set STEEL_API_KEY
go mod tidy
go run .
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The session viewer URL prints as the run starts; open it in another tab to watch the extension render on a live profile.

```text
Looking for an existing extension upload...
Not found. Uploading from the Chrome Web Store...
Uploaded extension ext_abc123
Creating Steel session with the extension attached...
Session created. Watch it live at https://app.steel.dev/sessions/ab12cd34
Navigating to https://github.com/junhsss...
Waiting for the extension to inject "div.ic-contributions-wrapper"...
Extension UI confirmed: the session attached and rewrote the DOM.
Releasing session...
```

## Make it yours

- **Upload your own extension.** `Extensions.Upload` takes any Chrome Web Store listing URL. Swap `storeURL` and update the `extensionName` that `Extensions.List` matches on, remembering the truncated, underscored form.
- **Target a different profile.** Change `profileURL` to any public GitHub user.
- **Stack extensions.** `ExtensionIDs` is a slice. Upload several and attach them together in one `Sessions.Create`.
- **Assert on real content.** Once the wrapper is visible, add `chromedp.Text` or `chromedp.Evaluate` steps to pull values the extension injected.

## Related

[extensions-ts](/cookbook/extensions) (Playwright sibling) · [extensions-py](/cookbook/extensions) · [extensions-rs](/cookbook/extensions) · [profiles-go](/cookbook/profiles) (reuse a full browser profile) · [chromedp docs](https://pkg.go.dev/github.com/chromedp/chromedp)

## Related recipes

- [Move files between your machine and a cloud browser](/cookbook/files): Use the Steel Files API with Playwright to automate file uploads and downloads in the cloud.
- [Scrape a page to Markdown, screenshot, and PDF](/cookbook/scrape): Use the Steel TypeScript SDK's direct API to scrape a page to clean Markdown for LLM context, plus screenshot and PDF, with no browser library.
- [Watch Claude pricing for divergent A/B variants](/cookbook/convex-price-watch): Convex cron plus two parallel Steel proxy probes against claude.com/pricing. Stores per-tier per-region snapshots and surfaces tiers where the probes disagree.


# Move files between your machine and a cloud browser
URL: https://docs.steel.dev/cookbook/files


**TypeScript**

Every Steel session ships with a scoped filesystem inside the session VM. `client.sessions.files` exposes methods to move bytes across the boundary between your machine and that sandbox. This recipe uses `upload` to push a local CSV into the session, hands the resulting path to a remote `<input type="file">` over CDP, and lets the browser render a chart against it. It's one of Steel's [session APIs](/cookbook/topics/steel-apis).

```typescript
const uploadedFile = await client.sessions.files.upload(session.id, {
  file,
});
```

`file` is a Web `File` built from `fs.readFileSync("./assets/stock.csv")`. What comes back is a record whose `path` is a handle inside the session VM (something like `stock.csv` at the sandbox root). That path is not valid on your laptop, and paths on your laptop are not valid inside the session. The whole recipe hinges on keeping that distinction straight.

## Wiring a remote file into a DOM input

`page.setInputFiles("./local.csv")` resolves paths on the machine running Playwright. Since Chromium lives on a Steel VM, you need to resolve the path there instead. The `main` function drops down to raw CDP:

```typescript
const cdpSession = await currentContext.newCDPSession(page);
const document = await cdpSession.send("DOM.getDocument");

const inputNode = await cdpSession.send("DOM.querySelector", {
  nodeId: document.root.nodeId,
  selector: "#load-file",
});

await cdpSession.send("DOM.setFileInputFiles", {
  files: [uploadedFile.path],
  nodeId: inputNode.nodeId,
});
```

`DOM.setFileInputFiles` runs browser-side, so `uploadedFile.path` resolves against the session VM, which is exactly where `upload()` wrote the bytes. After that, it's plain Playwright: wait for `svg.main-svg`, scroll into view, screenshot to `stock.png` on your local disk.

## Run it

```bash
cd examples/files-ts
cp .env.example .env          # set STEEL_API_KEY
npm install
npm start
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The script prints a session viewer URL as it starts. Open it in another tab to watch the upload land and the chart render.

Your output varies. Structure looks like this:

```text
Steel + Files API Starter
============================================================

Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...

Uploading CSV file to the Steel session...
CSV file uploaded successfully!
File path on Steel session: stock.csv

Connected to browser via Playwright

Releasing session...
Session released
Done!
```

`stock.png` lands in the recipe folder. It's the rendered chart, captured server-side after the CSV was parsed remotely, then saved locally. A run takes ~15 seconds.

## The rest of the surface

The recipe touches `upload` and nothing else, but `client.sessions.files` has more:

- `list(sessionId)`: returns every file in the session namespace with `{ path, size, lastModified }`. Useful after the browser triggers a download and you need to find the new file.
- `download(sessionId, path)`: pulls a single file back out. Stream the response body to disk.
- `downloadArchive(sessionId)`: zips the whole namespace into one response. One call instead of N.
- `delete(sessionId, path)` and `deleteAll(sessionId)`: explicit cleanup. Releasing the session also clears storage.

Browser-initiated downloads (PDF exports, file-save dialogs) land in the same namespace automatically, so the inverse of this recipe is: drive the page to export, then `list()` and `download()` what showed up.

There's also `client.files` (without `.sessions`), an organization-scoped store that persists across sessions. Same method shape. Useful for fixtures and assets you don't want to re-upload every run.

## Make it yours

- **Upload from a URL.** Pass a string instead of a `File`: `client.sessions.files.upload(session.id, { file: "https://example.com/report.pdf" })`. Steel fetches it server-side and drops it in the session namespace, skipping your machine entirely.
- **Harvest generated files.** Swap the `csvplot.com` flow for a site that exports. After the download fires, call `list()` to discover the new path, then `download()` it back.
- **Target a nested path.** `upload()` accepts a `path` argument to control where the file lands inside the sandbox. Default is the filename at root; pass `path: "inputs/stock.csv"` to nest.

## Related

[Credentials](/cookbook/credentials) for auth tokens kept out of the filesystem. [Auth context](/cookbook/auth-context) for cookies and storage state. [Profiles](/cookbook/profiles) for persistent user-data directories across runs. [Extensions](/cookbook/extensions) for loading unpacked Chrome extensions into a session.

**Python**

`client.sessions.files` moves bytes between your machine and the filesystem that lives inside a Steel session VM. This recipe uploads a local CSV into the session, hands the path the upload returns to a remote `<input type="file">` over raw CDP, and screenshots the chart the page renders from it. The whole thing turns on one fact: a file you push over the API lands at a path the browser can read, and that path means nothing back on your laptop.

## Shaping the upload

The Python SDK speaks `multipart/form-data`, so the `file` argument takes the same tuple shape as `requests` or the OpenAI client: `(filename, content, content_type)`.

```python
csv_bytes = (Path(__file__).parent / "assets" / "stock.csv").read_bytes()

uploaded = client.sessions.files.upload(
    session.id,
    file=("stock.csv", csv_bytes, "text/csv"),
)
```

`uploaded.path` comes back as a handle inside the session sandbox (typically just `stock.csv` at the root). Pass a URL string instead of the tuple and Steel fetches the file server-side, so the bytes never touch your machine at all.

## Reaching the input over CDP

`page.set_input_files("./stock.csv")` resolves paths on the host running Playwright. The browser is on a Steel VM, so the file has to be resolved there. That means dropping under Playwright's locators to the Chrome DevTools Protocol, which `new_cdp_session` exposes as a `send(method, params)` call:

```python
cdp = current_context.new_cdp_session(page)
document = cdp.send("DOM.getDocument")
input_node = cdp.send(
    "DOM.querySelector",
    {"nodeId": document["root"]["nodeId"], "selector": "#load-file"},
)
cdp.send(
    "DOM.setFileInputFiles",
    {"files": [uploaded.path], "nodeId": input_node["nodeId"]},
)
```

`send` returns plain dicts, so the node ids are read with subscript access. Because `DOM.setFileInputFiles` runs browser-side, `uploaded.path` resolves against the VM, exactly where `upload` wrote it. After that it is ordinary Playwright: wait for `svg.main-svg`, scroll it into view, and screenshot it to `stock.png` on your local disk.

## Run it

```bash
cd examples/files-py
cp .env.example .env          # set STEEL_API_KEY
uv run main.py
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). `uv sync` runs automatically on first `uv run`, so there is no separate install step. The script prints a session viewer URL as it starts. Open it in another tab to watch the upload land and the chart render.

Your output varies. Structure looks like this:

```text
Steel + Files API Starter
============================================================

Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...
Uploading CSV file to the Steel session...
CSV file uploaded successfully!
File path on Steel session: stock.csv
Connected to browser via Playwright
Releasing session...
Session released
Done!
```

`stock.png` lands in the recipe folder: the chart, parsed and drawn remotely, captured server-side, then saved to your disk. A run takes about 15 seconds.

## Make it yours

- **Skip your machine.** Pass a URL string for `file` instead of the tuple, and Steel downloads it into the session directly.
- **Nest the upload.** `upload` takes a `path` argument that sets where the file lands in the sandbox. Default is the filename at root; pass `path="inputs/stock.csv"` to nest it.
- **Pull files back out.** `client.sessions.files.list(session.id)` enumerates the namespace, and `download(session.id, path)` returns the bytes. Browser-initiated downloads land in the same namespace, so the inverse recipe is: drive the page to export, then list and download what appeared.

## Related

[TypeScript version](/cookbook/files) covers the same flow with the Web `File` API. [Go version](/cookbook/files) and [Rust version](/cookbook/files) build the upload from typed structs. The CDP calls map to Playwright's [`new_cdp_session`](https://playwright.dev/python/docs/api/class-cdpsession); the protocol methods are in the [DOM domain reference](https://chromedevtools.github.io/devtools-protocol/tot/DOM/).

**Rust**

Each Steel session owns a scoped filesystem inside its VM, and `client.sessions().files()` moves bytes across the boundary between your machine and that sandbox. This recipe reads a local CSV, uploads it with `upload`, captures the path the file landed at inside the session, and hands that path to a remote `<input type="file">` so csvplot.com renders a chart against bytes that never touched the browser's own disk.

```rust
let uploaded = client
    .sessions()
    .files()
    .upload(
        session_id,
        SessionFileUploadParams {
            file: FileUpload::new("stock.csv", bytes).with_content_type("text/csv"),
            path: None,
        },
    )
    .await?;
```

`FileUpload::new` takes a filename and the raw bytes; `with_content_type` is the builder step for the MIME type. What comes back is a `File` whose `path` is a handle inside the session VM (for this asset, `stock.csv` at the sandbox root). That path is meaningful to the browser running on Steel, not to your laptop, and keeping those two namespaces straight is the whole point of the recipe.

## Driving a file input over raw CDP

chromiumoxide's typed helpers resolve file paths on the machine running your code, which is the wrong filesystem here. The fix is to issue the `DOM` commands yourself. chromiumoxide re-exports the generated CDP types under `chromiumoxide::cdp::browser_protocol`, and `page.execute(...)` sends any of them and deserializes the typed reply:

```rust
let document = page.execute(GetDocumentParams::default()).await?;
let input = page
    .execute(QuerySelectorParams::new(document.root.node_id, "#load-file"))
    .await?;

page.execute(SetFileInputFilesParams {
    files: vec![uploaded.path.clone()],
    node_id: Some(input.node_id),
    backend_node_id: None,
    object_id: None,
})
.await?;
```

`DOM.setFileInputFiles` runs browser-side, so `uploaded.path` resolves against the session VM, which is exactly where `upload` wrote the bytes. After that it is ordinary automation: poll for `svg.main-svg`, scroll it into view, and screenshot the element to `stock.png` on your local disk.

## Run it

```bash
cd examples/files-rs
cp .env.example .env          # set STEEL_API_KEY
cargo run
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The program prints a session viewer URL as it starts. Open it in another tab to watch the upload land and the chart render.

Your output varies. Structure looks like this:

```text
Creating Steel session...
Session live at https://app.steel.dev/sessions/ab12cd34...
Uploading stock.csv (5488 bytes) to the session...
Uploaded. Path inside the session VM: stock.csv
Connected over CDP, opening csvplot.com...
Setting the uploaded file on the page's #load-file input...
Saved stock.png (48213 bytes)
Releasing session...
Session released
```

`stock.png` lands in the recipe folder: the rendered chart, captured server-side after the CSV was parsed remotely, then saved locally.

## Make it yours

- **Upload from a URL.** `FileUpload` carries the bytes here, but the underlying endpoint also accepts a URL it fetches server-side, so you can skip reading the file locally for large fixtures.
- **Harvest generated files.** Swap the csvplot flow for a site that exports. After the download fires, call `files().list(session_id)` to discover the new path, then `files().download(session_id, &path)` to pull the bytes back.
- **Target a nested path.** Set `path: Some("inputs/stock.csv".into())` on `SessionFileUploadParams` to control where the file lands inside the sandbox instead of the default filename at root.

## Related

[files-ts](/cookbook/files), [files-py](/cookbook/files), and [files-go](/cookbook/files) are the same recipe in other languages. The [chromiumoxide docs](https://docs.rs/chromiumoxide) cover `page.execute` and the generated CDP command types under `chromiumoxide::cdp::browser_protocol`.

**Go**

A Steel session carries its own filesystem inside the session VM. `client.Sessions.Files` moves bytes across the boundary between your machine and that sandbox. This recipe reads a local CSV, uploads it with `Upload`, then hands the returned server-side path to a remote `<input type="file">` so csvplot.com can render a chart against bytes that never lived on the browser host's local disk.

The upload is a plain Go value, not an `io.Reader` or a multipart form you assemble yourself:

```go
uploaded, err := client.Sessions.Files.Upload(ctx, sess.ID, steel.SessionFileUploadParams{
	File: steel.FileUpload{
		Name:        "stock.csv",
		Content:     csvBytes,
		ContentType: "text/csv",
	},
})
```

`Content` is the raw `[]byte` you got from `os.ReadFile`. What comes back is a `*steel.File` whose `Path` is a handle inside the session VM (typically `stock.csv` at the sandbox root). That path is meaningless on your laptop, and your laptop's paths are meaningless inside the session. Keeping that distinction straight is the whole point.

## Wiring a remote file into a DOM input

chromedp's `chromedp.SetUploadFiles` resolves paths on the machine running chromedp, which is your laptop. The file we want lives on the Steel VM, so we drop to raw CDP from `github.com/chromedp/cdproto/dom` instead. `DOM.setFileInputFiles` runs browser-side, so `uploaded.Path` resolves against the session VM, exactly where `Upload` wrote the bytes. `setRemoteFileInput` wraps the three CDP calls in a `chromedp.ActionFunc` so it slots into a normal `chromedp.Run` task list:

```go
func setRemoteFileInput(selector, remotePath string) chromedp.Action {
	return chromedp.ActionFunc(func(ctx context.Context) error {
		root, err := dom.GetDocument().Do(ctx)
		if err != nil {
			return err
		}
		nodeID, err := dom.QuerySelector(root.NodeID, selector).Do(ctx)
		if err != nil {
			return err
		}
		return dom.SetFileInputFiles([]string{remotePath}).WithNodeID(nodeID).Do(ctx)
	})
}
```

After that it is ordinary chromedp: `WaitVisible("svg.main-svg")`, then `FullScreenshot` to `stock.png` on your local disk.

## Run it

```bash
cd examples/files-go
cp .env.example .env          # set STEEL_API_KEY
go mod tidy
go run .
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The program prints a session viewer URL as it starts. Open it in another tab to watch the upload land and the chart render.

Your output varies. Structure looks like this:

```text
Creating Steel session...
Session created. Watch it live at https://app.steel.dev/sessions/ab12cd34...
Uploading stock.csv to the session...
Uploaded. Path inside the session VM: stock.csv
Loading csvplot.com and feeding it the uploaded file...
Saved chart to stock.png
Releasing session...
```

`stock.png` lands in the recipe folder. It is the rendered chart, captured server-side after the CSV was parsed remotely, then saved locally.

## Make it yours

- **Upload from a URL.** `steel.FileUpload` carries bytes, but the underlying API also accepts a URL string for the file field. Fetch a report server-side and skip your machine entirely.
- **Harvest generated files.** Swap the csvplot.com flow for a site that exports. After the download fires, call `client.Sessions.Files.List(ctx, sess.ID)` to discover the new path, then `client.Sessions.Files.Download(ctx, sess.ID, path)` to pull it back as an `io.ReadCloser`.
- **Target a nested path.** `SessionFileUploadParams` has an optional `Path` field. The default is the filename at the sandbox root; set `Path` to a pointer to nest the upload, for example under `inputs/`.

## Related

[files-ts](/cookbook/files) and [files-py](/cookbook/files) and [files-rs](/cookbook/files) for the same recipe in other languages. [chromedp](https://github.com/chromedp/chromedp) and its [cdproto/dom](https://pkg.go.dev/github.com/chromedp/cdproto/dom) package for the raw CDP surface used here.

## Related recipes

- [Upload and run browser extensions](/cookbook/extensions): Use the Steel Extensions API with Playwright to upload and run browser extensions.
- [Scrape a page to Markdown, screenshot, and PDF](/cookbook/scrape): Use the Steel TypeScript SDK's direct API to scrape a page to clean Markdown for LLM context, plus screenshot and PDF, with no browser library.
- [Watch Claude pricing for divergent A/B variants](/cookbook/convex-price-watch): Convex cron plus two parallel Steel proxy probes against claude.com/pricing. Stores per-tier per-region snapshots and surfaces tiers where the probes disagree.


# Drive a browser with Gemini Computer Use
URL: https://docs.steel.dev/cookbook/gemini-computer-use


**TypeScript**

Gemini exposes [computer use](/cookbook/topics/computer-use) as a built-in tool type, not a hand-written schema. You set `config.tools = [{ computerUse: { environment: Environment.ENVIRONMENT_BROWSER } }]` on a `generateContent` call and the model plans against a fixed action vocabulary (`click_at`, `type_text_at`, `navigate`, `scroll_document`, `search`, `drag_and_drop`, `key_combination`, ...) with coordinates in a normalized 0-1000 grid.

The model defaults to `gemini-3-flash-preview`. Conversation state lives entirely on your side, appended to `this.contents` turn by turn. The [Gemini Computer Use integration](/integrations/gemini-computer-use) covers install and setup.

## Coordinates and action mapping

Gemini plans in a 1000x1000 normalized grid regardless of the browser dimensions; `denormalizeX` and `denormalizeY` scale back to pixels off `viewportWidth`/`viewportHeight` (1440x900 by default).

```typescript
private denormalizeX(x: number): number {
  return Math.round((x / MAX_COORDINATE) * this.viewportWidth);
}
```

Several of Gemini's actions are compound; the starter expands them. `type_text_at` fans into click, Ctrl+A, Backspace, type_text, Enter, wait, screenshot. `navigate` and `search` skip the URL bar hunt by doing the Chrome `Ctrl+L` trick: focus the address bar, type, press Enter, wait. `key_combination` arrives as a `+`-joined string like `"Control+Enter"`; `splitKeys` and `normalizeKey` break it apart and rewrite synonyms (`CTRL` to `Control`, `CMD` to `Meta`, `ARROWUP` to `ArrowUp`).

Every mapped action sets `screenshot: true` on the Steel call. The PNG comes back in the same response.

## Sending frames back

Each completed call produces two parts in a single user-role turn: a `functionResponse` that names the call and echoes the current URL, then an `inlineData` part carrying the screenshot as raw base64.

```typescript
const functionResponse: FunctionResponse = {
  name: fc.name ?? "",
  response: { url: result.url ?? this.currentUrl },
};
parts.push({ functionResponse });

parts.push({
  inlineData: {
    mimeType: "image/png",
    data: result.screenshotBase64,
  },
});
```

## The loop

Four exits, in rough order of frequency:

- **Text, no function calls.** The model wrote a final message.
- **Empty turn.** No calls, no text. `consecutiveNoActions` increments. Three in a row stops the loop.
- **`MALFORMED_FUNCTION_CALL` with nothing else.** A known quirk of the preview model; the loop continues to the next iteration.
- **Iteration cap.** 50 turns by default.

The `finally` in `main` calls `agent.cleanup()`, which releases the Steel session.

## Run it

```bash
cd examples/gemini-computer-use-ts
cp .env.example .env          # set STEEL_API_KEY and GEMINI_API_KEY
npm install
npm start
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [aistudio.google.com](https://aistudio.google.com/apikey). Override the task inline:

```bash
TASK="Find the current weather in New York City" npm start
```

Your output varies. Structure looks like this:

```text
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34...

Executing task: Go to Steel.dev and find the latest news
============================================================
I'll navigate to steel.dev and scan the landing page for news.
navigate({"url":"https://steel.dev"})
scroll_document({"direction":"down"})
click_at({"x":520,"y":410})
Steel's latest release adds ...

============================================================
TASK EXECUTION COMPLETED
============================================================
Duration: 78.2 seconds
```

Expect roughly 60-120 seconds and 15-40 turns for a simple browsing task.

## Make it yours

- **Resize the viewport.** `viewportWidth` / `viewportHeight` in the `Agent` constructor feed both the Steel session `dimensions` and the `denormalizeX` / `denormalizeY` math.
- **Swap the model.** `this.model = "gemini-3-flash-preview"` is the only version string.
- **Tune the system prompt.** `BROWSER_SYSTEM_PROMPT` carries the browsing conventions: today's date via `formatToday()`, clear-before-typing, batch-actions-when-possible, black-screen recovery.
- **Gate safety decisions.** Replace the auto-acknowledgement branch with a human approval before the next `executeComputerAction` fires.
- **Hand off auth.** Pair this recipe with Steel's [credentials](/cookbook/credentials) or [auth contexts](/cookbook/auth-context) to start the session already logged in.

## Related

[Computer use docs](https://ai.google.dev/gemini-api/docs/computer-use) · [Python version](/cookbook/gemini-computer-use) · [Anthropic equivalent](/cookbook/claude-computer-use) · [OpenAI equivalent](/cookbook/openai-computer-use)

**Python**

Gemini's computer use ships through `google.genai` as a single built-in tool: `Tool(computer_use=ComputerUse(environment=ENVIRONMENT_BROWSER))`. Setting `ENVIRONMENT_BROWSER` unlocks a fixed vocabulary of browser function calls (`click_at`, `type_text_at`, `scroll_document`, `scroll_at`, `navigate`, `search`, `key_combination`, `drag_and_drop`, `hover_at`, `go_back`, `go_forward`, `open_web_browser`, `wait_5_seconds`).

Steel supplies the screen. A Steel session is a headful Chromium in a VM, and `sessions.computer(session_id, action=...)` runs mouse and keyboard actions with a base64 PNG attached to the response.

## The loop

`Agent.execute_task` seeds two user-role `Part`s (`BROWSER_SYSTEM_PROMPT` and the task) into `self.contents`, then calls `generate_content` in a loop:

```python
response = self.client.models.generate_content(
    model=self.model,
    contents=self.contents,
    config=self.config,
)

candidate = response.candidates[0]
if candidate.content:
    self.contents.append(candidate.content)

reasoning = self.extract_text(candidate)
function_calls = self.extract_function_calls(candidate)
```

Gemini doesn't keep server-side conversation state, so every turn resends the full `contents` list including every prior screenshot.

## Coordinates live in a 0-1000 canvas

Gemini never emits pixel coordinates. Every spatial argument (`x`, `y`, `destination_x`, `destination_y`, `magnitude`) is scaled against `MAX_COORDINATE = 1000` regardless of viewport. `denormalize_x` and `denormalize_y` rescale onto Steel's viewport before each action:

```python
def denormalize_x(self, x: int) -> int:
    return int(x / MAX_COORDINATE * self.viewport_width)
```

## Sending screenshots back

Gemini expects each function response as two `Part`s in a user-role `Content`: a `FunctionResponse` with metadata, then an `inline_data` `Blob` carrying the PNG.

```python
function_response = FunctionResponse(
    name=fc.name or "",
    response={"url": url or self.current_url},
)
parts.append(Part(function_response=function_response))

parts.append(
    Part(
        inline_data=types.Blob(
            mime_type="image/png",
            data=screenshot_base64,
        )
    )
)
```

## Stopping conditions

`execute_task` ends one of three ways:

1. Gemini emits only text, no function calls.
2. Three consecutive iterations produce neither text nor function calls.
3. `max_iterations=50` caps total turns.

## Run it

```bash
cd examples/gemini-computer-use-py
cp .env.example .env          # set STEEL_API_KEY and GEMINI_API_KEY
uv run main.py
```

Steel keys live at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). Gemini keys come from [aistudio.google.com/apikey](https://aistudio.google.com/apikey).

Override the task per run:

```bash
TASK="Find the current weather in New York City" python main.py
```

Your output varies. Structure looks like this:

```text
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34...

Executing task: Go to Steel.dev and find the latest news
============================================================

I'll open steel.dev and look for the latest news.
navigate({"url": "https://steel.dev"})
scroll_document({"direction": "down"})
click_at({"x": 512, "y": 340})
...
Task complete - model provided final response

TASK EXECUTION COMPLETED
Duration: 78.4 seconds
Result: Steel's latest release notes mention ...
```

A run typically takes 60-180 seconds and 10-30 iterations. Because `generate_content` has no server-side state, every new turn resends the full `self.contents` list including every prior `Blob`. The `finally` block in `main()` calls `sessions.release()`.

## Make it yours

- Change the task. Edit `TASK` in `.env` or pass it inline.
- Swap the model. `self.model = "gemini-3-flash-preview"` in `Agent.__init__`.
- Tune the viewport. `viewport_width` and `viewport_height` in `Agent.__init__` flow into `sessions.create(dimensions=...)`.
- Gate safety confirmations. Replace the auto-acknowledge branch in `execute_task` with a human prompt.
- Persist a login. Pass `session_context` to `sessions.create` to resume with cookies and local storage. See [credentials](/cookbook/credentials).
- Raise the ceiling. `max_iterations=50` in `execute_task` bounds a single task.

## Related

[TypeScript version](/cookbook/gemini-computer-use) · [Gemini Computer Use guide](https://ai.google.dev/gemini-api/docs/computer-use) · [google-genai SDK](https://googleapis.github.io/python-genai/)

**Rust**

There is no official Gemini Rust SDK, so this port talks to the `generateContent` REST endpoint directly over `reqwest`. The request body is a hand-built `serde_json::Value`: `contents` accumulates the conversation turn by turn, and `tools` carries a single `{ "computerUse": { "environment": "ENVIRONMENT_BROWSER" } }` entry that switches Gemini into its built-in computer-use vocabulary. The browser itself is a Steel cloud session driven through the `steel-rs` crate, the same `sessions().computer(...)` surface the Anthropic and OpenAI Rust recipes use.

The model is `gemini-3-flash-preview`, the viewport is 1440x900, and the agent caps out at 50 iterations.

## REST plumbing and coordinates

Everything in the request and response is camelCase JSON, so the two response structs (`Candidate`, `GenerateContentResponse`) carry `#[serde(rename_all = "camelCase")]` and the rest is read straight off `serde_json::Value` with `.get(...)`. Auth is the `x-goog-api-key` header rather than a bearer token.

Gemini plans on a fixed 0-1000 grid regardless of the real viewport. `denormalize_x` and `denormalize_y` scale those numbers back to pixels off `VIEWPORT_WIDTH` / `VIEWPORT_HEIGHT` before any coordinate reaches Steel. Several actions are compound and get expanded locally: `type_text_at` fans into click, Ctrl+A, Backspace, type, optional Enter, and a wait; `navigate` and `search` skip the address-bar hunt with the Chrome `Ctrl+L` trick (focus the bar, type the URL, press Enter, wait). `key_combination` arrives as a `+`-joined string such as `"Control+Enter"`, which `split_keys` and `normalize_key` break apart and rewrite to canonical names (`CTRL` to `Control`, `CMD` to `Meta`, `ARROWUP` to `ArrowUp`).

## Sending frames back

In REST the screenshot stays a base64 string the whole way through. Each completed call appends two parts to a single user-role turn: a `functionResponse` naming the call and echoing the current URL, then an `inlineData` part with `mimeType` `image/png` and the base64 PNG as `data`. The bytes are never decoded.

The loop has four exits: a text-only turn (the model wrote its final answer), three empty turns in a row, a `MALFORMED_FUNCTION_CALL` finish reason with nothing else (a known preview-model quirk, retried on the next iteration), and the 50-iteration cap. A call may carry a `safety_decision` arg requesting confirmation; the agent logs it and auto-acknowledges before running the action.

## Run it

```bash
cd examples/gemini-computer-use-rs
cp .env.example .env          # set STEEL_API_KEY and GEMINI_API_KEY
cargo run
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [aistudio.google.com](https://aistudio.google.com/apikey). Override the task with the `TASK` env var:

```bash
TASK="Find the current weather in New York City" cargo run
```

Output varies. The shape looks like this:

```text
Steel + Gemini Computer Use Assistant
============================================================

Starting Steel session...
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34...
Steel session started!
Executing task: Go to Steel.dev and find the latest news
============================================================

I'll navigate to steel.dev and scan the landing page for news.
navigate({"url":"https://steel.dev"})
scroll_document({"direction":"down"})
click_at({"x":520,"y":410})
Steel's latest release adds ...

============================================================
TASK EXECUTION COMPLETED
============================================================
Duration: 78.2 seconds
Task: Go to Steel.dev and find the latest news
Result:
Steel's latest release adds ...
============================================================
Releasing Steel session...
Session completed. View replay at https://app.steel.dev/sessions/ab12cd34...
```

Expect roughly 60 to 120 seconds and 15 to 40 turns for a simple browsing task.

## Make it yours

- **Resize the viewport.** `VIEWPORT_WIDTH` / `VIEWPORT_HEIGHT` feed both the Steel session `dimensions` and the `denormalize_x` / `denormalize_y` math, so they stay in sync.
- **Swap the model.** `GEMINI_URL` is the only place the version string `gemini-3-flash-preview` appears.
- **Tune the system prompt.** `browser_system_prompt` carries the browsing conventions: today's date via `format_today`, clear-before-typing, batch-actions-when-possible, black-screen recovery.
- **Gate safety decisions.** Replace the auto-acknowledge branch with a human approval before the next `execute_computer_action` fires.
- **Cap the run.** `MAX_ITERATIONS` bounds the loop; lower it for cheaper experiments.

## Related

[Gemini computer use docs](https://ai.google.dev/gemini-api/docs/computer-use) · [TypeScript version](/cookbook/gemini-computer-use) · [Python version](/cookbook/gemini-computer-use) · [Anthropic equivalent](/cookbook/claude-computer-use) · [OpenAI equivalent](/cookbook/openai-computer-use)

**Go**

`google.golang.org/genai` exposes computer use as a typed tool on the request config: set `config.Tools = []*genai.Tool{{ComputerUse: &genai.ComputerUse{Environment: genai.EnvironmentBrowser}}}` and `client.Models.GenerateContent(ctx, "gemini-3-flash-preview", contents, config)` starts planning against a fixed browser vocabulary (`click_at`, `type_text_at`, `navigate`, `scroll_document`, `search`, `drag_and_drop`, `key_combination`, `hover_at`, `go_back`, `go_forward`, `open_web_browser`, `wait_5_seconds`). Coordinates arrive in a normalized 0-1000 grid.

Steel runs the screen. A session is a headful Chromium in a VM, and `client.Sessions.Computer(ctx, sessionID, body)` takes the action as its body and returns a `*SessionComputerResponse` whose `Base64Image` carries the resulting PNG.

## Two typed surfaces, one bytes gotcha

The Steel computer endpoint accepts the action as an `any` body, so each action is a distinct struct: `steel.ClickMouse`, `steel.MoveMouse`, `steel.PressKey`, `steel.TypeText`, `steel.Scroll`, `steel.DragMouse`, `steel.Wait`, `steel.TakeScreenshot`. The agent's `switch fc.Name` builds the right one per Gemini call, and `run` reads `*resp.Base64Image` back out (pointer fields, nil-checked).

Gemini's args land in a `map[string]any` with JSON types: numbers are `float64`, so `argInt` casts before `denormalizeX` / `denormalizeY` scale 0-1000 onto the 1440x900 viewport. The one trap worth naming: `genai.Blob.Data` is `[]byte`, not a base64 string. Steel hands back base64 text, so every screenshot is run through `base64.StdEncoding.DecodeString` before it becomes an `InlineData` part.

```go
data, err := base64.StdEncoding.DecodeString(shots[i])
parts = append(parts, &genai.Part{
    InlineData: &genai.Blob{MIMEType: "image/png", Data: data},
})
```

Several Gemini actions are compound and get expanded locally. `type_text_at` fans into click, Ctrl+A, Backspace, type, optional Enter, a one-second wait, then a screenshot. `navigate` and `search` skip hunting for the URL bar by doing the Ctrl+L focus trick in `openURL`. `key_combination` arrives as a `+`-joined string; `splitKeys` and `normalizeKey` break it apart and rewrite synonyms (`CTRL` to `Control`, `CMD` to `Meta`, `ARROWUP` to `ArrowUp`).

## The loop

`executeTask` seeds two user `Part`s (the system prompt and the task) into `contents`, then loops on `GenerateContent`. genai keeps no server-side state, so the full `contents` slice, every prior screenshot included, is resent each turn. Each turn appends the model's `Content`, then a user `Content` pairing one `FunctionResponse` (name plus current URL) with one `InlineData` screenshot per call. Four exits:

- Text and no function calls: the model wrote its final answer.
- Three consecutive empty turns (no text, no calls): stop.
- `FinishReasonMalformedFunctionCall` with nothing else: a preview-model quirk, skip to the next iteration.
- The 50-iteration cap.

`main` defers `agent.cleanup`, which releases the Steel session.

## Run it

```bash
cd examples/gemini-computer-use-go
cp .env.example .env          # set STEEL_API_KEY and GEMINI_API_KEY
go mod tidy
go run .
```

Steel keys live at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys); Gemini keys at [aistudio.google.com/apikey](https://aistudio.google.com/apikey). Override the task per run:

```bash
TASK="Find the current weather in New York City" go run .
```

Output varies. The shape is:

```text
Steel + Gemini Computer Use Assistant
============================================================

Starting Steel session...
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34...
Executing task: Go to Steel.dev and find the latest news
============================================================

I'll open steel.dev and scan the page for recent news.
navigate({"url":"https://steel.dev"})
scroll_document({"direction":"down"})
click_at({"x":512,"y":340})
Task complete - model provided final response
Releasing Steel session...
Session completed. View replay at https://app.steel.dev/sessions/ab12cd34...

============================================================
TASK EXECUTION COMPLETED
============================================================
Duration: 78.4 seconds
Task: Go to Steel.dev and find the latest news
Result:
Steel's latest release notes mention ...
============================================================
```

A run usually takes 60-180 seconds across 10-30 iterations.

## Make it yours

- Change the task. Edit `TASK` in `.env` or pass it inline.
- Swap the model. The `model` constant is the only version string.
- Resize the viewport. `viewportWidth` / `viewportHeight` feed both the Steel `Dimensions` and the denormalize math.
- Gate safety decisions. Replace the auto-acknowledge branch in `executeTask` with a human approval before the action fires.
- Hand off auth. Pass `SessionContext` to `Sessions.Create` to resume with cookies and local storage. See [credentials](/cookbook/credentials).

## Related

[TypeScript version](/cookbook/gemini-computer-use) · [Python version](/cookbook/gemini-computer-use) · [Anthropic equivalent](/cookbook/claude-computer-use) · [OpenAI equivalent](/cookbook/openai-computer-use) · [google.golang.org/genai](https://pkg.go.dev/google.golang.org/genai)

## Related recipes

- [Drive a mobile browser with Claude Computer Use](/cookbook/claude-computer-use-mobile): Claude Computer Use with Steel for autonomous task execution in mobile browser environments.
- [Drive a browser with Claude Computer Use](/cookbook/claude-computer-use): Connect Claude to a Steel browser session for autonomous web interactions.
- [Drive a browser with OpenAI Computer Use](/cookbook/openai-computer-use): Connect OpenAI's Computer Use Assistant to a Steel browser session for autonomous web interactions.


# Build a browser agent with Genkit
URL: https://docs.steel.dev/cookbook/genkit


[Genkit](https://genkit.dev/go/docs/get-started-go) is Google's Go framework for building LLM applications. `genkit.DefineTool` turns a typed Go function into a tool the model can call, inferring the tool's JSON schema from the input struct by reflection. This starter defines three tools over a Steel cloud browser and lets a Claude model drive them to read Hacker News.

```go
navigate := genkit.DefineTool(g, "navigate",
    "Open a URL in the live browser tab and wait for it to load.",
    func(tc *ai.ToolContext, in navigateInput) (string, error) {
        var title, url string
        err := chromedp.Run(b.tab,
            chromedp.Navigate(in.URL), chromedp.Title(&title), chromedp.Location(&url))
        return fmt.Sprintf("title=%q url=%s", title, url), err
    },
)

resp, err := genkit.Generate(ctx, g,
    ai.WithModelName("anthropic/claude-haiku-4-5"),
    ai.WithTools(navigate, extract, scrape),
    ai.WithMaxTurns(12),
    ai.WithOutputType(Report{}),
)
```

`genkit.Generate` runs the tool-calling loop for you. It calls the model, executes any tools the model requests, feeds the results back, and repeats until the model stops or `WithMaxTurns` is hit. You do not write the loop. `WithOutputType(Report{})` constrains the final turn to a Go struct, so `resp.Output(&out)` fills a typed `Report` and a malformed answer is sent back for the model to correct.

The schema the model sees comes from struct tags. `jsonschema_description` on a field becomes that argument's description in the tool definition, which is how the model learns what `rowSelector` or `attr` mean:

```go
type extractInput struct {
    RowSelector string      `json:"rowSelector" jsonschema_description:"CSS selector matching each item, e.g. 'tr.athing'."`
    Fields      []fieldSpec `json:"fields"`
    Limit       int         `json:"limit,omitempty"`
}
```

## Two ways to read a page

The tools cover the two access patterns a [browsing agent](/cookbook/topics/agents) needs:

- `navigate` + `extract` drive one live chromedp tab attached to the Steel session over CDP. `extract` takes a row selector plus a field-per-column list and runs the whole pull inside a single `chromedp.Evaluate`. Serial CDP round-trips to a cloud browser run about 200 to 300 ms each, so collapsing N rows by M fields into one evaluate keeps a page read under a second instead of stacking dozens of trips.
- `scrape` calls `client.Scrape` and returns clean Markdown for a URL without touching the tab. It is the reliable path when the agent just needs an article's text, and it sidesteps selector guesswork entirely.

The model picks per step. On Hacker News it navigates, extracts the story rows, and answers. Pointed at an article it tends to reach for `scrape`.

## Run it

```bash
cd examples/genkit
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
go mod tidy
go run .
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). The program prints a session viewer URL as it starts; open it in another tab to watch the browser run live. Each tool call prints its latency.

Your output varies. Structure looks like this:

```text
Steel + Genkit Go Starter
============================================================
Session: https://app.steel.dev/sessions/ab12cd34...
    navigate: 1183ms
    extract: 412ms (5 rows)

Agent finished.
{
  "summary": "The front page is mostly systems and AI tooling right now.",
  "stories": [
    {
      "rank": 1,
      "title": "Show HN: ...",
      "url": "https://example.com/...",
      "points": "342"
    }
  ]
}

tokens: 5120 in, 380 out

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A run takes about 20 to 40 seconds and 4 to 8 model turns. Cost is a few cents of Steel session time plus Claude tokens. The deferred cleanup in `main` releases the session: Steel bills per session-minute, so a leaked session keeps running until the default 5-minute timeout.

## Notes

- **Go version.** Genkit Go 1.0 requires Go 1.25, so `go.mod` declares `go 1.25.0`. chromedp is pinned to `v0.13.6`, the last release that still builds on Go 1.23, to keep the rest of the tree from pulling the toolchain higher than Genkit needs.
- **Session reuse.** One Steel session and one chromedp tab live in the `browser` struct shared by every tool, so `navigate` and `extract` act on the same page. `chromedp.NoModifyURL` stops chromedp rewriting Steel's websocket URL, which would drop the `apiKey` query parameter.

## Make it yours

- **Swap the model.** Change `WithModelName`. Any model the [Anthropic plugin](https://pkg.go.dev/github.com/firebase/genkit/go/plugins/anthropic) exposes works without code changes, for example `anthropic/claude-sonnet-4-5`. To use Gemini instead, register `&googlegenai.GoogleAI{}` in `genkit.Init`, set `GEMINI_API_KEY`, and pass `googleai/gemini-2.5-flash`.
- **Swap the task.** Change the prompt and the `Report` struct in `main`. The tools stay the same; the agent re-plans against the new output shape.
- **Add a tool.** Write a function `func(tc *ai.ToolContext, in In) (Out, error)`, wrap it with `genkit.DefineTool`, and add it to `WithTools`. A useful fourth is `click(selector string)` that runs `chromedp.Click` and waits for navigation.
- **Expose it as a flow.** Wrap the `Generate` call in `genkit.DefineFlow` to get tracing in the Genkit Dev UI and an HTTP handler for the same logic.

## Related

[Steel + Eino (Go)](/cookbook/eino) and [Steel + Pydantic AI (Python)](/cookbook/pydantic-ai) build the same agent shape in other frameworks. [Genkit Go docs](https://genkit.dev/go/docs/get-started-go) cover tools, flows, and plugins.

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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 Eino](/cookbook/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.


# Build a browser agent with Google ADK
URL: https://docs.steel.dev/cookbook/google-adk


**TypeScript**

[Google ADK](https://adk.dev/) (`@google/adk`) is Google's Agent Development Kit. You build an `LlmAgent` with a Gemini model, `instruction`, and a list of `FunctionTool`s, then hand it to a `Runner`. The runner owns the loop: it appends your message to a session, calls the model, dispatches tool calls, feeds results back, and yields an async stream of `Event`s until the [agent](/cookbook/topics/agents) produces its final answer.

This recipe wires that tool layer to a Steel cloud browser. Three `FunctionTool`s in `index.ts` (`navigate`, `snapshot`, `extract`) drive a single Playwright page over CDP. The Steel session opens once in `main()` before the runner starts, so the tools close over a live `page` rather than spinning up a browser per call. Demo task: read the front page of Hacker News and return the top 5 stories as JSON.

```typescript
const agent = new LlmAgent({
  name: "steel_research",
  model: new Gemini({ model: "gemini-2.5-flash", apiKey: GOOGLE_API_KEY }),
  instruction: "You operate a Steel cloud browser via tools. Workflow: navigate, snapshot, extract. ...",
  tools: [navigate, snapshot, extract],
});

const runner = new InMemoryRunner({ agent });

// runTask() wraps this loop in a fresh session and retries up to three times
// when a turn ends in MALFORMED_FUNCTION_CALL or an empty answer.
for await (const event of runner.runAsync({ userId, sessionId, newMessage })) {
  if (event.errorCode) break; // transient: caught by runTask, retried
  if (isFinalResponse(event)) finalText = stringifyContent(event).trim();
}
```

The model is built as an explicit `Gemini` instance so the key comes from `GOOGLE_API_KEY`. ADK's bare-string model path (`model: "gemini-2.5-flash"`) only resolves `GOOGLE_GENAI_API_KEY` or `GEMINI_API_KEY` from the environment, so passing `apiKey` directly keeps the one variable name consistent with the rest of the cookbook.

## Run it

```bash
cd examples/google-adk-ts
cp .env.example .env          # set STEEL_API_KEY and GOOGLE_API_KEY
npm install
npm start
```

Get keys at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and [aistudio.google.com/apikey](https://aistudio.google.com/apikey). `main()` prints a Live View URL right after the session opens; open it in another tab to watch the page as the agent navigates and scrapes.

Each tool logs its own latency, and the event loop logs a `step:` line whenever the model emits a tool call, so you can read the agent's progress as it happens. Your output varies. Structure looks like this:

```text
Steel + Google ADK Starter
============================================================
    open-session: 1380ms
Live View: https://app.steel.dev/sessions/ab12cd34...
  step: navigate
    navigate: 690ms
  step: snapshot
    snapshot: 410ms (3820 chars, 120 links)
  step: extract
    extract: 95ms (5 rows)

Agent finished.

Top stories:
{
  "stories": [
    {
      "rank": 1,
      "title": "Show HN: ...",
      "url": "https://...",
      "points": 412
    }
  ]
}

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A full run takes ~15-30 seconds and a few cents of Steel session time plus Gemini tokens. The `finally` block calls `steel.sessions.release()`; skip it and the session keeps billing until the default 5-minute timeout.

## How the loop reads

`runAsync` is an async generator, not a callback. Every `for await` iteration hands you one `Event`: a tool call the model wants to make, the tool's result coming back, a chunk of the model's reasoning, or the final answer. Two helpers from `@google/adk` keep the consumer thin:

- `isFinalResponse(event)` is true on the last event of the turn. That is the cue to capture the answer.
- `stringifyContent(event)` flattens an event's `content.parts` into a single string, so you do not walk the parts array by hand.

The `step:` log reads `event.content.parts` for `functionCall.name`. That is the only place the recipe inspects raw event parts; everything else leans on the two helpers. ADK logs one INFO line per event by default; `setLogLevel(LogLevel.WARN)` at startup keeps the console to the agent's own output.

`runTask` runs that loop inside a fresh session and watches `event.errorCode`. gemini-2.5-flash occasionally ends a turn with `MALFORMED_FUNCTION_CALL` or an empty answer, so the helper retries up to three times before giving up rather than failing the whole run.

One Gemini wrinkle shapes the tools: its function-declaration schema rejects numeric bounds (`exclusiveMinimum`, `maximum`) and `default`, so each tool keeps its `parameters` to plain types and applies caps and defaults inside `execute`. A `.positive()` or `.default()` left on a Zod field surfaces as a 400 from the model call.

This agent has no `outputSchema`. ADK disables tool calls when an output schema is set on an `LlmAgent`, and this agent needs its tools through the whole turn, so the prompt asks for bare JSON instead and `main()` parses the final text (stripping a stray ```json fence if the model adds one). For a turn that does not call tools, set `outputSchema` on the agent for validated typed output.

## Make it yours

- **Swap the model.** Change the `model` string passed to `Gemini`. `"gemini-2.5-pro"`, `"gemini-flash-latest"`, and other Gemini IDs all work with the same `GOOGLE_API_KEY`.
- **Swap the task.** Edit `TASK` and the JSON shape named in the agent's `instruction`. The three tools are task-agnostic; they describe a generic navigate-then-scrape flow.
- **Add a tool.** A `click` tool wrapping `page.click`, or a `screenshot` tool returning a base64 PNG. Build it with `new FunctionTool({ name, description, parameters, execute })` and add it to the agent's `tools` array.
- **Persist sessions.** Swap `InMemoryRunner` for a `Runner` with a `DatabaseSessionService` to keep conversation state across runs; the session ID is the thread key.
- **Run Vertex instead of AI Studio.** Set `GOOGLE_GENAI_USE_VERTEXAI=TRUE` plus `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION`, and construct `new Gemini({ model, vertexai: true })`.
- **Turn on stealth.** Pass `useProxy`, `solveCaptcha`, or `sessionTimeout` to `steel.sessions.create({...})` for sites with anti-bot.

## Related

[Mastra version](/cookbook/mastra) · [OpenAI Agents SDK version](/cookbook/openai-agents) · [ADK TypeScript docs](https://adk.dev/get-started/typescript/)

**Python**

[Google ADK](https://google.github.io/adk-docs/) is Google's Agent Development Kit. An `LlmAgent` holds the model, instruction, and tools; a `Runner` drives the turn loop against a session service that stores conversation state. This starter binds a Steel cloud browser to three function tools, hands them to a Gemini agent, and points it at Hacker News.

The pieces ADK asks you to assemble:

```python
from google.adk.agents import LlmAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService

agent = LlmAgent(
    name="hn_scraper",
    model="gemini-2.5-flash",
    tools=[navigate, snapshot, extract],
    output_schema=TopStories,
    instruction="You operate a Steel cloud browser via tools. ...",
)

session_service = InMemorySessionService()
adk_session = await session_service.create_session(app_name=APP_NAME, user_id=USER_ID)
runner = Runner(agent=agent, app_name=APP_NAME, session_service=session_service)
```

Note the two session concepts that share a word. There is the Steel session (a remote browser, billed per minute) and the ADK session (a conversation record, held in memory here). They are unrelated objects; `main` creates one of each.

`run_agent` sends a turn and reads the result. `runner.run_async` returns an async generator of events: tool calls, tool results, model deltas, and finally one event where `event.is_final_response()` is true. You iterate, keep the text from that final event, and ignore the rest:

```python
message = types.Content(role="user", parts=[types.Part(text=prompt)])
async for event in runner.run_async(
    user_id=USER_ID, session_id=session_id, new_message=message
):
    if event.is_final_response() and event.content and event.content.parts:
        final = event.content.parts[0].text or ""
```

## Tools

ADK builds each tool's JSON schema from the Python function itself: parameter names and type hints become the arguments, and the docstring (summary plus `Args:` lines) becomes the descriptions the model reads. So the tools are plain `async def` functions with typed parameters and a Google-style docstring, no decorator:

```python
async def navigate(url: str) -> dict:
    """Navigate the open browser session to a URL and wait for it to load.

    Args:
        url: The absolute URL to open.

    Returns:
        A dict with the resolved url and page title.
    """
    await _PAGE.goto(url, wait_until="domcontentloaded", timeout=45_000)
    return {"url": _PAGE.url, "title": await _PAGE.title()}
```

A function tool in ADK takes no framework context argument, so the live Playwright `Page` is bound to a module-level `_PAGE` and the tools close over it. `main` sets `_PAGE` once the CDP connection is up, before the runner starts. The three tools:

- `navigate(url)` loads a page and reports the resolved URL and title.
- `snapshot(max_chars, max_links)` returns capped visible text plus a list of links, so the agent reads the page before guessing selectors.
- `extract(row_selector, fields, limit)` runs one `page.evaluate` that maps a CSS row selector and field specs to structured rows. One round trip, not one per cell. CDP calls to Steel's cloud browser run ~200 to 300ms each, so a per-cell loop would burn seconds.

Each tool prints its own latency (`navigate: 412ms`) so you can see where a turn spends its time.

## Typed output

`output_schema=TopStories` ties the final reply to a Pydantic model. ADK keeps the tools available during the thinking loop and constrains only the last message, so the agent still browses freely and then answers in shape. The final event text is JSON that already validates against `TopStories`; `main` parses and re-dumps it with indentation:

```python
class Story(BaseModel):
    rank: int
    title: str
    url: str = Field(description="Destination URL the story links to.")
    points: int

class TopStories(BaseModel):
    stories: list[Story] = Field(min_length=1, max_length=5)
```

## Run it

```bash
cd examples/google-adk-py
cp .env.example .env          # set STEEL_API_KEY and GOOGLE_API_KEY
uv run main.py
```

Get a Steel key from [app.steel.dev](https://app.steel.dev/settings/api-keys) and a Gemini key from [aistudio.google.com](https://aistudio.google.com/apikey). `GOOGLE_GENAI_USE_VERTEXAI=FALSE` keeps ADK on the AI Studio key path instead of trying to authenticate against a GCP project; `main` defaults it for you if it is unset.

Your output varies. Structure looks like this:

```text
Steel + Google ADK Starter
============================================================
Session: https://app.steel.dev/sessions/ab12cd34...
    navigate: 1612ms
    snapshot: 487ms (3821 chars, 48 links)
    extract: 394ms (5 rows)

Agent finished.

{
  "stories": [
    {
      "rank": 1,
      "title": "Show HN: ...",
      "url": "https://example.com/...",
      "points": 412
    },
    ...
  ]
}

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A run takes ~20 to 40 seconds and a handful of agent turns on Hacker News. Cost is a few cents of Steel session time plus Gemini tokens. The `finally` block in `main` closes Playwright and calls `steel.sessions.release()` so Steel stops billing per minute.

## Make it yours

- **Swap the model.** Change `MODEL`. Any Gemini that ADK reaches through the same API key works without code changes, since the tool schemas are generated from the functions. Heavier reasoning models trade latency for fewer wrong turns.
- **Swap the task.** Edit the prompt passed to `run_agent` and the `TopStories` / `Story` models. The tools stay the same; the agent re-plans against the new shape.
- **Add a tool.** Write another `async def` with type hints and a docstring, then append it to `tools=[...]`. A useful fourth is `click(selector: str)` that calls `page.click` and waits for navigation.
- **Carry state across turns.** The `InMemorySessionService` keeps history under one `session_id`, so calling `run_agent` again with the same id continues the conversation. Swap in a `DatabaseSessionService` to persist it.
- **Run more agents.** Build a Steel session and `_PAGE` per task and run them on separate ADK sessions. Since `_PAGE` is module-level here, give each concurrent run its own page object rather than sharing one.

## Related

[Steel + Genkit (Go)](/cookbook/genkit) · [Steel + Pydantic AI (Python)](/cookbook/pydantic-ai) · [Google ADK Python documentation](https://google.github.io/adk-docs/)

**Go**

[Google ADK](https://adk.dev/get-started/go/) is Google's Agent Development Kit, a code-first toolkit for building agents in Go. The pieces fit together as a tree: a `model.LLM`, a set of `tool.Tool` values, and an `llmagent` that owns them, all driven by a `runner.Runner` that turns one user message into a stream of events. This starter hands that agent three tools backed by a Steel cloud browser and points a Gemini model at Hacker News.

The runner is the part worth understanding first. You do not write the tool-calling loop. You hand `runner.New` a root agent and a session service, call `Run`, and range over the events it yields:

```go
r, _ := runner.New(runner.Config{AppName: appName, Agent: a, SessionService: sessionService})

for event, err := range r.Run(ctx, userID, sessionID, task, agent.RunConfig{
    StreamingMode: agent.StreamingModeNone,
}) {
    for _, part := range event.Content.Parts {
        if part.Text != "" {
            final = part.Text
        }
    }
}
```

`Run` returns a Go 1.23 iterator (`iter.Seq2[*session.Event, error]`). Each event is one step: a model turn that requests a tool, the tool's result fed back in, the next model turn, and so on until the model answers without calling anything. Every event carries a `genai.Content`, so ranging over `event.Content.Parts` lets you watch text, function calls, and function responses flow past. The loop in `main` keeps the last non-empty text part; that is the agent's final answer.

## Tools from a Go function

`functiontool.New` wraps a typed Go function as a tool. It is generic over the argument and result types and infers the JSON schema the model sees from your input struct by reflection:

```go
navigate, _ := functiontool.New(functiontool.Config{
    Name:        "navigate",
    Description: "Open a URL in the live browser tab and wait for it to load.",
}, func(tc agent.ToolContext, in navigateInput) (navigateOutput, error) {
    var title, url string
    err := chromedp.Run(b.tab,
        chromedp.Navigate(in.URL), chromedp.Title(&title), chromedp.Location(&url))
    return navigateOutput{Title: title, URL: url}, err
})
```

The schema comes from struct tags. A `jsonschema` tag on a field becomes that argument's description in the tool declaration, which is how the model learns what `rowSelector` or `attr` mean:

```go
type extractInput struct {
    RowSelector string      `json:"rowSelector" jsonschema:"CSS selector matching each item, e.g. 'tr.athing'."`
    Fields      []fieldSpec `json:"fields" jsonschema:"One entry per column to pull out of each row."`
    Limit       int         `json:"limit,omitempty" jsonschema:"Maximum number of rows to return. Defaults to 10."`
}
```

The first argument to every handler is an `agent.ToolContext`. It embeds `context.Context`, so the `scrape` tool passes `tc` straight to `client.Scrape` as the request context. The handlers return ordinary Go structs and errors; ADK marshals the struct into the function response and an error becomes a tool failure the model can react to.

Three tools cover the two access patterns a browsing agent needs:

- `navigate` and `extract` drive one live chromedp tab attached to the Steel session over CDP. `extract` takes a row selector plus a field-per-column list and runs the whole pull inside a single `chromedp.Evaluate`. Serial CDP round-trips to a cloud browser run about 200 to 300 ms each, so collapsing N rows by M fields into one evaluate keeps a page read under a second instead of stacking dozens of trips.
- `scrape` calls `client.Scrape` and returns clean Markdown for a URL without touching the tab. It is the reliable path when the agent just needs an article's text and sidesteps selector guesswork entirely.

## Run it

```bash
cd examples/google-adk-go
cp .env.example .env          # set STEEL_API_KEY and GOOGLE_API_KEY
go mod tidy
go run .
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [Google AI Studio](https://aistudio.google.com/apikey). `GOOGLE_GENAI_USE_VERTEXAI=FALSE` in `.env.example` keeps the genai client on the AI Studio backend, so the API key alone is enough and no Vertex project is required. The program prints a session viewer URL as it starts; open it in another tab to watch the browser run live. Each tool call prints its latency.

Your output varies. Structure looks like this:

```text
Steel + Google ADK Go Starter
============================================================
Session: https://app.steel.dev/sessions/ab12cd34...
    navigate: 1183ms
    extract: 412ms (5 rows)

Agent finished.
{
  "stories": [
    {
      "points": "342",
      "rank": 1,
      "title": "Show HN: ...",
      "url": "https://example.com/..."
    }
  ]
}

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A run takes about 20 to 40 seconds and a handful of model turns. Cost is a few cents of Steel session time plus Gemini tokens. The deferred cleanup in `main` releases the session: Steel bills per session-minute, so a leaked session keeps running until the default 5-minute timeout.

## Structured output

ADK Go can pin an agent's reply to a `genai.Schema` through `OutputSchema` on the agent config, but setting it disables tools: an agent with an output schema can only reply, it cannot call functions. This agent needs its tools, so it returns JSON as text instead. The instruction asks for a bare JSON object, and `prettyJSON` in `main` strips a stray code fence if the model adds one, then re-indents the result. If you would rather have a typed value, split the work into two agents: a tool-using agent that gathers the rows and a second agent with `OutputSchema` set that formats them.

## Make it yours

- **Swap the model.** Change `modelName`. Any Gemini model your key can reach works without code changes, for example `gemini-2.5-pro`. `gemini.NewModel` takes the name and a `genai.ClientConfig`.
- **Swap the task.** Change the `task` content and the JSON shape named in the agent instruction. The tools stay the same; the agent re-plans against the new request.
- **Add a tool.** Write a `func(agent.ToolContext, In) (Out, error)`, wrap it with `functiontool.New`, and add it to the agent's `Tools`. A useful fourth is `click(selector string)` that runs `chromedp.Click` and waits for navigation.
- **Inspect the loop.** Range over more than text. Every event exposes `event.Content.Parts`, where `FunctionCall` and `FunctionResponse` parts let you log exactly which tool the agent reached for and what came back.

## Related

[Steel + Genkit (Go)](/cookbook/genkit) and [Steel + Eino (Go)](/cookbook/eino) build the same agent shape in other Go frameworks. The [ADK Go quickstart](https://adk.dev/get-started/go/) covers agents, tools, and the runner in depth.

## Related recipes

- [Build a typed browser agent with Pydantic AI](/cookbook/pydantic-ai): Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection.
- [Build a typed browser agent with LangGraph](/cookbook/langgraph): Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node.
- [Build a typed browser agent with Mastra](/cookbook/mastra): Use Steel with Mastra to build a typed browser agent with the Mastra Model Router and Studio playground.


# Automate a cloud browser with headless_chrome
URL: https://docs.steel.dev/cookbook/headless-chrome


headless_chrome is the Rust equivalent of Puppeteer: a high-level, synchronous wrapper over the Chrome DevTools Protocol. `Browser::connect` takes a websocket URL and returns a connected browser, which is all a Steel session is. There is no event loop to drive and no async runtime in the browser code itself: `new_tab`, `navigate_to`, `find_elements`, and `capture_screenshot` block until they return, the way the Node original does. It is the same cloud [browser automation](/cookbook/topics/browser-automation) as the other CDP recipes, in synchronous Rust.

```rust
let browser = Browser::connect(cdp_url)?;
let tab = browser.new_tab()?;
tab.navigate_to("https://quotes.toscrape.com")?;
tab.wait_until_navigated()?;

let quotes = tab.find_elements(".quote")?;
```

Scraping is element handles rather than evaluated JavaScript. `find_elements` returns a `Vec<Element>`, and each `Element` queries its own subtree, so `quote.find_element(".text")?.get_inner_text()?` reads the text inside one card without touching the rest of the page. The loop in `scrape` pulls the quote, author, and tags from the first five `.quote` blocks that way.

## Sync library, async SDK

The one seam worth understanding is that the two halves of this program disagree about async. The Steel SDK (`steel-rs`) is async: `sessions().create(...).await` and `sessions().release(...).await` need a runtime, so `main` is `#[tokio::main]`. headless_chrome is the opposite, a blocking API built on threads. Calling its blocking methods directly inside the async `main` would stall a runtime worker for the whole scrape.

The bridge is `spawn_blocking`, which hands the synchronous work to a thread pool meant for exactly this:

```rust
let result = tokio::task::spawn_blocking(move || scrape(&websocket_url, &key)).await?;
```

That is also why `scrape` returns `Box<dyn Error + Send + Sync>` rather than the bare `Box<dyn Error>` you would reach for first: `spawn_blocking` moves the closure to another thread, so its return type has to be `Send`. The session is created before the blocking call and released after it, so the browser work sits between two `await` points and the async SDK never blocks.

One detail in `scrape` is shared with the [chromiumoxide](/cookbook/chromiumoxide) recipe: the connect URL is normalized to carry a path. Steel's websocket URL is `wss://host?token`, and the websocket layer underneath headless_chrome expects `wss://host/?token`, so the `match` on `://` and `?` inserts the slash before `Browser::connect` dials it.

## Run it

```bash
cd examples/headless-chrome
cp .env.example .env          # set STEEL_API_KEY
cargo run
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The first build pulls headless_chrome and tokio and takes a minute or two; later runs are quick. The program prints a session viewer URL as it starts. Open it in a second tab to watch the remote browser load the page, and it writes `quotes.png` to the working directory on the way out.

Your output varies with the site. Structure looks like this:

```text
Creating Steel session...
Session live at https://app.steel.dev/sessions/ab12cd34
Connected over CDP, opening page...

Found 10 quotes on the page:

1. The world as we have created it is a process of our thinking.
   - Albert Einstein
   tags: change, deep-thoughts, thinking, world

2. It is our choices, Harry, that show what we truly are.
   - J.K. Rowling
   tags: abilities, choices

Saved screenshot to quotes.png (98231 bytes)
Releasing session...
Session released
```

A run costs a few cents of browser time. Steel bills per session-minute, so the `sessions().release()` call after the blocking work is not optional: `main` captures the scrape result, releases the session, and only then propagates any error, so a failed scrape still tears the session down instead of leaving it to idle until the default 5-minute timeout.

## Make it yours

- **Swap the target.** Change the URL in `navigate_to` and the selectors in the loop. `quotes.toscrape.com` paginates with a `.next > a` link, so you can follow it and scrape every page; the connect and cleanup code stays the same.
- **Wait on a specific element.** `tab.wait_for_element(selector)` blocks until a node appears, which is sturdier than `wait_until_navigated` for pages that fill in content with JavaScript after first paint.
- **Capture a single element.** Beyond the full-page `tab.capture_screenshot`, an `Element` has its own `capture_screenshot` that crops to that node, useful for grabbing one card or chart instead of the whole viewport.
- **Harden for anti-bot.** `SessionCreateParams` carries `block_ads`, `solve_captcha`, `use_proxy`, and `dimensions`. Set them on the struct passed to `sessions().create()` for sites that fingerprint or challenge headless traffic.

## Related

- [chromiumoxide](/cookbook/chromiumoxide) drives the same kind of Steel session the other way: async, tokio-native, with an explicit handler loop you spawn yourself. Comparing the two `main` files is the fastest way to decide whether you want the sync or async model in Rust.
- [scrape-rs](/cookbook/scrape) skips the browser entirely and reaches the page through Steel's `scrape` and `screenshot` endpoints. Start there if you only need content or an image and never touch the DOM.
- [playwright-py](/cookbook/playwright) and [playwright-go](/cookbook/playwright) connect over CDP the same way from other languages.
- The [headless_chrome docs](https://docs.rs/headless_chrome) cover the full `Tab` and `Element` API.

## Related recipes

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
- [Automate a cloud browser with chromedp](/cookbook/chromedp): Use Steel with chromedp to connect over CDP, navigate to Hacker News, extract the top stories, and capture a screenshot.


# Cookbook
URL: https://docs.steel.dev/cookbook



# Build a browser agent with LangChainGo
URL: https://docs.steel.dev/cookbook/langchaingo


[LangChainGo](https://github.com/tmc/langchaingo) is the Go port of LangChain: LLM wrappers, chains, and agents that loop over tools until they reach an answer. This recipe gives a LangChainGo [browser agent](/cookbook/topics/agents) one tool backed by Steel's `scrape` endpoint, so the model reads pages as clean Markdown and never touches a browser library or CDP. The agent runs on Anthropic (`claude-sonnet-4-6`) through a zero-shot ReAct (MRKL) executor.

LangChainGo's `tools.Tool` interface is deliberately small. A tool is a name, a description, and a `Call` that takes a string and returns a string:

```go
type scrapeTool struct{ client *steel.Client }

func (t scrapeTool) Name() string       { return "scrape" }
func (t scrapeTool) Description() string { return "Fetch a web page as clean Markdown. Input: one absolute URL." }

func (t scrapeTool) Call(ctx context.Context, input string) (string, error) {
    url := strings.Trim(strings.TrimSpace(input), "\"'")
    resp, err := t.client.Scrape(ctx, steel.ClientScrapeParams{
        URL:    url,
        Format: &[]steel.ScrapeRequestFormatItem{steel.ScrapeRequestFormatItemMarkdown},
    })
    // ... return the capped resp.Content.Markdown
}
```

The input arrives as a plain string because a ReAct agent emits `Action: scrape` then `Action Input: https://...` as text, and the executor hands you whatever follows. That is why `Call` trims surrounding quotes and whitespace before using the URL: the model's formatting is not guaranteed. There is no JSON schema and no typed argument struct, which is the trade LangChainGo makes for running on any text model.

Wiring the agent is one call:

```go
executor, err := agents.Initialize(
    llm,
    []tools.Tool{scrapeTool{client: client}},
    agents.ZeroShotReactDescription,
    agents.WithMaxIterations(5),
)
answer, err := chains.Run(ctx, executor, task)
```

`Initialize` builds the MRKL agent and wraps it in an `Executor`, which is itself a chain, so `chains.Run` drives the whole reason-act loop and returns the final string. `WithMaxIterations(5)` caps the loop so a model that never emits `Final Answer:` cannot spin forever.

## Run it

```bash
cd examples/langchaingo
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
go run .
```

Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and an Anthropic key at [console.anthropic.com](https://console.anthropic.com/settings/keys). Your output varies. Structure looks like this:

```text
Running LangChainGo agent...

The top 3 Hacker News stories right now are:
1. "..." with 512 points
2. "..." with 488 points
3. "..." with 401 points
```

Each scrape call spins up a short-lived Steel browser server-side, so a run costs a few cents of browser time plus the Anthropic tokens for the ReAct loop. There is no session to release: `scrape` opens and closes its own browser per call.

## Make it yours

- **Swap the task.** Change `task` in `main.go`. The tool stays the same; the agent re-plans against the new goal.
- **Add a tool.** Any struct with `Name`, `Description`, and `Call` slots into the `[]tools.Tool` list. A second tool backed by `client.Screenshot`, or one of LangChainGo's built-ins like the calculator, drops straight in and the MRKL agent picks per step.
- **Change the model.** Pass a different id to `anthropic.WithModel`, or swap `anthropic.New` for `openai.New` (LangChainGo ships both). The tool is unaffected.
- **Use native tool-calling.** `agents.NewOpenAIFunctionsAgent` replaces ReAct text parsing with structured function calls on models that support them.

## Related

[eino](/cookbook/eino) is the closest sibling: another Go ReAct agent on Steel's scrape API, but with typed tool arguments instead of LangChainGo's string interface. [genkit](/cookbook/genkit) drives a chromedp browser instead of the scrape endpoint. The [LangChainGo docs](https://pkg.go.dev/github.com/tmc/langchaingo) cover chains, memory, and the agent types.

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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 typed browser agent with LangGraph
URL: https://docs.steel.dev/cookbook/langgraph


[LangGraph](https://langchain-ai.github.io/langgraph/) builds agents as state machines: nodes do work, edges route control, and the agent loop is something you compose explicitly. LangChain ships the model wrapper (`ChatAnthropic`) and the `@tool` decorator; LangGraph ships the graph runtime, plus prebuilt `ToolNode` and `tools_condition` helpers. Steel's [LangGraph integration](/integrations/langgraph) covers the same setup on its own.

This recipe is a four-tool [browser agent](/cookbook/topics/agents): `open_session`, `navigate`, `snapshot`, `extract`. Each tool drives a Steel cloud session over Playwright. The graph has three nodes (`agent`, `tools`, `format`) and runs against `github.com/trending/python`, returning a Pydantic-validated `FinalReport`.

```python
graph = StateGraph(State)
graph.add_node("agent", agent_node)
graph.add_node("tools", ToolNode(tools))
graph.add_node("format", format_node)

graph.add_edge(START, "agent")
graph.add_conditional_edges(
    "agent",
    tools_condition,
    {"tools": "tools", END: "format"},
)
graph.add_edge("tools", "agent")
graph.add_edge("format", END)

app = graph.compile()
```

If you'd rather skip the explicit construction, `langgraph.prebuilt.create_react_agent(model, tools, prompt=SYSTEM, response_format=FinalReport)` builds the same three-node graph in one call.

## Run it

```bash
cd examples/langgraph
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
uv sync
uv run playwright install chromium
uv run main.py
```

Get keys at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). Each tool call prints its latency; the `open_session` tool returns a Live View URL you can open in another tab to watch the agent work.

Your output varies. Structure looks like this:

```text
Steel + LangGraph Starter
============================================================
    open_session: 1840ms
  step: agent -> navigate | 1207 tokens
    navigate: 712ms
  step: agent -> snapshot | 1502 tokens
    snapshot: 412ms (3812 chars, 49 links)
  step: agent -> extract | 1741 tokens
    extract: 198ms (3 rows)
  step: agent -> (text only) | 4998 tokens
  step: format

Agent finished.

{
  "summary": "Three trending Python repos focused on agentic workflows...",
  "repos": [
    {
      "name": "owner/repo",
      "url": "https://github.com/owner/repo",
      "stars": "1,240",
      "description": "..."
    },
    ...
  ]
}

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A run takes ~20 to 40 seconds and a few cents of Steel session time plus Anthropic tokens. The `finally` block calls `steel.sessions.release()`. Skip it and the session keeps billing until the default 5-minute timeout.

## Make it yours

- **Use the prebuilt.** Replace the explicit graph with `create_react_agent(model, tools, prompt=SYSTEM, response_format=FinalReport)` from `langgraph.prebuilt`. Same behavior, three lines.
- **Add a checkpointer.** Pass `checkpointer=MemorySaver()` to `graph.compile(...)` and a `thread_id` in the run config. The graph snapshots state after every node, so a crashed run can resume from the last checkpoint. Use `SqliteSaver` (from `langgraph-checkpoint-sqlite`) for persistence across processes.
- **Stream events.** Swap `app.ainvoke(...)` for `async for event in app.astream_events(..., version="v2")`. You'll see `on_tool_start`, `on_tool_end`, and `on_chat_model_stream` events you can pipe to a UI.
- **Trace with LangSmith.** Set `LANGSMITH_API_KEY` and `LANGSMITH_TRACING=true` in `.env`. No code changes; every node and tool call shows up at [smith.langchain.com](https://smith.langchain.com).
- **Swap the model.** Any `langchain-*` chat model works. `ChatOpenAI(model="gpt-5-mini")` swaps Anthropic for OpenAI without touching the graph.

## Related

[OpenAI Agents SDK (Python)](/cookbook/openai-agents) · [Browser Use](/cookbook/browser-use) · [LangGraph docs](https://langchain-ai.github.io/langgraph/)

## Related recipes

- [Build a browser agent with Google ADK](/cookbook/google-adk): Use Steel with Google's Agent Development Kit (ADK) for Go to build a tool-using browser agent that drives a chromedp session over CDP and reads Hacker News.
- [Build a typed browser agent with Pydantic AI](/cookbook/pydantic-ai): Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection.
- [Build a typed browser agent with Mastra](/cookbook/mastra): Use Steel with Mastra to build a typed browser agent with the Mastra Model Router and Studio playground.


# Build an AI browser agent with Magnitude
URL: https://docs.steel.dev/cookbook/magnitude


Magnitude grew out of end-to-end testing and kept the bias: an [agent loop](/cookbook/topics/agents) that narrates each turn, a CDP-level browser hookup, and LLM-backed primitives designed to intermix navigation, action, and typed readback. Steel's [Magnitude integration](/integrations/magnitude) covers the same setup on its own. `startBrowserAgent()` hands you a `BrowserAgent` with a small surface this recipe exercises:

- `agent.extract(instruction, schema)`: describe what to pull off the page, pass a Zod schema, get a typed result.
- `agent.act(instruction)`: describe an interaction in natural language. The agent plans, clicks, types, retries.
- `agent.stop()`: flush and tear down. Pair with `client.sessions.release()` in a `finally`.

```typescript
const agent = await startBrowserAgent({
  url: "https://github.com/steel-dev/leaderboard",
  narrate: true,
  telemetry: false,
  llm: {
    provider: "anthropic",
    options: {
      model: "claude-sonnet-4-6",
      apiKey: ANTHROPIC_API_KEY,
    },
  },
  browser: {
    cdp: `${session.websocketUrl}&apiKey=${STEEL_API_KEY}`,
  },
});
```

`browser.cdp` is the whole wiring. `narrate: true` streams a log of what the agent is doing between screenshot turns. The `url` option does the first navigation, so there is no separate `goto` call in `main()`.

## What the demo does

`main()` walks a three-step flow against Steel's public leaderboard repo:

1. Extract the user behind the most recent commit:

```typescript
const mostRecentCommitter = await agent.extract(
  "Find the user with the most recent commit",
  z.object({
    user: z.string(),
    commit: z.string(),
  }),
);
```

2. Act to open the pull request that produced that commit:

```typescript
await agent.act(
  "Find the pull request behind the most recent commit if there is one",
);
```

3. Extract a prose summary of what the PR changed.

The `act` call sits in `try / catch` because the leaderboard head commit is not always tied to a merged PR.

## Run it

```bash
cd examples/magnitude
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
npm install
npm start
```

Steel keys live at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys); Anthropic keys at [console.anthropic.com](https://console.anthropic.com/).

Your output varies. Structure looks like this:

```text
Steel + Magnitude Node Starter
============================================================

Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...

Connected to browser via Magnitude
Looking for commits
[narrate] taking screenshot of github.com/steel-dev/leaderboard
[narrate] extracting: Find the user with the most recent commit

Most recent committer:
alice-dev has the most recent commit

Looking for pull request behind the most recent commit
[narrate] clicking commit SHA link
[narrate] navigating to pull/482
Found pull request!
Adds a tie-breaker rule when two contributors have identical scores.

Automation completed successfully!
Stopping Magnitude agent...
Releasing Steel session...
Steel session released successfully
```

A full run takes ~45 seconds. The `finally` block stops the agent first, then releases the session. Reverse that order and Magnitude can try to screenshot a browser Steel already tore down.

## Make it yours

- **Swap the schema and prompt.** `extract()` is schema-driven: forms, tables, invoices, search results.
- **Chain `act` calls for multi-step flows.** Login, filter, paginate, export. Each step is one natural-language instruction.
- **Switch models.** `llm.provider` accepts `"anthropic"` (used here) among others. Point `model` and `apiKey` at a different provider in `startBrowserAgent()`.
- **Turn on stealth.** Uncomment `useProxy`, `solveCaptcha`, or `sessionTimeout` in `client.sessions.create()` for sites with anti-bot.

## Related

[Magnitude docs](https://docs.magnitude.run)

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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 typed browser agent with Mastra
URL: https://docs.steel.dev/cookbook/mastra


[Mastra](https://mastra.ai/) is a TypeScript framework that wraps the Vercel AI SDK with typed tools, a model router, and a built-in Studio playground for chatting with your agents and inspecting traces. Steel's [Mastra integration](/integrations/mastra) covers the same setup on its own.

This recipe is a four-tool [browser agent](/cookbook/topics/agents): `open-session`, `navigate`, `snapshot`, `extract`. Each tool drives a Steel cloud session over Playwright. The agent runs against `github.com/trending/python` and returns a Zod-validated `FinalReport`.

```typescript
const researchAgent = new Agent({
  id: "research-agent",
  name: "Steel Research",
  instructions: "You operate a Steel cloud browser via tools. ...",
  model: "anthropic/claude-haiku-4-5",
  tools: { openSession, navigate, snapshot, extract },
});

export const mastra = new Mastra({ agents: { researchAgent } });

const result = await researchAgent.generate(prompt, {
  structuredOutput: {
    schema: FinalReport,
    model: "anthropic/claude-haiku-4-5",
  },
  maxSteps: 15,
  onStepFinish: async (step) => { ... },
});

console.log(result.object); // typed as z.infer<typeof FinalReport>
```

## Run it

```bash
cd examples/mastra
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
npm install
npx playwright install chromium
npm start
```

Get keys at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). The `open-session` tool prints a Live View URL; open it in another tab to watch the browser as the agent works.

Mastra requires Node 22.13+. If `npm install` complains about engines, `nvm use 22` (or newer) first.

Your output varies. Structure looks like this:

```text
Steel + Mastra Starter
============================================================
    open-session: 1433ms
  step: openSession | 1549 tokens
    navigate: 708ms
  step: navigate | 1702 tokens
    snapshot: 400ms (2630 chars, 99 links)
  step: snapshot | 1829 tokens
    extract: 120ms (14 rows)
  step: extract | 5595 tokens
  step: (text only) | 6802 tokens

Agent finished.

Structured output:
{
  "summary": "These repositories represent cutting-edge AI and ML...",
  "repos": [
    { "name": "owner/repo", "url": "...", "stars": "1,204", "description": "..." },
    ...
  ]
}

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A full run takes ~20-40 seconds and a few cents of Steel session time plus Anthropic tokens. The `finally` block calls `steel.sessions.release()`; skip it and the session keeps billing until the default 5-minute timeout.

## Open the Studio

Mastra ships a local playground for chatting with agents, watching tool calls, and replaying traces. Run alongside the script:

```bash
npx mastra dev
```

It serves at `http://localhost:4111` and reads the `mastra` registry exported from `index.ts`. Pick `research-agent` in the sidebar, drop in a prompt, and watch each tool call as the agent works.

## Make it yours

- **Swap the model.** Change the `model` string. `"openai/gpt-5-mini"`, `"google/gemini-2.5-flash"`, `"anthropic/claude-sonnet-4-6"` all work; set the matching API key in `.env`.
- **Swap the task.** Change the prompt and the `FinalReport` schema. The four tools are task-agnostic.
- **Add a tool.** A `click` tool wrapping `page.click`, a `screenshot` tool returning a base64 PNG. Add to the `tools` record.
- **Add memory.** Install `@mastra/memory` plus a storage adapter (`@mastra/libsql`), pass `memory` on the `Agent`, then call `generate(prompt, { memory: { resource, thread } })` to persist conversation across runs. See [Mastra memory docs](https://mastra.ai/docs/memory/overview).
- **Wrap it in a workflow.** For multi-step pipelines (login → scrape → summarize) where each step needs to be retryable or human-resumable, port the tool calls into `createStep` blocks under a `createWorkflow`. See [Mastra workflows](https://mastra.ai/docs/workflows/overview).
- **Turn on stealth.** Pass `useProxy`, `solveCaptcha`, or `sessionTimeout` to `steel.sessions.create({...})` for sites with anti-bot.

## Related

[Vercel AI SDK version](/cookbook/vercel-ai-sdk) · [OpenAI Agents SDK version](/cookbook/openai-agents) · [Mastra docs](https://mastra.ai/docs) · [Mastra Studio](https://mastra.ai/docs/studio/overview)

## Related recipes

- [Build a browser agent with Google ADK](/cookbook/google-adk): Use Steel with Google's Agent Development Kit (ADK) for Go to build a tool-using browser agent that drives a chromedp session over CDP and reads Hacker News.
- [Build a typed browser agent with Pydantic AI](/cookbook/pydantic-ai): Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection.
- [Build a typed browser agent with LangGraph](/cookbook/langgraph): Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node.


# Expose a Steel browser to any MCP client
URL: https://docs.steel.dev/cookbook/mcp


**TypeScript**

This is a [Model Context Protocol](https://modelcontextprotocol.io) server that hands any [MCP client](/cookbook/topics/mcp) a Steel cloud browser to drive. It uses the official [TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) and drives the browser with Playwright over CDP through `connectOverCDP`, so there is no local Chrome to launch. The server carries no model key of its own: the client supplies the model, this process owns the cloud session.

Five tools make up the surface. `create_session` opens a Steel session and returns its id; `navigate`, `extract`, and `screenshot` take that id and act on the browser; `release_session` closes it.

## Each tool declares its shape, the id ties them together

Every tool is a `server.registerTool` call: a name, a description, an `inputSchema` written as a Zod shape, and the handler. The shape is the contract the client sees and the type of the handler's argument in one place:

```ts
server.registerTool(
  "navigate",
  {
    description: "Open a URL in the session's browser tab and wait for it to load. Returns the resolved title and URL.",
    inputSchema: {
      session_id: z.string().describe("Handle returned by create_session."),
      url: z.string().describe("Absolute URL to open, e.g. https://news.ycombinator.com."),
    },
  },
  async ({ session_id, url }) => {
    const page = getPage(session_id);
    await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
    return { content: [{ type: "text", text: JSON.stringify({ url: page.url(), title: await page.title() }) }] };
  },
);
```

Every tool except `create_session` takes a `session_id` and resolves it through `getPage`, which reads a `Map` keyed by the Steel session id. That id is the handle the model threads back on each call, and it is what keeps browsers apart: the server holds no hidden "current page," so two clients with two ids never touch each other's sessions. The [Go recipe](/cookbook/mcp) covers why the explicit handle, rather than one session hidden in server state, is the shape the MCP spec now recommends. `screenshot` returns an image content block so the client renders the PNG, and `release_session` plus the `releaseAll` signal handlers make sure a session stops billing when the client goes away.

## Run it

```bash
cd examples/mcp-ts
cp .env.example .env          # set STEEL_API_KEY for local runs
npm install
```

Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The server runs straight from TypeScript with `ts-node`, so an MCP client launches it through `npx` and gets the key from the client's `env` block. For Claude Desktop, add this to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "steel": {
      "command": "npx",
      "args": ["ts-node", "/absolute/path/to/examples/mcp-ts/index.ts"],
      "env": { "STEEL_API_KEY": "your-steel-api-key" }
    }
  }
}
```

Restart the client and ask it to open a page and read it back. It calls `create_session`, then `navigate` and `extract` against the returned id, and `release_session` at the end. Open the `live_view_url` from `create_session` to watch the browser. Stdio uses stdout for the JSON-RPC stream, so the server logs only to stderr.

## Make it yours

- **Add a tool.** Another `server.registerTool` with a `session_id` field, resolve the page with `getPage`, and act on it. A `click` tool is `await page.click(selector)`.
- **Start authenticated.** Pass options to `steel.sessions.create` to attach a [profile](/cookbook/profiles) or [credentials](/cookbook/credentials) so a session opens already logged in.
- **Return structured output.** Add an `outputSchema` to a tool and return `structuredContent` so the client gets typed fields instead of a JSON string.

## Related

[Steel + MCP server (Go)](/cookbook/mcp) and [Steel + MCP server (Rust)](/cookbook/mcp) are the same five tools as single static binaries; read the Go one for the handle-versus-hidden-state rationale. [puppeteer-ts](/cookbook/puppeteer) and [playwright-ts](/cookbook/playwright) are the bare browser recipes, and [vercel-ai-sdk-ts](/cookbook/vercel-ai-sdk) drives Steel from an in-process agent. The [TypeScript SDK docs](https://github.com/modelcontextprotocol/typescript-sdk) cover transports, resources, and prompts beyond the tools shown here.

**Python**

This is a [Model Context Protocol](https://modelcontextprotocol.io) server that hands any MCP client a Steel cloud browser to drive. It uses [FastMCP](https://github.com/modelcontextprotocol/python-sdk), the decorator API in the official Python SDK, and drives the browser with Playwright over CDP. Because it connects to a remote browser with `connect_over_cdp`, there are no local browser binaries to install: the server is a thin process that owns the cloud session and nothing else, and it has no model key of its own.

Five tools make up the surface. `create_session` opens a Steel session and returns its id; `navigate`, `extract`, and `screenshot` act on a session by id; `release_session` closes it.

## The decorator is the schema, the id is the handle

Each tool is a plain async function under `@mcp.tool()`. FastMCP reads the type hints and the docstring to build the JSON Schema the client sees, so the signature is the whole contract:

```python
@mcp.tool()
async def navigate(session_id: str, url: str) -> dict:
    """Open a URL in the session's browser tab and wait for it to load.

    Args:
        session_id: Handle returned by create_session.
        url: Absolute URL to open, e.g. https://news.ycombinator.com.
    """
    page = _page(session_id)
    await page.goto(url, wait_until="domcontentloaded", timeout=45_000)
    return {"url": page.url, "title": await page.title()}
```

Every tool except `create_session` takes a `session_id` and looks it up in `_sessions`, a plain dict keyed by the Steel session id. That id is the handle the model threads back on each call, which is what keeps browsers apart: the server holds no hidden "current page," so two clients with two ids never touch each other's sessions. The [Go recipe](/cookbook/mcp) covers why the explicit handle, rather than one session hidden in server state, is the shape the MCP spec now recommends. `screenshot` returns FastMCP's `Image`, so the client renders the PNG instead of a base64 string, and `release_session` plus the `_release_all` cleanup make sure a session does not keep billing after the client goes away.

## Run it

```bash
cd examples/mcp-py
cp .env.example .env          # set STEEL_API_KEY for local runs
uv sync
```

Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). Point an MCP client at the script through `uv run` and pass the key in the client's `env` block. For Claude Desktop, add this to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "steel": {
      "command": "uv",
      "args": ["run", "--directory", "/absolute/path/to/examples/mcp-py", "python", "main.py"],
      "env": { "STEEL_API_KEY": "your-steel-api-key" }
    }
  }
}
```

Restart the client and ask it to open a page and read it back. It calls `create_session`, then `navigate` and `extract` against the returned id, and `release_session` at the end. Watch the run at the `live_view_url` that `create_session` returns. One stdio rule: stdout carries the JSON-RPC stream, so the server prints nothing there. Log to stderr if you add diagnostics.

## Make it yours

- **Add a tool.** Write one more `async def` under `@mcp.tool()` that takes `session_id`, look the page up with `_page`, and act on it. A `click` tool is `await page.click(selector)`.
- **Start authenticated.** Pass arguments to `steel.sessions.create` to attach a [profile](/cookbook/profiles) or [credentials](/cookbook/credentials) so a session opens already logged in.
- **Return typed data.** Tools here return dicts, strings, and an `Image`. Return a Pydantic model from a tool and FastMCP emits a structured-content schema the client can validate against.

## Related

[Steel + MCP server (Go)](/cookbook/mcp) and [Steel + MCP server (Rust)](/cookbook/mcp) are the same five tools as single static binaries; read the Go one for the handle-versus-hidden-state rationale. [stagehand-py](/cookbook/stagehand) and [google-adk-py](/cookbook/google-adk) are the in-process agent recipes in Python. The [Python SDK docs](https://github.com/modelcontextprotocol/python-sdk) cover transports, resources, and prompts beyond the tools shown here.

**Rust**

This is a [Model Context Protocol](https://modelcontextprotocol.io) server that lends a Steel cloud browser to any MCP client. It is built on [rmcp](https://docs.rs/rmcp), the official Rust SDK, and drives the browser over CDP with [chromiumoxide](https://docs.rs/chromiumoxide). It compiles to a single binary with no interpreter and no model key of its own: the client supplies the model, this process owns the browser and nothing else.

Five tools make up the surface. `create_session` opens a Steel session and returns its id; `navigate`, `extract`, and `screenshot` take that id and act on the browser; `release_session` closes it. Each tool is one `#[tool]`-annotated method on `SteelMcp`, and `#[tool_router]` plus `#[tool_handler]` turn those methods into the served schema.

## Holding the browser open between calls

The hard part of a browser MCP server is not any single tool, it is keeping one browser alive and reachable across separate calls. `create_session` does three things that have to outlive the call that made them:

```rust
let (browser, mut handler) = Browser::connect(cdp_url).await?;
let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} });
let page = browser.new_page("about:blank").await?;
```

`Browser::connect` returns a command handle plus a `handler` stream that pumps the CDP websocket. Nothing polls it on its own, so the spawned task that drives it to exhaustion is mandatory: drop it and the next `goto` hangs with no error. All three, the `Browser`, the join handle, and the `Page`, go into a `SessionEntry` stored in `Arc<Mutex<HashMap<String, SessionEntry>>>`, keyed by the Steel session id. Because `Page` and the `Arc`s are cheap to clone, `get` copies a whole entry out and releases the map lock before any browser work, so two sessions never block each other.

That id is the handle the model threads back on every later call, which is what keeps sessions apart. The [Go recipe](/cookbook/mcp) covers why the explicit handle, rather than one browser hidden in server state, is what the MCP spec now asks for. The short version: each Steel session is its own isolated cloud browser, and naming it on every call means two clients holding two ids can never read each other's pages. `release_session` removes the entry, aborts the handler task, and releases the Steel session so it stops billing.

## Run it

```bash
cd examples/mcp-rs
cp .env.example .env          # set STEEL_API_KEY for local `cargo run`
cargo build --release
```

Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). Point an MCP client at the compiled binary and pass the key through the client's `env` block. For Claude Desktop, add this to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "steel": {
      "command": "/absolute/path/to/examples/mcp-rs/target/release/mcp-rs",
      "env": { "STEEL_API_KEY": "your-steel-api-key" }
    }
  }
}
```

Restart the client and ask it to open a page and read it back. It calls `create_session`, then `navigate` and `extract` against the returned id, and `release_session` at the end. Open the `live_view_url` from `create_session` to watch the browser work. Note that stdio uses stdout for the JSON-RPC stream, so the server keeps it clean and writes nothing there itself.

## Make it yours

- **Add a tool.** Write one more `async fn` with a `#[tool]` attribute and a `Parameters<T>` argument carrying `session_id`. A `click` tool is `entry.page.find_element(sel).await?.click().await?`.
- **Start authenticated.** Pass a populated `SessionCreateParams` to `sessions().create` to attach a [profile](/cookbook/profiles) or [credentials](/cookbook/credentials) so the session opens already logged in.
- **Return richer output.** Tools here return `Content::text` and `Content::image`. Swap in structured JSON content when a client wants typed fields instead of a string.

## Related

[Steel + MCP server (Go)](/cookbook/mcp) is the same five tools on the official Go SDK and chromedp; read it for the handle-versus-hidden-state rationale. [chromiumoxide](/cookbook/chromiumoxide) is the bare CDP browser, [rig](/cookbook/rig) drives it from an in-process agent, and [swiftide](/cookbook/swiftide) reads pages through Steel's scrape API instead. The [rmcp docs](https://docs.rs/rmcp) cover transports, resources, and prompts past the tools shown here.

**Go**

This is a [Model Context Protocol](https://modelcontextprotocol.io) server that hands any MCP client (Claude Desktop, an IDE, your own agent) a Steel cloud browser to drive. It is built on the [official MCP Go SDK](https://github.com/modelcontextprotocol/go-sdk) and talks to the browser over CDP with [chromedp](https://github.com/chromedp/chromedp). The whole server is one statically linked binary with no runtime, no `node_modules`, and no model key of its own: the client brings the model, this process only owns the browser.

The server exposes five tools. `create_session` starts a Steel session and returns its id; `navigate`, `extract`, and `screenshot` act on a session; `release_session` tears it down. The id Steel returns is the only thing tying the calls together.

## The session id is the handle

A browser MCP server has to answer one question: when two tools run against "the browser," which browser do they mean? Hiding a single session in a global is the trap the [MCP spec calls out](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/), because a second client on the same process would inherit the first one's cookies and page. The 2026 spec removed the transport-level session and says state should ride on an explicit handle "a `browser_id` minted from a tool and passed back as an ordinary argument." That is exactly what `create_session` does:

```go
func (s *server) createSession(ctx context.Context, _ *mcp.CallToolRequest, _ createInput) (*mcp.CallToolResult, createOutput, error) {
	steelSession, err := s.steel.Sessions.Create(ctx, steel.SessionCreateParams{ ... })
	// ... connect chromedp to steelSession.WebsocketURL ...
	s.sessions[steelSession.ID] = sess
	return nil, createOutput{SessionID: steelSession.ID, LiveViewURL: sess.viewerURL}, nil
}
```

Every other tool takes a `session_id` and looks it up in `server.sessions` (a plain `map` behind a mutex), so the model names the browser it means on each call. Two clients hold two ids and never collide, and because the handle is a normal tool argument it works the same whether the client connected over stdio or HTTP. The Steel session itself is the isolation boundary: each one is its own cloud browser with its own cookies, so the server's only job is to never share a single id across callers.

The allocator in `createSession` runs on `context.Background()`, not the request context. The request is cancelled the moment the tool call returns, but the browser has to outlive that call to serve the next one. `releaseAll`, deferred in `main`, releases whatever is still open when the client disconnects, so a forgotten session does not bill against your account until its idle timeout.

## Run it

```bash
cd examples/mcp-go
cp .env.example .env          # set STEEL_API_KEY for local `go run`
go build -o steel-mcp .
```

Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). Point an MCP client at the binary and pass the key through the client's `env` block. For Claude Desktop, add this to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "steel": {
      "command": "/absolute/path/to/examples/mcp-go/steel-mcp",
      "env": { "STEEL_API_KEY": "your-steel-api-key" }
    }
  }
}
```

Restart the client and ask it to "open news.ycombinator.com and tell me the top story." It will call `create_session`, `navigate`, `extract`, then `release_session` on its own. Watch the run live at the `live_view_url` that `create_session` returns.

One stdio rule: the JSON-RPC stream owns stdout, so the server logs only to stderr (`log` writes there by default). A stray `fmt.Println` corrupts the protocol and the client drops the connection.

## Make it yours

- **Add a tool.** A `click` tool is a few `chromedp.Click` lines and one more `mcp.AddTool` call. Take a `session_id`, look it up with `s.get`, act on the tab.
- **Start authenticated.** Swap the `SessionCreateParams` in `createSession` to attach a [profile](/cookbook/profiles) or [credentials](/cookbook/credentials) so a session opens already logged in.
- **Go remote.** Replace `mcp.StdioTransport` with the SDK's streamable-HTTP handler to serve many clients from one process. The handle pattern already carries the state, so nothing else changes.

## Related

[Steel + MCP server (Rust)](/cookbook/mcp) is the same server built on `rmcp` and chromiumoxide; compare the two for how each language holds the session map. [chromedp](/cookbook/chromedp), [genkit](/cookbook/genkit), and [google-adk-go](/cookbook/google-adk) are the other Go recipes, covering the raw browser, a tool-calling agent, and Google's ADK. The [MCP Go SDK docs](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp) cover transports, resources, and prompts beyond the tools used here.

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Build a browser agent with Genkit](/cookbook/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](/cookbook/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.


# Build a browser agent with Microsoft Agent Framework
URL: https://docs.steel.dev/cookbook/microsoft-agent-framework


[Microsoft Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/) is Microsoft's 1.0 agent runtime (Python + .NET), the successor to AutoGen and Semantic Kernel. Provider-agnostic chat clients, function tools as plain Python callables, and built-in MCP / A2A interop.

This starter wires a Steel browser into the framework's `@tool` decorator pattern and points the [agent](/cookbook/topics/agents) at GitHub Trending.

```python
from agent_framework import Agent, tool
from agent_framework.openai import OpenAIChatClient

agent = Agent(
    client=OpenAIChatClient(model="gpt-5-mini"),
    name="SteelBrowserAgent",
    instructions="You operate a Steel cloud browser via tools. ...",
    tools=build_tools(page),  # navigate, snapshot, extract
)

result = await agent.run(
    "Go to https://github.com/trending/python ..."
)
print(result.text)
```

`agent.run` runs the model loop until the agent stops calling tools. `result.text` aggregates every text content item across the run's messages; `result.messages` is the full transcript including `function_call` and `function_result` contents if you want to inspect what happened.

Tools are plain Python functions decorated with `@tool`. The framework infers the JSON schema from the type hints (`Annotated[str, Field(description=...)]`) — or you can pass a Pydantic model via `schema=`:

```python
@tool(
    name="navigate",
    description="Navigate the open session to a URL and wait for the page to load.",
    approval_mode="never_require",
)
async def navigate(
    url: Annotated[str, Field(description="Absolute URL to navigate to.")],
) -> dict:
    await page.goto(url, wait_until="domcontentloaded", timeout=45_000)
    return {"url": page.url, "title": await page.title()}
```

Tools are built inside `build_tools(page)` so each run closes over its own `Page` — no module globals, safe to run concurrently.

## Run it

```bash
cd examples/microsoft-agent-framework
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
uv run playwright install chromium
uv run main.py
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). Each tool call prints its latency so you can see where time is going.

Your output varies. Structure looks like this:

```text
Steel + Microsoft Agent Framework Starter
============================================================
Session: https://app.steel.dev/sessions/ab12cd34...
    navigate: 1612ms
    snapshot: 487ms (3821 chars, 48 links)
    extract: 394ms (3 rows)

Agent finished.

1. owner/repo — https://github.com/owner/repo (1,240 stars)
   ...

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A run takes ~20 to 40 seconds and 5 to 10 agent turns on GitHub Trending. Cost is a few cents of Steel session time plus OpenAI tokens. The `finally` block in `main` closes Playwright and calls `steel.sessions.release()` so Steel stops billing per-minute.

## Make it yours

- **Swap the model.** Change the `OpenAIChatClient(model=...)` argument. `'gpt-5'`, `'gpt-4o'`, and the chat-completion variants all work. For Anthropic or Azure OpenAI, swap the client: `from agent_framework.anthropic import AnthropicChatClient` or `from agent_framework.azure import AzureOpenAIChatClient`.
- **Swap the task.** Change `PROMPT` and the `INSTRUCTIONS` system text. Tools stay the same; the agent re-plans against the new shape.
- **Structured output.** Pass a Pydantic model via `response_format` in `default_options` (or the per-run `options`). The agent's final message will be validated against the schema.
- **Stream the answer.** Use `async for update in agent.run(prompt, stream=True):` to stream tokens as they arrive. Tool calls happen behind the scenes; `update.text` carries the partial answer.
- **Run agents in parallel.** Construct a session+page+agent per task and `asyncio.gather(*[agent.run(...) for agent in agents])`. Each closure captures its own `page`; nothing is shared by accident.
- **Approval gates.** Drop `approval_mode="never_require"` on a sensitive tool and the framework will pause for an approval response before invoking it — useful for destructive actions (form submits, purchases).
- **Multi-agent workflows.** The framework ships a `Workflow` / `GroupChat` API for graph-based multi-agent orchestration; one Steel-driven `Agent` becomes a node in a larger graph.

## Related

[Steel + Claude Agent SDK (Python)](/cookbook/claude-agent-sdk) · [Steel + Pydantic AI](/cookbook/pydantic-ai) · [Microsoft Agent Framework docs](https://learn.microsoft.com/en-us/agent-framework/overview/)

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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.


# Control a browser with Notte's reasoning engine
URL: https://docs.steel.dev/cookbook/notte


Notte builds its [agent](/cookbook/topics/agents) on top of a perception layer. Each step, `notte.Session` flattens the live DOM into a compact action space (labeled interactive elements, form fields, section headings) and hands that structured view to the reasoning model. The model picks an action by id, Notte translates it back into a browser command. Steel's [Notte integration](/integrations/notte) covers the same setup on its own.

This recipe points that loop at a Steel session instead of a locally-launched browser.

```python
with notte.Session(cdp_url=cdp_url) as notte_session:
    agent = notte.Agent(
        session=notte_session,
        max_steps=5,
        reasoning_model="gemini/gemini-2.5-flash",
    )
    response = agent.run(task=TASK)
```

`notte.Session(cdp_url=...)` is the integration surface. The default `perception_type` is `"fast"` (heuristic parser); pass `perception_type="deep"` on pages where the fast path misses elements.

`max_steps` caps iterations. The starter uses 5; sign-in / filter / extract flows typically want 15 to 30. The agent exits early when it marks the task complete.

## Run it

```bash
cd examples/notte
cp .env.example .env          # set STEEL_API_KEY and GEMINI_API_KEY
uv run main.py
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [aistudio.google.com](https://aistudio.google.com/app/apikey). The session viewer URL prints as the script starts.

Your output varies. Structure looks like this:

```text
Steel + Notte Assistant
============================================================

Starting Steel browser session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34...

Executing task: Go to Wikipedia and search for machine learning
============================================================

============================================================
TASK EXECUTION COMPLETED
============================================================
Duration: 24.3 seconds
Task: Go to Wikipedia and search for machine learning
Result:
Machine learning is a field of artificial intelligence...
============================================================

Releasing Steel session...
Session completed. View replay at https://app.steel.dev/sessions/ab12cd34...
Done!
```

A default run takes ~25 seconds. The `finally` block calls `client.sessions.release(session.id)`.

## Make it yours

- **Change the task.** Set `TASK` in `.env` or edit the default in `main.py`.
- **Raise `max_steps`.** Bump the ceiling on `notte.Agent(...)` for multi-page flows.
- **Swap the reasoning model.** Change `reasoning_model` on `notte.Agent`. Flash for speed, GPT-5 or Sonnet for ambiguity.
- **Switch to deep perception.** Pass `perception_type="deep"` to `notte.Session(...)` when the fast heuristics miss elements.
- **Turn on stealth.** Add `use_proxy=True`, `solve_captcha=True`, or `session_timeout=1800000` to `client.sessions.create()` for sites with anti-bot.

## Related

[Notte docs](https://docs.notte.cc) · [Notte on GitHub](https://github.com/nottelabs/notte)

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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 typed browser agent with the OpenAI Agents SDK
URL: https://docs.steel.dev/cookbook/openai-agents


**TypeScript**

The [OpenAI Agents SDK](https://openai.github.io/openai-agents-js/) (`@openai/agents`) is a small runtime for [agentic loops](/cookbook/topics/agents). You define an `Agent` with `instructions`, a `model`, `tools`, and an `outputType`. You call `run(agent, prompt)`. The SDK handles "pick a tool, call it, feed the result back, repeat" and validates the final message against your schema. Steel's [OpenAI Agents SDK integration](/integrations/openai-agents-sdk) covers the same pairing on its own.

This recipe turns the tool layer into a Steel cloud browser. Four `tool()` wrappers in `index.ts` (`openSession`, `navigate`, `snapshot`, `extract`) shuttle CDP calls between the agent and Playwright. Demo task: scan `github.com/trending/python` and return the top 3 AI/ML repos as a validated `FinalReport`.

```typescript
const FinalReport = z.object({
  summary: z.string(),
  repos: z.array(z.object({
    name: z.string(),
    url: z.string(),
    stars: z.string().nullable(),
    description: z.string().nullable(),
  })).min(1).max(5),
});

const agent = new Agent({
  name: "SteelResearch",
  instructions: "You operate a Steel cloud browser via tools. Workflow: ...",
  model: "gpt-5-mini",
  tools: [openSession, navigate, snapshot, extract],
  outputType: FinalReport,
});

const result = await run(agent, "Go to https://github.com/trending/python ...", { maxTurns: 15 });
console.log(result.finalOutput); // typed as z.infer<typeof FinalReport>
```

The SDK compiles Zod to OpenAI's strict JSON Schema at registration, which tightens a couple of rules: no `.url()` format (pass a plain `z.string()`), and `.optional()` is rejected. Use `.nullable()` instead.

## Run it

```bash
cd examples/openai-agents-ts
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
npm install
npx playwright install chromium
npm start
```

Get keys at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and [platform.openai.com/api-keys](https://platform.openai.com/api-keys). A viewer URL prints as `openSession` runs.

Your output varies. Structure looks like this:

```text
Steel + OpenAI Agents SDK (TypeScript) Starter
============================================================
    open_session: 1432ms
    navigate: 2180ms
    snapshot: 412ms (3921 chars, 48 links)
    extract: 380ms (3 rows)

Agent finished.

{
  "summary": "All three repos focus on LLM tooling written in Python...",
  "repos": [
    { "name": "owner/repo", "url": "https://github.com/...", "stars": "1,204", "description": "..." },
    ...
  ]
}

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A full run is ~20-40 seconds. Cost is a few cents of Steel session time plus OpenAI tokens per turn. The `finally` block calls `steel.sessions.release()`.

## Make it yours

- **Swap the task and schema.** Change the prompt passed to `run()` and rewrite `FinalReport`. The four tools are task-agnostic.
- **Add handoffs.** Pass `handoffs: [writerAgent]` on the `Agent`. The SDK routes between agents based on each one's description.
- **Add a guardrail.** Wire `inputGuardrails` or `outputGuardrails` on the `Agent` to vet the user's prompt or the final message. See the [guardrails guide](https://openai.github.io/openai-agents-js/guides/guardrails).
- **Use a stronger model.** `model: "gpt-5"` plans better on ambiguous pages at the cost of tokens and latency.
- **Turn on stealth.** Pass `useProxy`, `solveCaptcha`, or a longer `sessionTimeout` to `sessions.create()` for sites with anti-bot.

## Related

[Python version](/cookbook/openai-agents) · [OpenAI Agents SDK docs](https://openai.github.io/openai-agents-js/) · [Computer Use version](/cookbook/openai-computer-use)

**Python**

The [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) runs the tool-call loop so you don't have to. You declare an `Agent` with tools, a model, and (optionally) a Pydantic `output_type`. You call `Runner.run(agent, input=...)` once. The SDK handles every model turn, every tool dispatch, and every schema check until the agent returns a typed final answer.

This starter wraps a Steel browser as four tools and points the agent at GitHub Trending.

```python
from agents import Agent, Runner, function_tool

agent = Agent(
    name="SteelResearch",
    instructions="You operate a Steel cloud browser via tools. ...",
    model="gpt-5-mini",
    tools=[open_session, navigate, snapshot, extract],
    output_type=FinalReport,
)

result = await Runner.run(agent, input="...", max_turns=15)
final: FinalReport = result.final_output
```

Each tool is a plain async function wrapped with `@function_tool`. The SDK reads the signature and docstring to build the JSON schema the model sees. `output_type=FinalReport` forces the last turn to produce a Pydantic-validated object, so `result.final_output` is typed.

## Run it

```bash
cd examples/openai-agents-py
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
uv run playwright install chromium
uv run main.py
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). Each tool call prints its latency so you can see where time is going.

Your output varies. Structure looks like this:

```text
Steel + OpenAI Agents SDK (Python) Starter
============================================================
    open_session: 2843ms
    navigate: 1612ms
    snapshot: 487ms (3821 chars, 48 links)
    extract: 394ms (3 rows)

Agent finished.

{
  "summary": "Three trending Python repos focused on agentic workflows...",
  "repos": [
    {
      "name": "owner/repo",
      "url": "https://github.com/owner/repo",
      "stars": "1,240",
      "description": "..."
    },
    ...
  ]
}

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A run takes ~20 to 40 seconds and 5 to 10 agent turns on GitHub Trending. Cost is a few cents of Steel session time plus OpenAI tokens. The `finally` block in `main` closes Playwright and calls `steel.sessions.release()`.

The Agents SDK ships [tracing](https://openai.github.io/openai-agents-python/tracing/) on by default. Each `Runner.run` produces a trace viewable at [platform.openai.com/traces](https://platform.openai.com/traces).

## Make it yours

- **Swap the task.** Change the `input=` string in `main()` and the `FinalReport` schema. Tools stay the same; the agent re-plans.
- **Add a tool.** Write an async function, decorate with `@function_tool`, add it to `tools=[...]`. A useful fifth tool is `click(selector: str)` that calls `page.click` and waits for navigation.
- **Hand off to a specialist.** The SDK supports [handoffs](https://openai.github.io/openai-agents-python/handoffs/): define a second `Agent` (say, a `Summarizer` with no tools) and list it in `handoffs=[...]` on the research agent.
- **Add a guardrail.** Attach an [input or output guardrail](https://openai.github.io/openai-agents-python/guardrails/) to reject off-topic requests or validate the `FinalReport` before it returns.
- **Swap the model.** `model="gpt-5"` for harder reasoning, `"gpt-5-mini"` (default) for speed and cost.
- **Raise `max_turns`.** 15 is plenty for single-page extraction. Multi-page flows want 25 to 40.
- **Use `context`.** Replace module globals with a dataclass passed to `Runner.run(agent, input=..., context=my_ctx)`. Each tool reads it via `RunContextWrapper`. Needed for concurrent runs.

## Related

[TypeScript version](/cookbook/openai-agents) · [OpenAI Computer Use (Python)](/cookbook/openai-computer-use) · [OpenAI Agents SDK docs](https://openai.github.io/openai-agents-python/)

## Related recipes

- [Build a browser agent with Google ADK](/cookbook/google-adk): Use Steel with Google's Agent Development Kit (ADK) for Go to build a tool-using browser agent that drives a chromedp session over CDP and reads Hacker News.
- [Build a typed browser agent with Pydantic AI](/cookbook/pydantic-ai): Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection.
- [Build a typed browser agent with LangGraph](/cookbook/langgraph): Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node.


# Drive a browser with OpenAI Computer Use
URL: https://docs.steel.dev/cookbook/openai-computer-use


**TypeScript**

OpenAI's [computer-use](/cookbook/topics/computer-use) model ships as a single tool declaration: `{ type: "computer" }`. You hand it to the Responses API, send a screenshot, and the model returns `computer_call` items with actions like `click`, `type`, `keypress`, `scroll`. Your job is to execute them against a real browser, capture the next screenshot, and feed it back. Steel's [OpenAI Computer Use integration](/integrations/openai-computer-use) covers the same loop as a guide.

## The loop

The Responses API threads conversation state server-side, so each turn carries only the new tool outputs plus a `previous_response_id`:

```typescript
const response = await createResponse({
  model: this.model,
  instructions: this.systemPrompt,
  input: nextInput,
  tools: this.tools,
  previous_response_id: previousResponseId,
  reasoning: { effort: "medium" },
  truncation: "auto",
});
```

First turn: `nextInput` is `[{ role: "user", content: task }]`. Subsequent turns: `nextInput` is just the array of tool outputs from the previous iteration.

The response's `output` array mixes three item types:

- `message`: a plain text reply. The final message becomes the return value.
- `reasoning`: the model's own summary; printed but not fed back.
- `computer_call`: one or more actions to execute. Each call has a `call_id` that must be echoed back in the matching `computer_call_output`.

## Actions in, screenshots out

`executeComputerAction` is the translation layer:

```typescript
case "click": {
  const coords = this.toCoords(actionArgs.x, actionArgs.y);
  const button = this.mapButton(actionArgs.button);
  const clicks = this.toNumber(actionArgs.num_clicks, 1);
  body = {
    action: "click_mouse",
    button,
    coordinates: coords,
    ...(clicks > 1 ? { num_clicks: clicks } : {}),
    screenshot: true,
  };
  break;
}
```

The screenshot goes back as a `computer_call_output`, matched by `call_id`:

```typescript
toolOutputs.push({
  type: "computer_call_output",
  call_id: item.call_id,
  acknowledged_safety_checks: pendingChecks,
  output: {
    type: "computer_screenshot",
    image_url: `data:image/png;base64,${screenshotBase64}`,
  },
});
```

A few translation details:

- **`keypress` takes a list.** `normalizeKey` rewrites synonyms (`CTRL` to `Control`, `CMD` to `Meta`, `ENTER` to `Enter`).
- **`scroll` is delta-based.** OpenAI sends `scroll_x`/`scroll_y` in pixels. Steel's `scroll` takes `delta_x`/`delta_y` directly.
- **`drag` gives a path.** OpenAI provides the full point list in `path`; Steel's `drag_mouse` wants the same shape.
- **Unknown actions fall through to `take_screenshot`.**

## Safety checks

A `computer_call` can attach `pending_safety_checks` when the planned action looks sensitive. The call won't take effect until you echo those check IDs back in `acknowledged_safety_checks`. The starter auto-acknowledges everything; for production, flip `autoAcknowledgeSafety` to `false` and gate each check on a human approval.

## Run it

```bash
cd examples/openai-computer-use-ts
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
npm install
npm start
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). Override the task inline:

```bash
TASK="Find the current weather in New York City" npm start
```

Your output varies. Structure looks like this:

```text
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34...

Executing task: Go to Steel.dev and find the latest news
============================================================
I'll navigate to steel.dev and scan the landing page.
click({"x":720,"y":48})
type({"text":"https://steel.dev"})
keypress({"keys":["Enter"]})
scroll({"x":720,"y":450,"scroll_y":600})
Steel's latest release adds ...

============================================================
TASK EXECUTION COMPLETED
============================================================
Duration: 71.4 seconds
```

Expect roughly 60-120 seconds and 15-40 turns for a simple browsing task.

## Make it yours

- **Change the viewport.** `viewportWidth` and `viewportHeight` in the `Agent` constructor set the Steel session `dimensions`.
- **Swap the model.** The default is `gpt-5.5`. Update `this.model` in the `Agent` constructor.
- **Tune reasoning effort.** `reasoning: { effort: "medium" }` trades latency for planning quality.
- **Rewrite the system prompt.** `BROWSER_SYSTEM_PROMPT` holds the browsing conventions.
- **Persist a login.** Pass `sessionContext` to `sessions.create`. See [credentials](/cookbook/credentials) and [auth-context](/cookbook/auth-context).
- **Turn off auto-ack.** Flip `autoAcknowledgeSafety` to `false` to make pending safety checks raise.

## Related

[Computer use guide](https://platform.openai.com/docs/guides/tools-computer-use) · [Python version](/cookbook/openai-computer-use) · [Anthropic equivalent](/cookbook/claude-computer-use)

**Python**

OpenAI's Computer Use models expose one tool (`{"type": "computer"}`) and emit `computer_call` items containing an `action` the model wants performed on a screen. You execute the action, return a screenshot as a `computer_call_output`, and the next turn the model sees the result. The action vocabulary (`click`, `type`, `keypress`, `scroll`, `drag`, `wait`, `screenshot`) is fixed by OpenAI.

This recipe uses OpenAI's **Responses API**, not Chat Completions. Responses keeps conversation state on OpenAI's side via `previous_response_id`, so each turn only sends the new tool outputs rather than the full screenshot history.

## The loop

```python
params = {
    "model": self.model,
    "instructions": self.system_prompt,
    "input": next_input,
    "tools": self.tools,
    "reasoning": {"effort": "medium"},
    "truncation": "auto",
}
if previous_response_id:
    params["previous_response_id"] = previous_response_id

response = create_response(**params)
previous_response_id = response.get("id")
```

Each `response["output"]` is a list of items with a `type`. The loop walks them:

- `reasoning`: model's internal thinking, printed.
- `message`: terminal prose; the agent stores the last one as the final result.
- `computer_call`: one or more actions to execute.

`execute_computer_action` maps OpenAI's action vocabulary onto Steel's Input API. Each branch builds a Steel request body and sends it through `self.steel.sessions.computer(...)` with `screenshot: True`:

```python
elif action_type in ("click",):
    coords = self.to_coords(action_args.get("x"), action_args.get("y"))
    button = self.map_button(action_args.get("button"))
    num_clicks = int(self.to_number(action_args.get("num_clicks"), 1))
    payload = {
        "action": "click_mouse",
        "button": button,
        "coordinates": [coords[0], coords[1]],
        "screenshot": True,
    }
    if num_clicks > 1:
        payload["num_clicks"] = num_clicks
    body = payload
```

`keypress` arrives with OpenAI names (`CTRL`, `ENTER`, `ESC`, `UP`); `normalize_key` rewrites them into the Steel / DOM vocabulary (`Control`, `Enter`, `Escape`, `ArrowUp`).

The screenshot goes back as a `computer_call_output`:

```python
tool_outputs.append({
    "type": "computer_call_output",
    "call_id": item["call_id"],
    "acknowledged_safety_checks": pending_checks,
    "output": {
        "type": "computer_screenshot",
        "image_url": f"data:image/png;base64,{screenshot_base64}",
    },
})
```

## Safety checks

A `computer_call` can include `pending_safety_checks`. You must echo them back in `acknowledged_safety_checks` on the next turn, or the model stalls. The default here is `auto_acknowledge_safety = True`, which suits a starter but is not what you want in production. Flip it to `False` and surface the check to a human before proceeding.

## Run it

```bash
cd examples/openai-computer-use-py
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
uv run main.py
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys).

Override the task per run:

```bash
TASK="Find the current weather in New York City" python main.py
```

Your output varies. Structure looks like this:

```text
Starting Steel session...
Steel Session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34…

Executing task: Go to Steel.dev and find the latest news
============================================================
I'll open steel.dev and check the blog.
keypress({"keys": ["CTRL", "L"]})
type({"text": "https://steel.dev"})
keypress({"keys": ["ENTER"]})
wait({"ms": 1500})
…
Steel's latest release notes mention …

TASK EXECUTION COMPLETED
Duration: 62.8 seconds
```

A run typically takes 60-180 seconds and 10-30 iterations. Screenshots are cached between turns via `previous_response_id`, so per-turn input cost stays roughly flat even on long loops. The `finally` block in `main()` calls `sessions.release()`.

## Make it yours

- **Change the task.** Edit `TASK` in `.env` or pass it inline.
- **Swap the model.** The default is `gpt-5.5`. Update `self.model` in `Agent.__init__`.
- **Tune the viewport.** `viewport_width` / `viewport_height` in `Agent.__init__` flow into `sessions.create(dimensions=...)`.
- **Turn off auto-ack.** Flip `auto_acknowledge_safety = False` to make pending safety checks raise.
- **Persist a login.** Pass `session_context` to `sessions.create`. See [credentials](/cookbook/credentials).
- **Adjust reasoning.** `"effort": "medium"` trades latency for deeper plans. Drop to `"low"` for fast lookups, raise to `"high"` for multi-step research.

## Related

[TypeScript version](/cookbook/openai-computer-use) · [Claude version](/cookbook/claude-computer-use) · [OpenAI Computer Use guide](https://platform.openai.com/docs/guides/tools-computer-use) · [Responses API reference](https://platform.openai.com/docs/api-reference/responses)

**Rust**

OpenAI's `computer-use-preview` model returns mouse and keyboard actions instead of text. This recipe executes those actions against a real Chromium running in Steel's cloud, feeds each resulting screenshot back, and loops until the model reports the task done. There is no official OpenAI Rust SDK, so it calls the Responses API directly with `reqwest` and drives the browser through Steel's server-side `computer` endpoint. It is the Rust counterpart to [openai-computer-use-py](/cookbook/openai-computer-use) and the OpenAI sibling of [claude-computer-use-rs](/cookbook/claude-computer-use).

## The loop

The Responses API is stateful. Each turn you pass the previous `response.id` as `previous_response_id` and send only the new input, so the conversation never gets resent:

```rust
let mut input = json!([{ "role": "user", "content": task }]);
let mut previous_response_id: Option<String> = None;

for _ in 0..MAX_ITERATIONS {
    let response = self.call_openai(&input, &previous_response_id).await?;
    previous_response_id = Some(response.id);

    let mut next_input = Vec::new();
    for item in &response.output {
        // message -> print it, reasoning -> print it,
        // computer_call -> run the action, screenshot, push a computer_call_output
    }
    if next_input.is_empty() { break; } // model returned only text: done
    input = Value::Array(next_input);
}
```

Contrast [claude-computer-use-rs](/cookbook/claude-computer-use), where the Anthropic Messages API is stateless and you grow and resend a `messages` array every turn. Here the server holds the history and you send back only `computer_call_output` items. `MAX_ITERATIONS` caps the loop so a stuck model cannot run forever.

## From OpenAI action to Steel action

A `computer_call` carries one `action` such as `{ "type": "click", "button": "left", "x": 412, "y": 280 }`. `execute_action` matches on `type` and builds the matching Steel `SessionComputerParams`:

```rust
"click" => SessionComputerParams::ClickMouse(ComputerActionRequestClickMouse {
    button: Some(map_button(/* left | right | middle | back | forward */)),
    coordinates: Some(vec![x, y]),
    screenshot: Some(true),
    ..
}),
"type"     => SessionComputerParams::TypeText(/* text */),
"keypress" => SessionComputerParams::PressKey(/* normalized keys */),
"scroll"   => SessionComputerParams::Scroll(/* delta_x, delta_y */),
```

Every action sets `screenshot: true`, so Steel returns a fresh base64 PNG. That image goes back as a `computer_call_output` with `image_url: data:image/png;base64,...`, which is what the model sees for its next move. OpenAI key names (`ENTER`, `CTRL`, `ESC`) are normalized to the DOM vocabulary Steel expects (`Enter`, `Control`, `Escape`).

When a turn includes a `pending_safety_check`, the recipe auto-acknowledges it by echoing it back in `acknowledged_safety_checks`. That is fine for a demo on a throwaway page. Read each check before letting an agent act on a real account.

## Run it

```bash
cd examples/openai-computer-use-rs
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
cargo run
```

Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and an OpenAI key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys). Set `TASK` in `.env` to change the goal. Your output varies. Structure looks like this:

```text
Steel + OpenAI Computer Use Assistant
============================================================

Starting Steel session...
View live session at: https://app.steel.dev/sessions/3f2a...
Executing task: Go to Steel.dev and find the latest news
============================================================
click(button=left x=720 y=400)
type(text="steel.dev blog")
keypress(keys=["Enter"])
...
The latest Steel post is "...".
============================================================
TASK EXECUTION COMPLETED
Duration: 48.2 seconds
```

A run drives a real session and a vision model across many turns, so it costs a few cents of browser time plus the OpenAI tokens for the loop. Steel bills per session-minute until `cleanup` releases the session, which always runs through a deferred release, even on error.

## Make it yours

- **Change the task.** Set `TASK` in `.env`, or edit the default in `main`.
- **Resize the viewport.** `VIEWPORT_WIDTH` and `VIEWPORT_HEIGHT` set both the Steel session dimensions and the `display_width`/`display_height` on the tool. Keep them in sync so the model's coordinates match the page.
- **Gate safety checks.** Instead of auto-acknowledging every `pending_safety_check`, prompt a human or allowlist specific codes before echoing them back.
- **Start authenticated.** Pass a session context or credentials to `sessions().create(...)` so the agent begins on a logged-in page. See [auth-context](/cookbook/auth-context) and [credentials](/cookbook/credentials).

## Related

[openai-computer-use-go](/cookbook/openai-computer-use) is the same agent through the official `openai-go` SDK, which has a typed Responses API. [openai-computer-use-py](/cookbook/openai-computer-use) and [openai-computer-use-ts](/cookbook/openai-computer-use) are the Python and TypeScript versions. [claude-computer-use-rs](/cookbook/claude-computer-use) runs the same Steel action loop against Anthropic instead.

**Go**

This recipe wires two typed Go SDKs together so OpenAI's `computer-use-preview` model can drive a Steel cloud browser. The model emits a computer action through the official `github.com/openai/openai-go/v3` Responses API. You execute that action against a Steel session with `client.Sessions.Computer`, then hand the resulting screenshot back so the next turn sees what changed. That single exchange, repeated, is the whole agent.

The interesting part in Go is the seam between the two SDKs, because each models the action vocabulary differently. openai-go gives you a *flattened* union: `ResponseComputerToolCallActionUnion` carries every possible field (`X`, `Y`, `Button`, `Keys`, `ScrollX`, `Text`, `Path`) on one struct, and you read whichever ones the `Type` discriminator says are live. Steel's Go SDK takes the opposite shape: `SessionComputerParams` is a *constructed* discriminated union where you set an `Action` string and attach the one matching `ComputerActionRequest*` pointer. `executeAction` is the translation layer between the two.

```go
case "click":
    body := &steel.ComputerActionRequestClickMouse{
        Action:      steel.ComputerActionRequestClickMouseActionClickMouse,
        Button:      ptr(mapButton(act.Button)),
        Coordinates: coords(),
        Screenshot:  ptr(true),
    }
    return a.run(ctx, steel.SessionComputerParams{Action: "click_mouse", ComputerActionRequestClickMouse: body})
```

Every branch sets `Screenshot: ptr(true)`, so the Steel call that performs the action also returns the screenshot in the same round trip. `run` reads `resp.Base64Image` and falls back to an explicit `take_screenshot` if a particular action did not capture one.

## Responses keeps the conversation, you keep the loop

The Responses API stores conversation state server side. The first turn sends the task as a user message; every later turn sends only the new `computer_call_output` items and threads `PreviousResponseID` from the prior response. You never resend the screenshot history, so input size stays roughly flat even across a long run.

```go
params := responses.ResponseNewParams{
    Model:        shared.ResponsesModelComputerUsePreview,
    Instructions: openai.String(systemPrompt()),
    Input:        responses.ResponseNewParamsInputUnion{OfInputItemList: input},
    Tools: []responses.ToolUnionParam{
        responses.ToolParamOfComputerUsePreview(viewportHeight, viewportWidth, responses.ComputerUsePreviewToolEnvironmentBrowser),
    },
    Reasoning:  shared.ReasoningParam{Effort: shared.ReasoningEffortMedium},
    Truncation: responses.ResponseNewParamsTruncationAuto,
}
if previousResponseID != "" {
    params.PreviousResponseID = openai.String(previousResponseID)
}
```

`executeTask` walks `resp.Output`, switching on each item's `Type`. A `reasoning` item is the model thinking out loud and gets printed. A `message` item is terminal prose, stored as the final result. A `computer_call` item carries the actions to run. The model may batch several actions into one call, so `actionsFromCall` returns `call.Actions` when it is populated and the single `call.Action` otherwise, normalizing both into the same flat slice. When a turn produces no tool output, the loop stops.

The model speaks OpenAI key names (`CTRL`, `ENTER`, `ESC`, `ArrowUp`). Steel expects DOM key names (`Control`, `Enter`, `Escape`). `normalizeKey` rewrites them before any `press_key` action goes out.

## Safety checks

A `computer_call` can arrive with `PendingSafetyChecks`. You must echo each one back in `AcknowledgedSafetyChecks` on the matching `computer_call_output`, or the model stalls waiting for confirmation. This starter auto-acknowledges and prints each check:

```go
for _, check := range call.PendingSafetyChecks {
    fmt.Printf("Auto-acknowledging safety check: %s\n", check.Message)
    acks = append(acks, responses.ResponseInputItemComputerCallOutputAcknowledgedSafetyCheckParam{
        ID: check.ID, Code: openai.String(check.Code), Message: openai.String(check.Message),
    })
}
```

Auto-acknowledging suits a demo, not production. In a real deployment, surface the check's `Message` to a human and only acknowledge on approval.

## Run it

```bash
cd examples/openai-computer-use-go
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
go mod tidy
go run .
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). The program prints a session viewer URL at startup. Open it in another tab to watch the browser run live.

Override the task per run:

```bash
TASK="Find the current weather in New York City" go run .
```

Your output varies. Structure looks like this:

```text
Steel session created successfully!
View live session at: https://app.steel.dev/sessions/ab12cd34...

Executing task: Go to Steel.dev and find the latest news
============================================================
I'll open steel.dev and check for recent posts.
keypress(keys=[Control l])
type(text="https://steel.dev")
keypress(keys=[Enter])
...
Steel's latest update mentions ...

============================================================
TASK EXECUTION COMPLETED
============================================================
Duration: 78.4 seconds
```

A run typically takes 60-180 seconds across 10-30 model turns. Each turn is one Responses call plus one or more Steel computer actions, so a run costs a few cents of browser time on top of model tokens. Steel bills per session-minute, so the deferred `cleanup` that calls `Sessions.Release` is not optional: skip it and the browser keeps running until the session timeout (set to 900000 ms here in `initialize`).

## Make it yours

- **Change the task.** Edit `TASK` in `.env` or pass it inline.
- **Tune reasoning effort.** `shared.ReasoningEffortMedium` trades latency for deeper plans. Drop to `ReasoningEffortLow` for quick lookups, raise to `ReasoningEffortHigh` for multi-step research.
- **Adjust the viewport.** `viewportWidth` and `viewportHeight` feed both the Steel session `Dimensions` and the `computer_use_preview` tool's display size. Keep the two in sync so the model's coordinates match the real screen.
- **Raise or lower the ceiling.** `maxIterations` bounds the loop at 50 turns. Lower it to cap spend on a flaky task.
- **Gate safety checks.** Replace the auto-acknowledge block with a prompt or an allowlist before appending to `acks`.
- **Persist a login.** Pass `SessionContext` to `Sessions.Create` to reuse cookies across runs. See [credentials](/cookbook/credentials).

## Notes

The published OpenAI Python and TypeScript computer-use recipes target the `{"type": "computer"}` tool on a newer general model. This Go port uses the dedicated `computer-use-preview` model and its `computer_use_preview` tool, which is the path the openai-go Responses API exposes today through `ToolParamOfComputerUsePreview`. The action vocabulary and the loop shape are identical; only the tool descriptor and model id differ.

## Related

[Python version](/cookbook/openai-computer-use) · [TypeScript version](/cookbook/openai-computer-use) · [Claude on Steel in Go](/cookbook/claude-computer-use) · [OpenAI computer use guide](https://platform.openai.com/docs/guides/tools-computer-use) · [Responses API reference](https://platform.openai.com/docs/api-reference/responses)

## Related recipes

- [Drive a browser with Gemini Computer Use](/cookbook/gemini-computer-use): Connect Google's Gemini Computer Use to a Steel browser session for autonomous web interactions.
- [Drive a mobile browser with Claude Computer Use](/cookbook/claude-computer-use-mobile): Claude Computer Use with Steel for autonomous task execution in mobile browser environments.
- [Drive a browser with Claude Computer Use](/cookbook/claude-computer-use): Connect Claude to a Steel browser session for autonomous web interactions.


# Automate a cloud browser with Playwright
URL: https://docs.steel.dev/cookbook/playwright


**TypeScript**

Playwright exposes `chromium.connectOverCDP()`, which attaches to any Chrome speaking the Chrome DevTools Protocol. Steel sessions expose one over a websocket. Connect them and your local code [drives a remote browser](/cookbook/topics/browser-automation) with stealth, proxies, and a live viewer. No Chrome on your machine required. Steel's [Playwright integration](/integrations/playwright) covers the same connection as a guide.

```typescript
session = await client.sessions.create();

const browser = await chromium.connectOverCDP(
  `${session.websocketUrl}&apiKey=${STEEL_API_KEY}`,
);

const page = browser.contexts()[0].pages()[0];
```

A few lines. Steel returns a context with a page already open, so skip `newContext()` / `newPage()`. Everything after is plain Playwright: selectors, `page.evaluate`, `waitForSelector`, tracing.

## Run it

```bash
cd examples/playwright-ts
cp .env.example .env          # set STEEL_API_KEY
npm install
npm start
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The script prints a session viewer URL as it starts. Open it in another tab to watch the browser run live.

Your output varies. Structure looks like this:

```text
Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34…

Connected to browser via Playwright
Navigating to Hacker News...

Top 5 Hacker News Stories:

1. Claude 4.7 Opus released today
   Link: https://news.ycombinator.com/item?id=43218921
   Points: 892

2. Show HN: A browser extension for reading on slow connections
   Link: https://github.com/user/project
   Points: 401

…

Releasing session...
Session released
Done!
```

A run costs a few cents of browser time. Steel bills per session-minute, so the `finally` block that calls `client.sessions.release()` isn't optional. Forgetting it keeps the browser running until the default 5-minute timeout.

## Make it yours

- **Swap the target.** Replace the `page.goto` URL and the `page.evaluate` body in `index.ts`. Session setup, auth, and cleanup stay the same.
- **Turn on stealth.** Uncomment `useProxy`, `solveCaptcha`, or `sessionTimeout` in the `sessions.create()` call for sites with anti-bot.
- **Persist login.** Reuse cookies and local storage across runs via [credentials](/cookbook/credentials).

## Related

[Python version](/cookbook/playwright) · [Playwright docs](https://playwright.dev)

**Python**

Playwright's Python API ships a CDP attach point, `chromium.connect_over_cdp()`. Point it at the websocket URL a Steel session hands back and your `Page`, `Locator`, and `expect` calls drive a remote browser instead of a local one. No `playwright install`, no headful display, no Chrome on your machine.

The whole connection is three lines inside a `with sync_playwright()` block:

```python
session = client.sessions.create()

playwright = sync_playwright().start()
browser = playwright.chromium.connect_over_cdp(
    f"{session.websocket_url}&apiKey={STEEL_API_KEY}"
)

page = browser.contexts[0].new_page()
```

Two Python-specific details worth calling out. First, this starter uses the **sync API**. Easier to read top-to-bottom and fine for one script at a time; swap in `async_playwright` if you need to fan out concurrent pages. Second, Steel returns a session with a context already attached, so you reuse `browser.contexts[0]` rather than calling `new_context()`. Everything downstream is plain Playwright: `page.locator`, `page.goto(url, wait_until="networkidle")`, XPath selectors.

## Run it

```bash
cd examples/playwright-py
cp .env.example .env          # set STEEL_API_KEY
uv run main.py
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). As the script boots it prints a session viewer URL. Open it in a second tab to watch the browser click through Hacker News in real time.

Your output varies. Structure looks like this:

```text
Creating Steel session...
Steel Session created successfully!
You can view the session live at https://app.steel.dev/sessions/ab12cd34…

Connected to browser via Playwright
Navigating to Hacker News...

Top 5 Hacker News Stories:

1. Claude 4.7 Opus released today
   Link: https://news.ycombinator.com/item?id=43218921
   Points: 892

2. Show HN: A browser extension for reading on slow connections
   Link: https://github.com/user/project
   Points: 401

…

Releasing session...
Session released
Done!
```

One run costs a few cents of session time. Steel bills per session-minute, which is why `main()` wraps the script in `try / finally` and calls `client.sessions.release(session.id)` on exit. If you skip that, the session sits idle until the default 5-minute timeout burns through.

## Make it yours

- **Swap the target.** The scraping logic lives between the `Your Automations Go Here!` banner comments in `main.py`. Replace `page.goto` and the `story_rows` loop with your own selectors. Session setup, auth, and teardown stay the same.
- **Harden for anti-bot.** Uncomment `use_proxy`, `solve_captcha`, or `session_timeout` inside `client.sessions.create()` for sites that fingerprint or challenge headless traffic.
- **Go async.** If you need parallel pages, switch `from playwright.sync_api import sync_playwright` to `playwright.async_api` and rewrite `main()` as `async def`. The Steel connection call is identical, just awaited.
- **Persist login.** Carry cookies and local storage between runs with [credentials](/cookbook/credentials).

## Related

[TypeScript version](/cookbook/playwright) · [Playwright docs](https://playwright.dev/python)

**Go**

playwright-go exposes `pw.Chromium.ConnectOverCDP`, which attaches to any Chrome speaking the DevTools Protocol. A Steel session is exactly that: a remote Chrome reachable over a websocket. Hand the connect call the session's websocket URL with your key appended and the rest is ordinary Playwright, running against a browser in Steel's cloud with stealth, proxies, and a live viewer.

```go
cdpURL := fmt.Sprintf("%s&apiKey=%s", sess.WebsocketURL, apiKey)
browser, err := pw.Chromium.ConnectOverCDP(cdpURL)

page := browser.Contexts()[0].Pages()[0]
```

Steel returns a context with a page already open, so there is no `NewContext` / `NewPage` ceremony: reach into `Contexts()[0].Pages()[0]` and start driving. Everything after, `Goto`, `Evaluate`, `QuerySelectorAll`, `Screenshot`, is the same Playwright API the JavaScript and Python bindings expose.

## The driver, not the browser

playwright-go is not a pure-Go CDP client the way [chromedp](/cookbook/chromedp) and [Rod](/cookbook/rod) are. It drives the same Node-based Playwright driver the other language bindings use, so that driver has to exist on disk before `playwright.Run()` will start. The program installs it on the first line of `run`:

```go
if err := playwright.Install(&playwright.RunOptions{SkipInstallBrowsers: true}); err != nil {
    return fmt.Errorf("install driver: %w", err)
}
pw, err := playwright.Run()
```

`SkipInstallBrowsers: true` is the part that matters for Steel. A normal Playwright install also downloads Chromium, Firefox, and WebKit, hundreds of megabytes you never run, because the browser lives in Steel's cloud, not on your machine. The flag fetches the driver alone. Drop it and the first run still works, but it pulls three browser engines you will never launch. Calling `Install` in code is convenient for a one-file example; in a larger project you would run `go run github.com/playwright-community/playwright-go/cmd/playwright install` once at build time instead and let `Run` assume the driver is present.

The extraction reads the way it does in [chromedp](/cookbook/chromedp) for the same reason. `page.Evaluate` returns an `interface{}`, and a slice of structs does not cross that boundary cleanly, so the page-side script `JSON.stringify`s its result and the Go side `json.Unmarshal`s the string into a typed `[]story`. The `extractTopStories` constant holds that script: it pulls title, link, and points from the top five `tr.athing` rows.

## Run it

```bash
cd examples/playwright-go
cp .env.example .env          # set STEEL_API_KEY
go mod tidy
go run .
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The first `go run` downloads the Playwright driver, which takes a moment; later runs reuse it. The program prints a session viewer URL as it starts. Open it in another tab to watch the page load live, and it writes `hackernews.png` to the working directory on the way out.

Your output varies with the site. Structure looks like this:

```text
Creating Steel session...
Session created. Watch it live at https://app.steel.dev/sessions/ab12cd34
Connected to browser via playwright-go
Navigating to Hacker News...

Top 5 Hacker News Stories:

1. A compiler that fits in a tweet
   Link: https://example.com/tiny-compiler
   Points: 521

2. Show HN: I mapped every CDP command to a Go method
   Link: https://news.ycombinator.com/item?id=43990011
   Points: 274

Saved screenshot to hackernews.png
Releasing session...
```

A run costs a few cents of browser time. Steel bills per session-minute, so the deferred `client.Sessions.Release` is not optional. The `defer` sits right after the create call, so the session is released whether `run` returns clean or errors partway through. Drop it and the browser stays up until the default five-minute timeout, on your dime.

## Make it yours

- **Swap the target.** Change the `page.Goto` URL and the `extractTopStories` script. The JSON-string bridge works for any shape: define a matching Go struct and unmarshal. Session setup and cleanup stay identical.
- **Skip the JS.** `page.QuerySelectorAll("tr.athing")` returns `ElementHandle` values with `TextContent` and `GetAttribute`, if you would rather query node by node than evaluate a script. It is more round-trips and easier to debug one selector at a time.
- **Turn on stealth.** `SessionCreateParams` carries `UseProxy`, `SolveCaptcha`, and `Timeout` for sites with anti-bot defenses. Set them on the struct you pass to `Sessions.Create`.
- **Add steps.** Playwright auto-waits on actionability, so `page.Click`, `page.Fill`, and `page.WaitForSelector` are reliable without manual sleeps when you fill forms or paginate before extracting.

## Related

[chromedp](/cookbook/chromedp) and [Rod](/cookbook/rod) are the pure-Go options: both speak CDP directly with no Node driver to install, so compare their `main.go` against this one to decide whether the Playwright API is worth the extra dependency. The [TypeScript](/cookbook/playwright) and [Python](/cookbook/playwright) starters connect to Steel the same way through the official Playwright bindings. See the [playwright-go docs](https://pkg.go.dev/github.com/playwright-community/playwright-go) for the full page, locator, and screenshot API.

## Related recipes

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
- [Automate a cloud browser with headless_chrome](/cookbook/headless-chrome): Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.


# Persist authenticated sessions with Profiles
URL: https://docs.steel.dev/cookbook/profiles


**TypeScript**

A Steel profile is a named, long-lived browser identity. It holds everything a real Chrome user profile accumulates over time: cookies, localStorage, IndexedDB, history, installed extensions, autofill, site permissions. Every session you attach to the profile starts where the last one left off, and writes the user data directory back on release. Profiles are one of Steel's [authentication](/cookbook/topics/authentication) options for starting a browser already signed in.

Two options on `sessions.create` wire it up. On the first run, mint a fresh profile:

```typescript
session = await client.sessions.create({
  persistProfile: true,
  profileId: undefined,
});

const profileId = session.profileId;
```

`persistProfile: true` tells Steel to snapshot the browser data directory when the session ends. `profileId: undefined` means "create a new one." After the session is created, `session.profileId` holds the identifier. Store it. Every later run passes it back:

```typescript
session = await client.sessions.create({
  persistProfile: true,
  profileId,
});
```

The new browser opens as that identity. `client.profiles.list()`, `client.profiles.retrieve(id)`, and `client.profiles.delete(id)` round out the surface.

## How the demo works

`index.ts` uses [demowebshop.tricentis.com](https://demowebshop.tricentis.com), a public shopping cart demo that stores cart state in cookies. The flow lives in `main()` and three helpers in `utils.ts`:

1. `selectOrCreateProfile` calls `client.profiles.list()`, then `inquirer` prompts you to pick an existing profile or create a new one. Returns `undefined` to signal "mint a fresh one."
2. On a fresh run, Session #1 launches with `persistProfile: true` and no `profileId`. `addItemsToCart` visits three category pages (books, digital downloads, notebooks) and clicks the first add-to-cart button on each. Session #1 releases; Steel writes the profile snapshot.
3. Session #2 launches with the same `profileId`. `checkItemsInCart` opens `/cart` and reads the rows. Same identity, different browser, same cart.

Select the saved profile on a later run and the menu skips step 2 entirely. One session spins up, finds the cart intact, and exits. That is the test: state survives across distinct sessions because the profile carries it.

## Run it

```bash
cd examples/profiles-ts
cp .env.example .env          # set STEEL_API_KEY
npm install
npm start
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). Session viewer URLs print as the script runs. Open them in other tabs to watch each browser.

Your first-run output varies. Structure looks like this:

```text
Steel Profiles Demo
============================================================
? Select a profile to use: Create a new profile
Steel Session #1 created!
View session at https://app.steel.dev/sessions/ab12cd34...
Profile ID: prof_9f3c...

Successfully logged in
Added item from Book category
Added item from Digital Download category
Added item from Notebook category

3 items in cart
Items added:
  1. Computing and Internet
  2. Music 2
  3. Fiction

Session #1 released

Steel Session #2 created!
View session at https://app.steel.dev/sessions/ef56gh78...
Found 3 items in cart
Found your shopping cart!
Session released
```

Full round-trip takes ~60 seconds. A second run against the saved profile takes ~30 seconds because step 2 is skipped.

Sessions go through `client.sessions.release()` in the `finally` block. Skipping it keeps browsers running until the 5-minute default timeout and delays the profile snapshot.

## What persists

Profiles capture the full Chromium user data directory, not just cookies:

- Cookies and localStorage for every origin you visited.
- Login sessions you kept alive (bank, SaaS dashboard, email).
- IndexedDB entries for apps that cache state client-side.
- Installed extensions and their configuration.
- Autofill, history, bookmarks, site permissions.

Treat the profile like an account. Anyone who can call `sessions.create({ profileId })` on your workspace can drive a browser logged in as you. Rotate or delete with `client.profiles.delete(id)` when the identity is done.

## Make it yours

- **Swap the target site.** Replace the URLs in `login`, `addItemsToCart`, and `checkItemsInCart`. The profile plumbing does not change.
- **Seed a profile interactively.** Create a session with `persistProfile: true`, open the live viewer, sign in by hand, close the session. The profile keeps the login. Every scripted run after that reuses it.
- **One profile per identity.** If you automate three accounts on the same site, create three profiles. Sharing a profile across accounts means one session's writes overwrite another's state on release.
- **Read without writing back.** Pass `persistProfile: false` with an existing `profileId` to load the profile without snapshotting changes on release. Useful for risky runs that might corrupt state.

## Related

Three recipes handle "start the browser already signed in." Pick by lifetime:

- [credentials](/cookbook/credentials): Steel stores a username and password per origin and fills the login form each session. No browser state persists. Good when the form is standard and you want a stable, long-lived setup.
- [auth-context](/cookbook/auth-context): one-shot JSON snapshot of cookies and localStorage you capture from one session and replay into the next. Good when you log in once (SSO, MFA, magic link) and want to move the resulting state forward.
- Profiles (this recipe): long-lived named identity that accumulates everything (history, extensions, preferences, logins) across runs. Good when the browser itself is the unit of persistence.

[Playwright docs](https://playwright.dev)

**Python**

A Steel profile is a named, long-lived browser identity. It carries everything a real Chrome user data directory accumulates: cookies, localStorage, IndexedDB, history, installed extensions, autofill, site permissions. Attach a session to a profile and the browser opens where the last one left off. Release the session and Steel snapshots the data directory back into the profile.

Two arguments on `client.sessions.create` wire this up. The first run mints a profile by asking for persistence and passing no id:

```python
session = client.sessions.create(persist_profile=True)
profile_id = session.profile_id
```

`persist_profile=True` tells Steel to write the browser data directory back when the session ends. With no `profile_id`, Steel creates a fresh one and returns it on the session object. Store that id. Every later run passes it back:

```python
session = client.sessions.create(persist_profile=True, profile_id=profile_id)
```

The new browser opens as that same identity. `client.profiles.list()`, `client.profiles.retrieve(id)`, and `client.profiles.delete(id)` round out the surface.

## How the demo works

`main.py` runs a straight two-session flow against [demowebshop.tricentis.com](https://demowebshop.tricentis.com), a public shopping cart demo that keeps cart state in cookies:

1. Session #1 launches with `persist_profile=True` and no profile id. `add_first_book_to_cart` opens `/books`, clicks the first add-to-cart button, and waits for `.cart-qty` to move off `(0)`. The session releases and Steel snapshots the profile.
2. Session #2 launches with the captured `profile_id`. `count_cart_rows` opens `/cart` and counts `.cart tbody tr`. A row count above zero means the cart survived a browser that no longer exists, carried forward by the profile.

This is a port of [`../profiles-ts`](/cookbook/profiles), reworked to run end to end without input. The TypeScript version opens an `inquirer` picker to choose an existing profile or mint a new one. This Python version drops the picker and always creates a fresh profile, then reuses it once, so the persistence round-trip happens in a single run.

## Run it

```bash
cd examples/profiles-py
cp .env.example .env          # set STEEL_API_KEY
uv run main.py
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). `uv sync` runs automatically on first `uv run`, so there is no separate install step. The two viewer URLs print as the script runs. Open them in other tabs to watch each browser.

Your output varies. Structure looks like this:

```text
Steel Profiles Demo (Python)
============================================================
Session #1: https://app.steel.dev/sessions/ab12cd34...
Profile ID: prof_9f3c...
Added a book to the cart (cart shows Shopping cart (1))
Session #1 released, snapshotting profile...
Session #2: https://app.steel.dev/sessions/ef56gh78...
Profile ID: prof_9f3c...
Success: cart persisted across sessions with 1 item(s) via the profile
Releasing session...
Session released
Done!
```

Both sessions go through `client.sessions.release()`. Session #1 is released inline so its profile snapshot lands before Session #2 opens; Session #2 is released in the `finally` block. Skipping release keeps browsers running until the default timeout and delays the snapshot.

## What persists

Profiles capture the full Chromium user data directory, not just the cart cookie this demo touches:

- Cookies and localStorage for every origin you visited.
- Login sessions you kept alive (bank, SaaS dashboard, email).
- IndexedDB entries for apps that cache state client-side.
- Installed extensions and their configuration.
- Autofill, history, bookmarks, site permissions.

Treat a profile like an account. Anyone who can call `client.sessions.create(profile_id=...)` on your workspace can drive a browser logged in as you. Delete one with `client.profiles.delete(id)` when the identity is done.

## Make it yours

- **Swap the target site.** Replace the URLs in `add_first_book_to_cart` and `count_cart_rows`. The profile plumbing does not change.
- **Seed a profile by hand.** Create a session with `persist_profile=True`, open the live viewer, sign in yourself, then release. The profile keeps the login, and every scripted run after that reuses it.
- **One profile per identity.** Automating three accounts on the same site means three profiles. Sharing one across accounts lets a later session's snapshot overwrite an earlier one's state.
- **Read without writing back.** Pass `persist_profile=False` with an existing `profile_id` to load a profile without snapshotting changes on release. Useful for risky runs that might corrupt state.

## Related

Three recipes solve "start the browser already signed in." Pick by lifetime:

- [credentials-py](/cookbook/credentials): Steel stores a username and password per origin and fills the login form each session. No browser state persists.
- [auth-context-py](/cookbook/auth-context): a one-shot JSON snapshot of cookies and localStorage captured from one session and replayed into the next.
- Profiles (this recipe): a long-lived named identity that accumulates everything across runs.

Other ports of this recipe: [profiles-ts](/cookbook/profiles) (interactive picker), [profiles-go](/cookbook/profiles), [profiles-rs](/cookbook/profiles). See the [Playwright docs](https://playwright.dev/python/) for the Python browser API.

**Rust**

A Steel profile is a named, long-lived browser identity: the full Chromium user data directory (cookies, localStorage, IndexedDB, history, extensions, autofill, permissions) snapshotted on release and reloaded on the next attach. Two fields on `SessionCreateParams` drive it. `persist_profile: Some(true)` tells Steel to write the data directory back when the session ends. `profile_id` selects which identity to load: leave it `None` to mint a fresh one, or pass a captured id to resume.

The whole demo turns on one value moving between two `create` calls:

```rust
let session = client
    .sessions()
    .create(SessionCreateParams {
        persist_profile: Some(true),
        ..Default::default()
    })
    .await?;

let profile_id = session.profile_id.clone().ok_or("no profile_id")?;
```

`SessionCreateParams` derives `Default`, so struct-update syntax sets only the two profile fields and leaves the rest at their server defaults. The first session returns a `profile_id`; the second passes it back with `profile_id: Some(profile_id.clone())` and the same `persist_profile: Some(true)`. Same identity, a brand new browser.

## What the demo does

This recipe is non-interactive. The TypeScript sibling prompts you to pick a profile with `inquirer`; here both sessions run end to end with no input, so a single `cargo run` proves the round trip. It drives [demowebshop.tricentis.com](https://demowebshop.tricentis.com), a public shopping cart demo that keeps cart state in the browser, over CDP with chromiumoxide:

1. Create session #1 with `persist_profile: Some(true)`, capture `session.profile_id`, connect, open `/books`, and click the first add-to-cart button (`.product-box-add-to-cart-button`, falling back to `input[value="Add to cart"]`). Waiting for `.cart-qty` to appear confirms the click landed.
2. Release session #1 so Steel writes the profile snapshot, then sleep ~3 seconds to let the write settle.
3. Create session #2 with the same `persist_profile: Some(true)` and the captured `profile_id`, connect, open `/cart`, and count `.cart tbody tr` rows with a one-line `page.evaluate`. More than zero rows means the cart crossed the session boundary.

Each chromiumoxide connection spawns a handler task (`tokio::spawn`) to pump CDP events; `handle.abort()` stops it before the session is released.

## Run it

```bash
cd examples/profiles-rs
cp .env.example .env          # set STEEL_API_KEY
cargo run
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). Both session viewer URLs print as the run proceeds. Open them in other tabs to watch each browser.

```text
Creating Steel session #1 with a fresh persisted profile...
Profile ID: prof_9f3c...
Session #1 live at https://app.steel.dev/sessions/ab12cd34...
Added the first book to the cart
Session #1 released

Creating Steel session #2 from profile prof_9f3c...
Session #2 live at https://app.steel.dev/sessions/ef56gh78...
Found 1 item(s) in the cart
Session #2 released

Profile persistence confirmed: the cart survived across sessions
```

A full round trip takes ~30 seconds. Both sessions go through `client.sessions().release(...)` before the program exits; skip it and the browsers idle until the 5-minute default timeout, which also delays the profile snapshot.

## Make it yours

- **Swap the target.** Change `BOOKS_URL`, `CART_URL`, and the selectors. The two-`create` profile plumbing stays the same for any site whose state lives in the browser.
- **Resume an existing profile.** Skip session #1 and start at session #2 with a `profile_id` you saved earlier. Seed it once by hand: create a session with `persist_profile: Some(true)`, sign in through the live viewer, release, and reuse the id forever.
- **Read without writing back.** Pass `persist_profile: Some(false)` with an existing `profile_id` to load the identity without snapshotting changes on release. Good for risky runs that might corrupt state.
- **Manage the identity.** `client.profiles().list()`, `retrieve`, and `delete` round out the surface. Treat a profile like an account: anyone who can call `create` with its id drives a browser logged in as you.

## Related

[profiles-ts](/cookbook/profiles) · [profiles-py](/cookbook/profiles) · [profiles-go](/cookbook/profiles) · [auth-context-rs](/cookbook/auth-context) · [chromiumoxide](https://github.com/mattsse/chromiumoxide)

**Go**

A Steel profile is a named, long-lived browser identity. It carries everything a real Chrome user profile accumulates: cookies, localStorage, IndexedDB, history, installed extensions, autofill, site permissions. Attach a session to a profile and the browser opens where the last one left off; on release, Steel writes the user data directory back to the profile.

Two fields on `Sessions.Create` wire it up, both through the `steel.F(...)` field wrapper. To mint a fresh profile, pass `PersistProfile: steel.F(true)` and leave `ProfileID` unset. The created `*steel.Session` exposes `.ProfileID`. Store it. Every later run passes it back as `ProfileID: steel.F(profileID)` alongside `PersistProfile`, and the browser opens as that identity.

## Non-interactive by design

The TypeScript sibling opens an `inquirer` menu so you can pick an existing profile or create a new one. This Go port drops the picker and runs the full round-trip end to end in one invocation: `seedCart` mints a profile and adds an item, the program sleeps about three seconds so the snapshot settles, then `verifyCart` opens a second session from the same `ProfileID` and counts the cart rows. Nothing to click. To reuse a profile from a previous run, read the printed `Profile ID` and feed it into `Sessions.Create` yourself.

chromedp talks CDP directly: `chromedp.Evaluate` runs the cart logic in the page (`document.querySelector(".product-box-add-to-cart-button")` with an `input[value='Add to cart']` fallback, then `.cart-qty` for the header count and `.cart tbody tr` for the row count). `chromedp.NewRemoteAllocator` with `chromedp.NoModifyURL` attaches to the Steel browser over the websocket URL, the same idiom as the [chromedp](/cookbook/chromedp) recipe.

## Run it

```bash
cd examples/profiles-go
cp .env.example .env          # set STEEL_API_KEY
go mod tidy
go run .
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). Both session viewer URLs print as the program runs; open them in other tabs to watch each browser.

```text
Steel Profiles Demo
============================================================

Session #1 created with a fresh profile.
View live at https://app.steel.dev/sessions/ab12cd34...
Profile ID: prof_9f3c...
Adding the first book to the cart...
Added item. Header cart count now reads "(1)".
Releasing session #1...

Waiting for the profile snapshot to settle...

Session #2 created from profile prof_9f3c...
View live at https://app.steel.dev/sessions/ef56gh78...
Opening the cart in the new browser...
Releasing session #2...

------------------------------------------------------------
Profile ID: prof_9f3c...
Session #1 viewer: https://app.steel.dev/sessions/ab12cd34...
Session #2 viewer: https://app.steel.dev/sessions/ef56gh78...
Found 1 item(s) in the cart. Profile persistence works.
```

Both sessions release through the `release` helper deferred right after each `Sessions.Create`. Skipping release keeps browsers running until the default timeout and delays the profile snapshot.

## Make it yours

- **Swap the target site.** Replace `booksURL`, `cartURL`, and the three `Evaluate` snippets. The profile plumbing does not change.
- **Add more items.** Loop the click snippet over several category pages before releasing session #1, and the whole cart rides the profile forward.
- **Seed a profile by hand.** Create one session with `PersistProfile: steel.F(true)`, open its live viewer, sign in manually, release. The login lives in the profile, and every scripted run after that reuses it via `ProfileID`.
- **Read without writing back.** Pass `PersistProfile: steel.F(false)` with an existing `ProfileID` to load the profile without snapshotting changes on release. Useful for risky runs that might corrupt state.

## Related

Three recipes handle "start the browser already signed in." Pick by lifetime:

- [auth-context](/cookbook/auth-context): one-shot JSON snapshot of cookies and localStorage you capture from one session and replay into the next. Good when you log in once (SSO, MFA, magic link) and want to move that state forward.
- Profiles (this recipe): long-lived named identity that accumulates everything (history, extensions, preferences, logins) across runs. Good when the browser itself is the unit of persistence.
- Sibling ports: [profiles-ts](/cookbook/profiles), [profiles-py](/cookbook/profiles), [profiles-rs](/cookbook/profiles).

[chromedp docs](https://pkg.go.dev/github.com/chromedp/chromedp)

## Related recipes

- [Reuse authenticated sessions across browsers](/cookbook/auth-context): Maintain authenticated sessions across Steel browser instances by capturing and reusing cookies and local storage.
- [Automate logins with the Credentials API](/cookbook/credentials): Use the Steel Credentials API with Playwright to automate flows with stored credentials.
- [Scrape a page to Markdown, screenshot, and PDF](/cookbook/scrape): Use the Steel TypeScript SDK's direct API to scrape a page to clean Markdown for LLM context, plus screenshot and PDF, with no browser library.


# Automate a cloud browser with Puppeteer
URL: https://docs.steel.dev/cookbook/puppeteer


Puppeteer ships a `connect()` call that attaches to any Chrome exposing a DevTools websocket. Steel sessions expose one. Point Puppeteer at it and the rest of the script is plain Puppeteer: `page.goto`, `page.evaluate`, `page.waitForSelector`, the whole surface area. Stealth, proxies, and the live session viewer come from Steel without extra wiring.

```typescript
session = await client.sessions.create();

browser = await puppeteer.connect({
  browserWSEndpoint: `${session.websocketUrl}&apiKey=${STEEL_API_KEY}`,
});

const page = await browser.newPage();
```

Two notes on the shape here. First, the package is `puppeteer-core`, not `puppeteer`. There's no Chromium to download because the browser lives on Steel. Second, `browser.newPage()` opens a fresh tab in the Steel session; the session viewer starts blank until you navigate.

## Run it

```bash
cd examples/puppeteer-ts
cp .env.example .env          # set STEEL_API_KEY
npm install
npm start
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The script prints a session viewer URL as it starts. Open it in another tab to watch the browser run live.

Your output varies. Structure looks like this:

```text
Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34…

Connected to browser via Puppeteer
Navigating to Hacker News...

Top 5 Hacker News Stories:

1. Claude 4.7 Opus released today
   Link: https://news.ycombinator.com/item?id=43218921
   Points: 892

2. Show HN: A browser extension for reading on slow connections
   Link: https://github.com/user/project
   Points: 401

…

Releasing session...
Session released
Done!
```

A run costs a few cents of browser time. Steel bills per session-minute, so the `finally` block that calls `client.sessions.release()` isn't optional. Forgetting it keeps the browser running until the default 5-minute timeout.

## Make it yours

- **Swap the target.** Replace the `page.goto` URL and the `page.evaluate` body in `main()`. Session setup, connect, and cleanup stay the same.
- **Turn on stealth.** Uncomment `useProxy`, `solveCaptcha`, or `sessionTimeout` in the `sessions.create()` call for sites with anti-bot.
- **Persist login.** Reuse cookies and local storage across runs via [credentials](/cookbook/credentials).

## Related

[Puppeteer docs](https://pptr.dev)

## Related recipes

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
- [Automate a cloud browser with headless_chrome](/cookbook/headless-chrome): Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.


# Build a typed browser agent with Pydantic AI
URL: https://docs.steel.dev/cookbook/pydantic-ai


[Pydantic AI](https://ai.pydantic.dev/) is the Pydantic team's agent framework. It's provider-agnostic and reuses Pydantic models for tool arguments and final outputs. Steel's [Pydantic AI integration](/integrations/pydantic-ai) covers the same setup on its own.

This starter wires a Steel browser into Pydantic AI's dependency-injection pattern and points the [agent](/cookbook/topics/agents) at GitHub Trending.

```python
from pydantic_ai import Agent, RunContext

agent = Agent(
    "openai:gpt-5-mini",
    deps_type=BrowserDeps,
    output_type=FinalReport,
    tools=[navigate, snapshot, extract],
    instructions="You operate a Steel cloud browser via tools. ...",
)

result = await agent.run(
    "Go to https://github.com/trending/python ...",
    deps=BrowserDeps(page=page),
)
final: FinalReport = result.output
```

`agent.run` runs the model loop until the agent returns a `FinalReport` (or an exception unwinds it). `result.output` is typed because `output_type=FinalReport` ties the final turn to the schema. Validation failures are fed back to the model so it corrects itself.

`deps_type=BrowserDeps` takes a single dependencies object per run and passes it to every tool through `RunContext.deps`. Tools are plain async functions that take `RunContext[BrowserDeps]` first:

```python
@dataclass
class BrowserDeps:
    page: Page

async def navigate(ctx: RunContext[BrowserDeps], url: str) -> dict:
    """Navigate the open session to a URL and wait for the page to load."""
    await ctx.deps.page.goto(url, wait_until="domcontentloaded", timeout=45_000)
    return {"url": ctx.deps.page.url, "title": await ctx.deps.page.title()}
```

## Run it

```bash
cd examples/pydantic-ai
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
uv run playwright install chromium
uv run main.py
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). Each tool call prints its latency so you can see where time is going.

Your output varies. Structure looks like this:

```text
Steel + Pydantic AI Starter
============================================================
Session: https://app.steel.dev/sessions/ab12cd34...
    navigate: 1612ms
    snapshot: 487ms (3821 chars, 48 links)
    extract: 394ms (3 rows)

Agent finished.

{
  "summary": "Three trending Python repos focused on agentic workflows...",
  "repos": [
    {
      "name": "owner/repo",
      "url": "https://github.com/owner/repo",
      "stars": "1,240",
      "description": "..."
    },
    ...
  ]
}

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A run takes ~20 to 40 seconds and 5 to 10 agent turns on GitHub Trending. Cost is a few cents of Steel session time plus OpenAI tokens. The `finally` block in `main` closes Playwright and calls `steel.sessions.release()` so Steel stops billing per-minute.

## Make it yours

- **Swap the model.** Change the first arg to `agent`. `'anthropic:claude-sonnet-4-6'` and `'google-gla:gemini-2.5-flash'` work without code changes; tool-arg JSON schemas are provider-agnostic. Set the matching API key in `.env`.
- **Swap the task.** Change the prompt in `agent.run` and the `FinalReport` schema. Tools stay the same; the agent re-plans against the new shape.
- **Add a tool.** Write an async function that takes `RunContext[BrowserDeps]`, add it to `tools=[...]` (or use `@agent.tool` after the agent exists). A useful fourth tool is `click(selector: str)` that calls `page.click` and waits for navigation.
- **Stream the answer.** Use `async with agent.run_stream(prompt, deps=...)` to stream the final answer token-by-token while tool calls happen behind the scenes. Helpful for long summaries.
- **Run agents in parallel.** Construct a session+page per task and `asyncio.gather(agent.run(...))` over them. Each run sees its own `deps`; nothing is shared by accident.
- **Watch with Logfire.** Pydantic AI integrates with [Logfire](https://logfire.pydantic.dev/) for traces of every turn, tool call, and token count.

## Related

[Steel + OpenAI Agents SDK (Python)](/cookbook/openai-agents) · [Pydantic AI documentation](https://ai.pydantic.dev/)

## Related recipes

- [Build a browser agent with Google ADK](/cookbook/google-adk): Use Steel with Google's Agent Development Kit (ADK) for Go to build a tool-using browser agent that drives a chromedp session over CDP and reads Hacker News.
- [Build a typed browser agent with LangGraph](/cookbook/langgraph): Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node.
- [Build a typed browser agent with Mastra](/cookbook/mastra): Use Steel with Mastra to build a typed browser agent with the Mastra Model Router and Studio playground.


# Run a durable browser agent with Restate
URL: https://docs.steel.dev/cookbook/restate-agent


**TypeScript**

This recipe runs a [Restate](/cookbook/topics/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.

**Python**

`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/).

**Rust**

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.

**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).

## Related recipes

- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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](/cookbook/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.


# Build a browser agent with rig
URL: https://docs.steel.dev/cookbook/rig


[rig](https://docs.rs/rig-core) is a Rust framework for LLM applications: you define tools as trait impls, hand them to an `Agent`, and call `prompt`, which loops the model over those tools until it produces an answer. This recipe gives the [agent](/cookbook/topics/agents) two tools backed by a real Chrome running in the cloud through Steel, driven over CDP with [chromiumoxide](https://docs.rs/chromiumoxide). The model navigates and reads the live DOM itself instead of receiving pre-scraped text, so it can follow links and work on pages that only exist after JavaScript runs.

Each tool is a struct that owns a `chromiumoxide::Page` and implements rig's `Tool` trait:

```rust
struct Navigate { page: chromiumoxide::Page }

impl Tool for Navigate {
    const NAME: &'static str = "navigate";
    type Error = ToolError;
    type Args = NavigateArgs;     // { url: String }, Deserialize
    type Output = NavigateOutput; // { url, title }, Serialize

    async fn definition(&self, _prompt: String) -> ToolDefinition {
        ToolDefinition { name: Self::NAME.to_string(), description: "...", parameters: json!({ ... }) }
    }

    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
        self.page.goto(args.url).await.map_err(|e| ToolError(e.to_string()))?;
        self.page.wait_for_navigation().await.map_err(|e| ToolError(e.to_string()))?;
        // ... return the resolved url and page title
    }
}
```

`definition` is the JSON Schema Claude sees; `call` is what runs when the model picks the tool. rig deserializes `Args` from the model's arguments and serializes `Output` back into the transcript, so those two types are the whole contract. `ExtractText` is the second tool: it runs `document.body.innerText` and a `querySelectorAll('a[href]')` snippet through `page.evaluate(...).into_value()`, returning capped body text plus up to 50 links so the model reads real anchors instead of guessing selectors.

Wiring the agent is one builder chain:

```rust
let agent = anthropic::Client::new(&anthropic_api_key)?
    .agent("claude-sonnet-4-6")
    .preamble(SYSTEM_PROMPT)
    .max_tokens(2048)
    .tool(Navigate { page: page.clone() })
    .tool(ExtractText { page })
    .build();

let answer = agent.prompt(TASK).max_turns(8).await?;
```

Both tools hold the same page. `page.clone()` is a cheap handle to the one open tab, so `navigate` and `extract_text` act on the same browser rather than spawning new ones. `prompt(...).max_turns(8)` is what makes this an agent and not a single call: rig feeds each tool result back to the model and re-prompts up to eight times, so Claude navigates, reads, then answers inside one `await`. The `8` is also the safety cap that stops a confused model from looping forever.

## The handler you must not forget

```rust
let (mut browser, mut handler) = Browser::connect(cdp_url).await?;
let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} });
```

`Browser::connect` returns a `Browser` and a `handler` stream. The `Browser` only sends CDP commands; the `handler` is what pumps responses and events back off the WebSocket. If you never poll it, every `goto` and `evaluate` hangs forever with no error and no panic. Spawning a task that drives `handler` to exhaustion is mandatory, and it is the one thing people miss with chromiumoxide. On the way out, release the Steel session, call `browser.close()`, then `handler_task.abort()`, in that order.

## Run it

```bash
cd examples/rig
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
cargo run
```

Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and an Anthropic key at [console.anthropic.com](https://console.anthropic.com/settings/keys). Both keys are read from the environment; the Steel key is also passed to `Steel::new` explicitly so the same value signs the CDP WebSocket URL.

The run is quiet until the answer lands, since the agent loops without streaming its intermediate turns. Your output varies. Structure looks like this:

```text
Session: https://app.steel.dev/sessions/3f2a...

Releasing Steel session...

Top 3 Hacker News stories right now:
1. "Show HN: ..." (642 points) https://news.ycombinator.com/item?id=...
2. "..." (511 points) https://...
3. "..." (388 points) https://...
```

A run costs a few cents of browser time plus the Anthropic tokens for up to eight turns. Because this drives a real session (`sessions().create`), Steel bills per session-minute until the `release` call, so the cleanup in `main` is not optional.

## Make it yours

- **Swap the task.** Change `TASK` and the preamble in `main.rs`. The tools stay the same; the agent re-plans against the new goal.
- **Add a tool.** A `click` tool (`page.find_element(...).click()`) or a `screenshot` tool (`page.screenshot(...)`) drops in as another `impl Tool` and one more `.tool(...)` call. The model picks per turn.
- **Tune the reach.** Raise `max_turns` to let it crawl deeper, or lower the link cap and `max_chars` in `extract_text` to spend fewer tokens per read.
- **Change the model.** Any Anthropic model id works in `.agent(...)`. rig also ships OpenAI, Gemini, and other providers; swap the `anthropic::Client` for one of those and the tools are unaffected.

## Related

[Steel + Swiftide (Rust)](/cookbook/swiftide) is the other Rust agent recipe. It reads pages through Steel's `scrape` endpoint instead of driving a browser, so compare the two when you choose between live DOM access and clean Markdown. [chromiumoxide](/cookbook/chromiumoxide) is the same CDP browser without the agent layer. [genkit](/cookbook/genkit) and [pydantic-ai](/cookbook/pydantic-ai) are the tool-calling-agent shape in other languages. The [rig docs](https://docs.rs/rig-core) cover the `Tool` trait, multi-turn prompting, and the provider list.

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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.


# Automate a cloud browser with Rod
URL: https://docs.steel.dev/cookbook/rod


Rod talks the Chrome DevTools Protocol directly and exposes it through a chainable, panic-on-error API. A Steel session is a Chrome instance reachable over a websocket, so `ControlURL` is the only seam you need: hand Rod the session's websocket URL with your key appended, and the rest of your code is ordinary Rod against a browser that runs in Steel's cloud with stealth, proxies, and a live viewer. Nothing about the queries below knows or cares that the browser is remote. It is the same connect-over-CDP [browser automation](/cookbook/topics/browser-automation) the rest of the cookbook uses.

```go
cdpURL := fmt.Sprintf("%s&apiKey=%s", session.WebsocketURL, apiKey)
browser := rod.New().ControlURL(cdpURL).MustConnect()
defer browser.MustClose()

page := browser.MustPage("https://quotes.toscrape.com").MustWaitStable()
```

`rod.New()` returns a `*Browser` you keep configuring by chaining. `ControlURL` points it at the remote Chrome instead of launching a local one, and `MustConnect` attaches over CDP. There is no `NewContext` or `NewPage` ceremony: `MustPage` opens a tab and returns a `*Page` you query straight away.

## The connect URL

`session.WebsocketURL` already carries Steel's session identifier. The one thing you add is your API key as a query parameter, which is why the code formats `%s&apiKey=%s` rather than passing the URL through untouched. Rod connects to exactly the URL you give it and does not rewrite the address, so the key has to be in the string before `ControlURL` sees it. If you forget it, the websocket handshake is rejected and `MustConnect` panics before the first page loads.

The session itself comes from the Steel SDK. `client.Sessions.Create` returns a `*Session` whose `WebsocketURL`, `SessionViewerURL`, and `ID` fields drive the rest of the program: the websocket URL to connect, the viewer URL to print, and the ID to release at the end.

```go
session, err := client.Sessions.Create(ctx, steel.SessionCreateParams{
    Dimensions: &steel.SessionCreateParamsDimensions{Width: 1280, Height: 800},
})
```

Every field on `SessionCreateParams` is a pointer, so an omitted field is a real "unset" rather than a zero value the API has to guess about. The `ptr` helper at the top of `main.go` is a one-line generic that wraps a literal in a pointer, which is what lets you write `Dimensions` inline and, later, flags like `SolveCaptcha: ptr(true)`.

The one field worth setting deliberately on a longer job is `Timeout`. It is the hard cap on session lifetime in milliseconds and defaults to 300000, five minutes. A scrape that needs longer has to raise it at creation time, because there is no way to extend a session once it is live: when the timeout elapses, Steel releases the browser out from under you and the next Rod call fails. For the quick scrape here the default is plenty, and the deferred `Release` ends the session in well under a second anyway.

## The Must idiom

The `Must` prefix is the whole style. `MustElement`, `MustText`, and `MustElements` panic instead of returning a `(value, error)` pair, which keeps a scrape readable as a straight line of selectors rather than an error check after every call. The trade is that a missing selector aborts the program, so the cleanup that releases the session has to run no matter how the scrape exits. That is what the two deferred calls in `main` are for: one closes the CDP connection, the other ends the Steel session.

`main.go` loads `quotes.toscrape.com` and pulls the first five quote cards off the page. For each `.quote` block it reads the quote text, the author, and the tag list:

```go
cards := page.MustElements(".quote")
for i, card := range cards {
    text := strings.Trim(card.MustElement(".text").MustText(), "“”\"")
    author := card.MustElement(".author").MustText()
    tags := card.MustElements(".tag")
    // ...
}
```

`MustElements` returns `rod.Elements`, which is a `[]*Element`, so you range over it like any slice. Scoping the next query to `card` (calling `MustElement` on the element, not the page) is how Rod expresses "find this inside that": each `.text` and `.author` lookup is relative to its own card, not the whole document. After the loop, `MustScreenshot("quotes.png")` writes a PNG of the rendered page to disk.

The screenshot is captured on the remote browser and streamed back as bytes, so the PNG lands on your machine even though Chrome never ran locally. The same is true of `MustHTML` and `page.MustEval` for JavaScript: Rod issues the CDP command, Steel runs it in the cloud, and you get the result. This is the reason a scrape needs no local Chrome and no driver binary on your path.

## Watch it run

The program prints `session.SessionViewerURL` right after `Create`. Opening that link shows the live browser: the page navigating, the DOM settling, and the screenshot firing, all in real time. It is the fastest way to debug a selector that is not matching, because you can see the actual rendered page rather than guessing from a panic message. The viewer also keeps showing the last frame after the session ends, so a run that failed mid-scrape still leaves you something to inspect.

## Run it

```bash
cd examples/rod
cp .env.example .env          # set STEEL_API_KEY
go mod tidy
go run .
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The program prints a session viewer URL as it starts. Open it in another tab to watch the page load and the screenshot get taken in real time.

Your output varies with the site. Structure looks like this:

```text
Creating Steel session...
Session live at https://app.steel.dev/sessions/ab12cd34...

Connected to browser via Rod
Scraping quotes.toscrape.com...

Found 10 quotes on the page:

1. The world as we have created it is a process of our thinking.
   - Albert Einstein
   tags: change, deep-thoughts, thinking, world

2. It is our choices, Harry, that show what we truly are.
   - J.K. Rowling
   tags: abilities, choices

...

Saved screenshot to quotes.png

Releasing session...
Session released
Done!
```

A run takes a few seconds and costs a few cents of browser time. Steel bills per session-minute, so the `defer client.Sessions.Release(...)` call is not optional: skip it and the browser stays live until the default five-minute timeout, billing the whole time. `browser.MustClose()` closes the CDP connection; `Release` ends the Steel session. You want both, and you want them deferred so a panic from a `Must` call still triggers them on the way out.

## Make it yours

- **Swap the target.** Change the `MustPage` URL and the selectors in the loop. The `quotes.toscrape.com` site paginates with a `.next > a` link, so you can follow it in a loop and scrape every page instead of one. Session setup and cleanup stay the same.
- **Wait on real readiness.** `MustWaitStable` blocks until the DOM stops changing, which suits server-rendered pages. For a site that loads content with JavaScript after first paint, wait on the element you actually need with `page.MustElement(sel)`, which polls until it appears instead of guessing at a fixed delay.
- **Turn on stealth.** `SessionCreateParams` accepts `SolveCaptcha`, `UseProxy`, and `Timeout` for sites with anti-bot defenses. Each is a pointer, so set them through the `ptr` helper: `SolveCaptcha: ptr(true)`.
- **Survive missing elements.** The `Must` methods are convenient for a script. For a long-running job, use the non-`Must` variants (`page.Element` returns `(*rod.Element, error)`) or wrap the risky section in `rod.Try`, which converts a panic into an error you can inspect and recover from rather than crashing the process.

## Related

[chromedp version](/cookbook/chromedp) drives the same kind of Steel session with a different Go library: chromedp batches actions into a single `Run` call rather than chaining element handles, so comparing the two `main.go` files is a quick way to decide which style fits your code. See the [Rod documentation](https://go-rod.github.io) for the full selector, input, and waiting API, and the [Playwright starter](/cookbook/playwright) for the same connect-over-CDP idea in TypeScript.

## Related recipes

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
- [Automate a cloud browser with headless_chrome](/cookbook/headless-chrome): Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.


# Scrape JavaScript-Rendered Pages to Markdown
URL: https://docs.steel.dev/cookbook/scrape


**TypeScript**

`client.scrape()` takes a URL and returns the page already converted to Markdown. That matters because Markdown is the format large language models read best: headings, lists, and links survive, while the script tags, tracking pixels, and nav chrome that bloat a raw HTML dump are gone. You get a string you can drop straight into a prompt, with no headless Chrome on your machine and no DOM parsing in your code.

```typescript
const scraped = await client.scrape({
  url: TARGET_URL,
  format: ["markdown"],
});

const markdown = scraped.content.markdown ?? "";
```

`scrape()` runs the fetch and the cleanup on Steel's side, so there is no session to create, connect to, or release. One HTTP call in, structured content out. `scrape` is one of the direct [Steel API](/cookbook/topics/steel-apis) endpoints, with no browser session to manage. The same `client.screenshot()` and `client.pdf()` calls render the same page two other ways.

## Markdown for model context

The reason to reach for `scrape()` over a browser library is the format. A raw page is mostly markup a model has to wade through: a single news article can be tens of thousands of tokens of `<div>` soup before the first sentence. Markdown collapses that to the text, the structure, and the links, so you spend tokens on content instead of tags. The wiring is small once you have the string:

```typescript
const { content, metadata } = await client.scrape({
  url: TARGET_URL,
  format: ["markdown"],
});

const answer = await llm.chat({
  messages: [
    { role: "system", content: "Answer using only the page below." },
    { role: "user", content: `# ${metadata.title}\n\n${content.markdown}` },
  ],
});
```

That is the whole integration: scrape to Markdown, prepend the title, hand it to a model. No selectors, no `page.evaluate`, no waiting on a DOM you do not control.

One failure mode to plan for: a heavily client-rendered page can return near-empty Markdown if the content paints after the initial load. When `content.markdown` comes back short for a site you know is rich, add `delay` (milliseconds) to the `scrape()` call so the page settles before capture. Check `metadata.statusCode` too. A scrape of a 403 or a soft-blocked page still succeeds at the HTTP level but hands you the block page's text, not the content you wanted.

## What you get back

`format` is an array, so you can ask for more than one representation in a single call: `["markdown", "html", "cleaned_html", "readability"]`. Each lands under `content` on the response (`content.markdown`, `content.html`, and so on), and the field is undefined when you did not request that format, which is why the example reads `content.markdown ?? ""`.

The response carries more than the body. `scraped.metadata` holds the page `title`, `description`, `statusCode`, Open Graph tags, and the canonical URL. `scraped.links` is a flat array of `{ text, url }` for every link on the page, handy when you want an LLM to pick a next page to visit. The example prints the status code, title, link count, and the first 500 characters of Markdown so you can see the shape without dumping a whole article to the terminal.

`screenshot()` and `pdf()` differ from `scrape()` in one way worth knowing up front: they return a hosted URL, not bytes. `shot.url` and `pdf.url` point at the rendered artifact on Steel's storage, so the example logs the links rather than writing files. If you want the bytes on disk, fetch the URL yourself. The Python sibling does exactly that.

## Run it

```bash
cd examples/scrape-ts
cp .env.example .env          # set STEEL_API_KEY
npm install
npm start
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). `TARGET_URL` in `.env` is optional and defaults to Hacker News.

Your output varies. Structure looks like this:

```text
Steel Scrape API (TypeScript)
============================================================

Scraping https://news.ycombinator.com to markdown...
HTTP 200 | Hacker News
Links found: 174
Markdown length: 6841 characters

--- Markdown preview (first 500 chars) ---
# Hacker News

* [new](newest)
* [past](front)
* [comments](newcomments)
* [ask](ask)
* [show](show)
...
--- end preview ---

Capturing a full-page screenshot...
Screenshot hosted at: https://steel-screenshots.s3.amazonaws.com/...

Rendering the page to PDF...
PDF hosted at: https://steel-screenshots.s3.amazonaws.com/...

Done. Feed the markdown straight into an LLM prompt.
```

Each of the three calls is one billed request against Steel, so a full run costs a few cents of browser time. There is no session left open to leak: `scrape()`, `screenshot()`, and `pdf()` each return when the work is finished, so unlike the browser-driving recipes there is no `release()` to forget.

## Make it yours

- **Pipe Markdown into a model.** Pass `markdown` as the user message to your LLM of choice and ask it to summarize the page or pull out structured fields. This is the whole reason to scrape to Markdown instead of HTML.
- **Ask for several formats at once.** Set `format: ["markdown", "html"]` when you want the clean text for the model and the raw HTML for a fallback parser, both from a single request.
- **Bundle artifacts into the scrape.** Instead of separate `screenshot()` and `pdf()` calls, pass `screenshot: true` and `pdf: true` to `scrape()`. The URLs come back on `scraped.screenshot` and `scraped.pdf`, which is one billed request instead of three.
- **Get past anti-bot pages.** Add `useProxy: true` to route through Steel's residential proxies, or `delay: 3000` to wait for client-side rendering before the capture.
- **Pick a region.** `region` accepts values like `"iad"` or `"fra"` to run the fetch closer to the target or to your users.

## Related

[Python version](/cookbook/scrape) renders the same endpoints and writes the screenshot and PDF to disk as files. [Rust version](/cookbook/scrape) is the lowest-friction way into the Rust SDK. For a recipe that drives a real browser instead of the direct API, see [playwright-ts](/cookbook/playwright). Full method and parameter reference lives in the [steel-sdk package](https://www.npmjs.com/package/steel-sdk).

**Python**

Steel's `/v1/scrape` endpoint runs a browser server-side and hands back the rendered page. There is no session to create, no CDP socket to attach to, and no browser library on your machine. You call one method, and you get the page content, plus an optional screenshot and PDF. This recipe turns that single call into three files on disk: `page.md`, `screenshot.png`, and `page.pdf`.

```python
result = client.scrape(
    url=TARGET_URL,
    format=["markdown"],
    screenshot=True,
    pdf=True,
)
```

The one detail worth internalizing: the response mixes inline data and hosted artifacts. `result.content.markdown` is a string you can write straight to a file. But `result.screenshot.url` and `result.pdf.url` are **hosted URLs**, not bytes. Steel renders the image and PDF, stores them, and returns links. So the recipe writes the markdown directly, then fetches the two URLs with `urllib` and saves the bytes. The `download` helper does the fetch; `main` wires the three writes.

Because there is no session object, there is no teardown. `client.sessions.release(...)` does not apply here. You pay for the render, the response comes back, and you are done. That makes scrape the lowest-friction way to pull a page into an agent's context: one call, structured output, no lifecycle to manage.

## Run it

```bash
cd examples/scrape-py
cp .env.example .env          # set STEEL_API_KEY
uv run main.py
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). `uv sync` runs automatically on first `uv run`, so there is no separate install step.

Your output varies. Structure looks like this:

```text
Steel Scrape API (Python)
============================================================
Scraping https://news.ycombinator.com ...
Fetched "Hacker News" (HTTP 200)
Markdown: 8421 chars, 147 links
Saved page.md (8421 chars)
Saved screenshot.png (184320 bytes)
Saved page.pdf (96774 bytes)

Artifacts written to /path/to/examples/scrape-py/output
Done!
```

The three files land in `output/` next to `main.py`. Open `page.md` to see the markdown an LLM would read, `screenshot.png` for the rendered viewport, and `page.pdf` for a print-layout capture.

A scrape costs a few cents of browser time. You are billed per render, not per minute, so a one-shot scrape is cheaper than spinning up a full session for the same page. If you only need text, drop `screenshot=True` and `pdf=True` and you skip the render-and-host work for the artifacts you are not using.

## Make it yours

- **Change the target.** Set `TARGET_URL` in `.env`, or edit the default in `main.py`. Everything downstream is the same.
- **Pick your formats.** `format` accepts any of `markdown`, `html`, `cleaned_html`, and `readability`. Pass a list to get several at once, then read them off `result.content` (`result.content.html`, `result.content.cleaned_html`, and so on). `cleaned_html` strips scripts and boilerplate; `readability` returns article-extracted structure.
- **Mine the metadata.** `result.metadata` carries `title`, `description`, `status_code`, Open Graph fields (`og_title`, `og_image`), `canonical`, `author`, and `json_ld`. `result.links` is a list of `{text, url}` for every link on the page, which is a ready-made frontier for a crawler.
- **Get the artifacts without the markdown.** `client.screenshot(url=..., full_page=True)` and `client.pdf(url=...)` are standalone calls that each return a single hosted URL. Use them when you want a capture and nothing else. `full_page=True` captures past the fold.
- **Reach difficult sites.** Pass `use_proxy=True` to route the render through Steel's residential proxy network for pages that block datacenter traffic.

## How scrape differs from a browser session

The other recipes in the cookbook connect a browser library (Playwright, Selenium) to a live Steel session over CDP, then drive clicks and reads themselves. That is the right tool when you need to log in, fill forms, or step through an app. Scrape is the right tool when you just want the page as it renders: one request in, content out, nothing to keep alive. If your agent's job is "read this URL," reach for scrape first and graduate to a session only when you need interaction.

## Related

[TypeScript version](/cookbook/scrape) covers the same endpoint with the clean-markdown-for-LLM angle. [Rust version](/cookbook/scrape) walks the three calls separately. For a live, interactive browser instead, see [playwright-py](/cookbook/playwright).

**Rust**

Steel's REST API turns a URL into structured content without a browser on your side. The `steel-rs` crate wraps three of those endpoints as plain async methods: `client.scrape()` returns parsed content plus typed metadata, `client.screenshot()` and `client.pdf()` render the page and hand back a hosted file URL. There is no session to create, connect to, or release. Each call is one stateless request that runs a browser on Steel's side and returns when the page is done.

That makes this the shortest path into Steel from Rust, and it leans on the SDK's typed structs rather than raw JSON. `scrape()` deserializes into a `ScrapeResponse`, so the fields are real Rust types you can pattern-match on:

```rust
let scraped = client
    .scrape(ClientScrapeParams {
        url: TARGET_URL.to_string(),
        format: Some(vec![ScrapeRequestFormatItem::Markdown]),
        // remaining options set to None; see main.rs
    })
    .await?;

let meta = &scraped.metadata;       // ScrapeResponseMetadata
meta.status_code;                   // i64
meta.title.as_deref();              // Option<&str>
meta.language.as_deref();           // Option<&str>
scraped.links.len();                // Vec<ScrapeResponseLink>
scraped.content.markdown;           // Option<String>
```

`metadata` carries about twenty parsed fields (Open Graph tags, canonical URL, author, published time, the HTTP status code), so you get the document's shape without writing a single selector. `content` holds whichever formats you asked for in `format`: `Markdown`, `HTML`, `CleanedHTML`, or `Readability`. Request only what you need; markdown alone keeps the payload small for LLM context.

`main` runs all three calls against Hacker News, prints the typed metadata, and writes `page.md`, `screenshot.png`, and `page.pdf` to the working directory. Screenshot and PDF responses are a hosted URL, not bytes, so the `download` helper fetches each URL with `reqwest` and writes the file. The artifacts live on Steel for a while after the call, which is handy if you would rather hand the URL to another service than store the bytes yourself.

## Run it

```bash
cd examples/scrape-rs
cp .env.example .env          # set STEEL_API_KEY
cargo run
```

Get a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The first build pulls `steel-rs`, `tokio`, and `reqwest`, so it takes a moment; later runs are fast.

Your output varies. Structure looks like this:

```text
Scraping https://news.ycombinator.com ...
  status     200
  title      Hacker News
  language   en
  links      183
  markdown   14217 chars
  wrote      page.md
Capturing screenshot ...
  wrote      screenshot.png
Rendering PDF ...
  wrote      page.pdf
Done.
```

Three calls cost a few cents of browser time total. Steel bills per session-minute, and these one-shot endpoints spin up and tear down their own browser, so there is nothing to leak: no cleanup call, no session left running against the default 5-minute timeout. The trade-off is that each call is independent, so you cannot log in once and scrape five pages behind the auth. For that, open a session and drive a real browser (see Related).

## Make it yours

- **Change the target.** Edit the `TARGET_URL` constant. Every call reads from it.
- **Pick formats.** Pass more variants in `format`, for example `vec![ScrapeRequestFormatItem::Markdown, ScrapeRequestFormatItem::HTML]`, then read `scraped.content.html`. Each requested format comes back as its own `Option` field on `content`.
- **Get the screenshot and PDF in one call.** `scrape()` takes `pdf: Some(true)` and `screenshot: Some(true)`; the URLs come back on `scraped.pdf` and `scraped.screenshot` instead of making three round trips.
- **Handle anti-bot pages.** Set `use_proxy: Some(true)` on any of the params to route through a Steel residential proxy. Add `delay: Some(2000)` to wait for late-loading content before capture.
- **Match on the status.** `meta.status_code` is an `i64`, so branch on it before trusting the content (a soft 404 still returns markdown).

## Related

[TypeScript version](/cookbook/scrape) and [Python version](/cookbook/scrape) cover the same three endpoints. For a full browser session you connect to and drive over CDP, see [chromiumoxide](/cookbook/chromiumoxide). For the HTTP surface these methods wrap, see the [reqwest docs](https://docs.rs/reqwest) and [Tokio docs](https://tokio.rs).

**Go**

Steel's direct API turns a URL into clean content with no browser library and no session to manage. One `client.Scrape` call runs a browser server-side and returns the page as Markdown (or HTML, readability, or cleaned HTML) inline, while `client.Screenshot` and `client.Pdf` render the same page to hosted files. This recipe scrapes a page to Markdown, prints a preview, then captures a full-page screenshot and a PDF. It is the lowest-friction way to reach a page from Go: no CDP, no chromedp, no `defer release`.

The scrape call leads:

```go
scraped, err := client.Scrape(ctx, steel.ClientScrapeParams{
    URL:    targetURL,
    Format: &[]steel.ScrapeRequestFormatItem{steel.ScrapeRequestFormatItemMarkdown},
})
markdown := deref(scraped.Content.Markdown, "")
title := deref(scraped.Metadata.Title, "(no title)")
```

Two Go specifics show up here. Optional request fields are pointers (`Format` is a `*[]ScrapeRequestFormatItem`, `FullPage` is a `*bool`), and steel-go ships no pointer constructors, so the recipe defines a one-line `ptr[T]` generic. Response fields like `Content.Markdown` and `Metadata.Title` are `*string`, so a small `deref` helper supplies a fallback. The format is a typed constant (`steel.ScrapeRequestFormatItemMarkdown`), not a bare string.

Screenshot and PDF come back as hosted URLs, not bytes:

```go
shot, _ := client.Screenshot(ctx, steel.ClientScreenshotParams{URL: targetURL, FullPage: ptr(true)})
fmt.Println(shot.URL) // https://...

pdf, _ := client.Pdf(ctx, steel.ClientPdfParams{URL: targetURL})
fmt.Println(pdf.URL)
```

To keep the files, fetch each URL with `net/http` and write the bytes to disk.

## Run it

```bash
cd examples/scrape-go
cp .env.example .env          # set STEEL_API_KEY
go run .
```

Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). Point it at any page with `TARGET_URL` in `.env`. Your output varies. Structure looks like this:

```text
Steel Scrape API (Go)
============================================================

Scraping https://news.ycombinator.com to markdown...
HTTP 200 | Hacker News
Links found: 184
Markdown length: 8423 characters

--- Markdown preview (first 500 chars) ---
[ clean Markdown for the page ]
--- end preview ---

Capturing a full-page screenshot...
Screenshot hosted at: https://...
Rendering the page to PDF...
PDF hosted at: https://...

Done. Feed the markdown straight into an LLM prompt.
```

A scrape call costs a few cents of browser time. Steel starts and tears down the browser per call, so there is no session to release.

## Make it yours

- **Change the page.** Set `TARGET_URL` in `.env`, or pass a different URL to `client.Scrape`.
- **Ask for several formats.** `Format` takes a slice, so request more than one at once (`ScrapeRequestFormatItemMarkdown`, `...HTML`, `...Readability`, `...CleanedHTML`). Each lands under its own field on `Content`.
- **Save the artifacts.** Fetch `shot.URL` and `pdf.URL` with `net/http` and `os.WriteFile` to write `screenshot.png` and `page.pdf`, the way the Python recipe does.
- **Scrape behind a proxy.** Set `UseProxy: ptr(true)` to route through a Steel residential proxy for geofenced or bot-sensitive pages.

## Related

[scrape-ts](/cookbook/scrape) and [scrape-py](/cookbook/scrape) are the same direct API in TypeScript and Python, where the Python recipe writes the screenshot and PDF to disk. [scrape-rs](/cookbook/scrape) is the Rust version. For a full browser you drive yourself, [chromedp](/cookbook/chromedp) and [Rod](/cookbook/rod) connect over CDP instead.

## Related recipes

- [Watch Claude pricing for divergent A/B variants](/cookbook/convex-price-watch): Convex cron plus two parallel Steel proxy probes against claude.com/pricing. Stores per-tier per-region snapshots and surfaces tiers where the probes disagree.
- [Persist authenticated sessions with Profiles](/cookbook/profiles): Maintain authenticated sessions across Steel browser instances using profiles.
- [Reuse authenticated sessions across browsers](/cookbook/auth-context): Maintain authenticated sessions across Steel browser instances by capturing and reusing cookies and local storage.


# Automate a cloud browser with Selenium
URL: https://docs.steel.dev/cookbook/selenium


Selenium speaks the W3C WebDriver protocol over HTTP, not CDP. Every click, navigation, and `find_element` is an HTTP round-trip to a remote endpoint that implements the spec. Steel runs one at `http://connect.steelbrowser.com/selenium`, which is where `webdriver.Remote` points.

The catch: Steel identifies callers with a `steel-api-key` header and routes each command to the right browser with a `session-id` header. `webdriver.Remote` doesn't expose a direct hook for custom headers, so the starter subclasses `RemoteConnection`:

```python
class CustomRemoteConnection(RemoteConnection):
    _session_id = None

    def __init__(self, remote_server_addr: str, session_id: str):
        super().__init__(remote_server_addr)
        self._session_id = session_id

    def get_remote_connection_headers(self, parsed_url, keep_alive=False):
        headers = super().get_remote_connection_headers(parsed_url, keep_alive)
        headers.update({'steel-api-key': os.environ.get("STEEL_API_KEY")})
        headers.update({'session-id': self._session_id})
        return headers
```

`get_remote_connection_headers` runs on every outbound request. Selenium has no persistent connection to keep alive; the two headers ride along with each command. That's the integration. After the driver is wired, the rest is vanilla Selenium 4.

One requirement: create the session with `is_selenium=True`. Steel provisions a WebDriver-compatible node for those sessions; without the flag you get a CDP browser that Selenium cannot drive. Steel's [Selenium integration](/integrations/selenium) covers the same setup on its own.

```python
session = client.sessions.create(is_selenium=True)

driver = webdriver.Remote(
    command_executor=CustomRemoteConnection(
        remote_server_addr='http://connect.steelbrowser.com/selenium',
        session_id=session.id,
    ),
    options=webdriver.ChromeOptions(),
)
```

From here, `driver.get(...)`, `WebDriverWait`, `By.CLASS_NAME`, and `find_elements` behave as they would against a local ChromeDriver. The scraping body inside `main()` uses `WebDriverWait` with `expected_conditions.presence_of_element_located` to block until Hacker News renders its story rows, then walks `athing` elements to pull title, link, and points.

## Run it

```bash
cd examples/selenium
cp .env.example .env          # set STEEL_API_KEY
uv run main.py
```

Grab a key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The script prints a session viewer URL as it starts. Open it in another tab to watch the browser run live.

Your output varies. Structure looks like this:

```text
Creating Steel session...
Session created successfully with Session ID: ab12cd34...
You can view the session live at https://app.steel.dev/sessions/ab12cd34...

Connected to browser via Selenium
Navigating to Hacker News...

Top 5 Hacker News Stories:

1. Claude 4.7 Opus released today
   Link: https://news.ycombinator.com/item?id=43218921
   Points: 892

2. Show HN: A browser extension for reading on slow connections
   Link: https://github.com/user/project
   Points: 401

...

Releasing session...
Session released
Done!
```

A run costs a few cents of session time. Steel bills per session-minute, so `main()` wraps everything in a `try / finally` and calls `client.sessions.release(session.id)` on exit. Skip it and the browser idles until the default 5-minute timeout elapses.

## Make it yours

- **Swap the target.** The scraping logic sits between the `Your Automations Go Here!` banner comments in `main.py`. Replace `driver.get(...)` and the `story_elements` loop with your own selectors; session setup and teardown stay put.
- **Extend the session.** Pass `session_timeout=1800000` (30 minutes) alongside `is_selenium=True` in `sessions.create()` for longer runs. Keep `is_selenium=True`; it is the switch that provisions a WebDriver node.
- **Wait on DOM state.** Each command is an HTTP round-trip, so blind `time.sleep` calls compound latency. Prefer `WebDriverWait` with `expected_conditions` (as in the example) to block on the specific element or state you need.
- **Reuse the headers pattern.** `CustomRemoteConnection` is how you inject any extra header into every WebDriver request. The same subclass shape works for custom tracing or routing headers you want to attach per call.

## Related

[Selenium Python docs](https://selenium-python.readthedocs.io) · [WebDriver protocol](https://w3c.github.io/webdriver/)

## Related recipes

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
- [Automate a cloud browser with headless_chrome](/cookbook/headless-chrome): Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.


# Automate browsing with natural-language instructions using Stagehand
URL: https://docs.steel.dev/cookbook/stagehand


**TypeScript**

Stagehand replaces brittle selectors with two LLM-backed primitives:

- `stagehand.extract(instruction, schema)`: describe what you want, pass a Zod schema, get typed data back.
- `stagehand.act(instruction)`: describe an action in natural language, Stagehand figures out the click / type / scroll.

Both run against a Steel session over CDP, so Stagehand handles the reasoning and Steel handles the browser (stealth, proxies, live viewer). Steel's [Stagehand integration](/integrations/stagehand) covers the same wiring on its own.

```typescript
stagehand = new Stagehand({
  env: "LOCAL",
  localBrowserLaunchOptions: {
    cdpUrl: `${session.websocketUrl}&apiKey=${STEEL_API_KEY}`,
  },
  model: { modelName: "openai/gpt-5", apiKey: OPENAI_API_KEY },
});

await stagehand.init();
```

`env: "LOCAL"` tells Stagehand "I'll hand you the browser." That browser is Steel, reached via the CDP URL. `model` is the LLM that interprets every instruction. This starter targets **Stagehand v3**.

Typed extraction, an instruction paired with a Zod schema:

```typescript
const stories = await stagehand.extract(
  "extract the titles and ranks of the first 5 stories on the page",
  z.object({
    stories: z.array(z.object({ title: z.string(), rank: z.number() })),
  }),
);
```

The schema isn't just documentation. Stagehand constrains the LLM's output against it and gives you a typed result at runtime. Swap the prompt and schema for any extraction problem: forms, tables, search results, prices.

Natural-language action, no selector required:

```typescript
await stagehand.act("click the 'new' link in the top navigation");
```

Stagehand inspects the DOM, picks the matching element, and clicks it.

## Run it

```bash
cd examples/stagehand-ts
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
npm install
npm start
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). A session viewer URL prints as the script starts. Open it in another tab to watch Stagehand work.

Your output varies. Structure looks like this:

```text
Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34…

Initializing Stagehand...
Connected to browser via Stagehand
Navigating to Hacker News...
Extracting top stories using AI...

Top 5 Hacker News Stories:
1. Claude 4.7 Opus released today
2. Show HN: A browser extension for reading on slow connections
3. …

Navigating to HN's 'new' section via a natural-language click...
Navigated to new stories!

Automation completed successfully!
```

A full run takes ~30 seconds and costs a few cents of Steel session time plus OpenAI tokens for each `extract` / `act` call.

## Make it yours

- **Swap the schema and prompt.** `extract()` works on any data shape: forms, invoices, product grids, tables. Change the `stagehand.extract` call in `index.ts` to whatever you need to read off a page.
- **Chain acts and extracts.** Break a task into natural-language steps: "sign in with these creds, then extract invoices from the past month." Each step is one `act()` or `extract()`.
- **Try another model.** `gpt-5` works well out of the box; Claude and Gemini also work. Swap `modelName` and `apiKey` in the `Stagehand` config.

## Related

[Python version](/cookbook/stagehand) · [Stagehand docs](https://docs.stagehand.dev)

**Python**

Stagehand v3 ships two LLM-backed primitives that replace CSS selectors with natural language:

- `sessions.extract(instruction, schema)`: describe what you want, pass a JSON schema, get structured data back.
- `sessions.act(instruction)`: describe an action, Stagehand decides whether to click, type, or scroll.

Both run inside an embedded local Stagehand server that drives a Steel-hosted Chrome over CDP.

```python
stagehand = AsyncStagehand(
    server="local",
    model_api_key=OPENAI_API_KEY,
    local_ready_timeout_s=30.0,
)

stagehand_session = await stagehand.sessions.start(
    model_name="openai/gpt-5",
    browser={
        "type": "local",
        "launchOptions": {
            "cdpUrl": f"{session.websocket_url}&apiKey={STEEL_API_KEY}",
        },
    },
)
session_id = stagehand_session.data.session_id
```

The Python SDK is async-first. Every `extract`, `act`, and `navigate` call returns a coroutine, and this starter uses `asyncio.run(main())` as the entry point.

Unlike the TypeScript SDK, the Python v3 SDK exposes extract and act as SSE streams. The starter wraps that pattern in `_stream_to_result`:

```python
async def _stream_to_result(stream, label):
    result_payload = None
    async for event in stream:
        if event.type == "log":
            print(f"[{label}][log] {event.data.message}")
            continue
        status = event.data.status
        if status == "finished":
            result_payload = event.data.result
        elif status == "error":
            raise RuntimeError(f"{label} stream: {event.data.error or 'unknown'}")
    return result_payload
```

`sessions.extract` takes a JSON schema dict and returns data that conforms to it. No Zod, no pydantic required:

```python
STORY_SCHEMA = {
    "type": "object",
    "properties": {
        "stories": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "title": {"type": "string"},
                    "rank": {"type": "integer"},
                },
                "required": ["title", "rank"],
            },
        }
    },
    "required": ["stories"],
}

extract_stream = stagehand.sessions.extract(
    id=session_id,
    instruction="Extract the titles and ranks of the first 5 stories on the page",
    schema=STORY_SCHEMA,
    stream_response=True,
    x_stream_response="true",
)
stories = await _stream_to_result(extract_stream, "extract")
```

`sessions.act` takes an instruction and no selector:

```python
act_stream = stagehand.sessions.act(
    id=session_id,
    instruction="click the 'new' link in the top navigation",
    stream_response=True,
    x_stream_response="true",
)
await _stream_to_result(act_stream, "act")
```

## Run it

```bash
cd examples/stagehand-py
cp .env.example .env          # set STEEL_API_KEY and OPENAI_API_KEY
uv run main.py
```

Get keys from [app.steel.dev](https://app.steel.dev/settings/api-keys) and [platform.openai.com](https://platform.openai.com/api-keys). The script prints a session viewer URL as it starts.

Your output varies. Structure looks like this:

```text
Creating Steel session...
Steel Session created!
View session at https://app.steel.dev/sessions/ab12cd34…

Initializing Stagehand...
Connected to browser via Stagehand
Navigating to Hacker News...
Extracting top stories using AI...

Top 5 Hacker News Stories:
1. Claude 4.7 Opus released today
2. Show HN: A browser extension for reading on slow connections
3. …

Navigating to HN's 'new' section via a natural-language click...
Navigated to new stories!

Automation completed successfully!
```

A full run takes ~30 seconds. The `finally` block in `main()` calls `stagehand.sessions.end`, `stagehand.close()`, and `client.sessions.release()`. Keep all three.

## Make it yours

- **Swap the schema and prompt.** `STORY_SCHEMA` and the `sessions.extract` instruction in `main.py` are the only parts tied to the Hacker News demo.
- **Chain acts and extracts.** Break a task into natural-language steps, one `await _stream_to_result(...)` per step.
- **Try another model.** `openai/gpt-5` is a reasonable default; Claude and Gemini also work. Change `model_name` on `sessions.start` and point `model_api_key` at the matching provider.
- **Turn on Steel stealth.** Uncomment `use_proxy`, `solve_captcha`, or `session_timeout` in the `client.sessions.create()` call for sites with anti-bot.

## Related

[TypeScript version](/cookbook/stagehand) · [Stagehand docs](https://docs.stagehand.dev)

## Related recipes

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
- [Automate a cloud browser with headless_chrome](/cookbook/headless-chrome): Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.


# Build a research agent with Swiftide
URL: https://docs.steel.dev/cookbook/swiftide


[Swiftide](https://swiftide.rs) is a Rust framework for LLM applications: indexing pipelines, query pipelines, and agents that loop over tool calls until they reach an answer. This recipe builds an [agent](/cookbook/topics/agents) whose only tool reads the web through Steel's `scrape` endpoint, so the model works from clean Markdown instead of raw HTML and never touches a browser library or CDP.

The agent runs on Anthropic (`claude-sonnet-4-6`) and the tool is a `#[derive(Tool)]` struct that owns the Steel client:

```rust
#[derive(Clone, swiftide::Tool)]
#[tool(
    description = "Fetch a web page through a Steel cloud browser and return it as clean \
                   Markdown along with the page's outbound links. Use this to read a URL.",
    param(name = "url", description = "Absolute URL of the page to read, including https://")
)]
struct ReadPage {
    client: Arc<Steel>,
}

impl ReadPage {
    async fn read_page(&self, _ctx: &dyn AgentContext, url: &str) -> Result<ToolOutput, ToolError> {
        let response = self.client.scrape(ClientScrapeParams {
            url: url.to_string(),
            format: Some(vec![ScrapeRequestFormatItem::Markdown]),
            ..
        }).await?;
        // ... return response.content.markdown plus response.links
    }
}
```

The derive macro reads the struct's snake-case name (`ReadPage` -> `read_page`), finds the method with that name, and turns each `#[tool(param(...))]` into a JSON Schema field via `schemars`. Anything that implements `Tool` slots into `Agent::builder().tools(...)`, so a stateful struct and a `#[swiftide::tool]` free function are interchangeable at the call site. The struct form is what lets the tool hold `Arc<Steel>`; a free function has nowhere to put it.

Wiring the agent is four builder calls:

```rust
let anthropic = Anthropic::builder().default_prompt_model("claude-sonnet-4-6").build()?;

let mut agent = Agent::builder()
    .llm(&anthropic)
    .tools(vec![ReadPage { client: Arc::clone(&client) }])
    .system_prompt(SYSTEM_PROMPT)
    .limit(8)
    .build()?;

agent.query(TASK).await?;
```

`query` drives the loop: Claude reads the task, calls `read_page` on Hacker News, optionally follows one or two links the scrape returned, then calls the always-present `stop` tool when it has the answer. `.limit(8)` caps the round trips so a confused model can't loop forever. The `on_new_message` hook in `main` prints each assistant turn as it lands.

## Run it

```bash
cd examples/swiftide
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
cargo run
```

Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and an Anthropic key at [console.anthropic.com](https://console.anthropic.com/settings/keys). The Anthropic client reads `ANTHROPIC_API_KEY` from the environment on its own; the Steel key is passed to `Steel::new` explicitly.

Your output varies. Structure looks like this:

```text
Steel + Swiftide research agent
============================================================
    read_page: https://news.ycombinator.com (18243 chars)
The highest-scoring story on the front page is "Show HN: ..." with 642
points, submitted by pg. Let me open it to summarize.
    read_page: https://news.ycombinator.com/item?id=43218921 (9117 chars)
Top story: "Show HN: ..." by pg, 642 points. It is a ... . The author
built it to ... and the thread debates ... .

Done. Steel scrape calls bill a little browser time; no session to release.
```

Each `scrape` call spins up a short-lived Steel browser server-side, so a run costs a few cents of browser time plus a few thousand Anthropic tokens. There is no long-lived session to release here: `scrape` opens and closes its own browser per call, which is the trade for not managing a session yourself. If you switch to `client.sessions().create(...)` for a persistent browser, you own the `release` call and Steel bills per session-minute until you make it.

## One thing that will bite you

**The `#[derive(Tool)]` macro needs `serde` and `async-trait` as direct dependencies.** The expansion emits a bare `#[async_trait::async_trait]` and a `serde`-derived args struct without a `#[serde(crate = ...)]` override, so both crates have to resolve at the crate root even though you never name them. They are in `Cargo.toml` for that reason alone. The `#[swiftide::tool]` attribute macro on a free function fully qualifies its paths and does not need them, so that is the lighter option when your tool is stateless.

Steel's request builders implement `IntoFuture` with a `Send` future, so `client.scrape(...).await` works directly inside a Swiftide tool even though tools run on a multi-threaded Tokio runtime.

## Make it yours

- **Swap the task.** Change `TASK` and `SYSTEM_PROMPT` in `main.rs`. The tool stays the same; the agent re-plans against the new goal.
- **Give it more reach.** The tool already returns up to 40 of the page's links, which is what lets the model follow a story into its comments. Raise `.limit(8)` if you want it to crawl deeper, and widen or drop the link cap.
- **Add a second tool.** A `screenshot` tool backed by `client.screenshot(...)` (returns a base64 PNG) or a `pdf` tool backed by `client.pdf(...)` drops in as another `#[derive(Tool)]` struct in the `tools(vec![...])` list. The agent picks per turn.
- **Change the model.** Any Anthropic chat model works in `default_prompt_model`. Swiftide also ships OpenAI, Gemini, Groq, and Ollama integrations behind feature flags; swap the `Anthropic` builder for one of those and the tools are unaffected.

## Related

[Steel + rig (Rust)](/cookbook/rig) drives a real browser over CDP with chromiumoxide instead of the `scrape` endpoint. [Swiftide agent docs](https://swiftide.rs/agents/overview/) cover hooks, the `Tool` trait, and multi-agent setups.

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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.


# Run a durable browser workflow with Temporal
URL: https://docs.steel.dev/cookbook/temporal-browser-workflow


**TypeScript**

This recipe runs a [Temporal](/cookbook/topics/temporal) Workflow named `browserWorkflow`. The Workflow stays deterministic: it clamps small inputs, loops over URLs, and delegates each Steel scrape plus screenshot to the `capturePage` Activity. The Activity writes Markdown and PNG artifacts locally, then returns the compact page summary recorded in workflow history.

The retry boundary lives on the Activity proxy in `workflows.ts`:

```ts
const { capturePage } = proxyActivities<Activities>({
  startToCloseTimeout: "2 minutes",
  retry: {
    initialInterval: "5 seconds",
    maximumInterval: "30 seconds",
    backoffCoefficient: 2,
    maximumAttempts: 3,
  },
});
```

If a page fetch fails, Temporal retries that Activity. If the worker restarts after one URL succeeds, the completed Activity result is replayed from history and the workflow resumes at the next URL.

## Run it

Install the Temporal CLI if you do not already have it, then start a local dev server:

```bash
temporal server start-dev
```

In another terminal, run the worker and workflow client:

```bash
cd examples/temporal-browser-workflow-ts
cp .env.example .env
npm install
npm start
```

Set `STEEL_API_KEY` in `.env`. Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The Temporal dev server listens on `localhost:7233`, which matches `TEMPORAL_ADDRESS` in `.env.example`.

Your output varies. Structure looks like this:

```json
{
  "pages": [
    {
      "url": "https://news.ycombinator.com/",
      "title": "Hacker News",
      "statusCode": 200,
      "screenshotUrl": "https://...",
      "artifacts": {
        "screenshotPath": "artifacts/news-ycombinator-com-2026-06-29T10-30-00-000Z.png",
        "markdownPath": "artifacts/news-ycombinator-com-2026-06-29T10-30-00-000Z.md"
      }
    }
  ],
  "pageCount": 2
}
```

Open the Temporal UI printed by `temporal server start-dev`. The workflow history shows one `capturePage` Activity per URL.

## Why Steel runs in an Activity

Temporal Workflows replay, so they should not call Steel, `fetch`, `Date.now()`, the filesystem, or any other side-effecting API. `browserWorkflow` only decides which Activity to schedule next. `capturePage` owns the browser work and artifact writes:

```ts
const scraped = await steel.scrape({ url, format: ["markdown"] });
const screenshot = await steel.screenshot({ url, fullPage });
await writeFile(markdownPath, toMarkdown(result, markdown), "utf8");
await download(screenshot.url, screenshotPath);
```

This keeps browser cost tied to Activity attempts. A failed Activity can retry. A completed Activity is not re-run during workflow replay.

## Make it yours

- **Change the batch.** Set `TARGET_URLS` to a comma-separated list. The workflow caps each run at 10 URLs.
- **Adjust extraction.** Use more fields from the Steel scrape response, or add PDF generation inside `capturePage`.
- **Store artifacts durably.** Upload the PNG and Markdown files to object storage inside the Activity before returning.
- **Deploy the worker.** Point `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, and `TEMPORAL_TASK_QUEUE` at your Temporal cluster, then run the same worker process.

## Related

[temporal-browser-workflow-py](/cookbook/temporal-browser-workflow), [temporal-browser-workflow-go](/cookbook/temporal-browser-workflow), and [temporal-browser-workflow-rs](/cookbook/temporal-browser-workflow) implement the same workflow in other SDKs. See the [Temporal TypeScript SDK](https://docs.temporal.io/develop/typescript), [Steel scrape recipe](/cookbook/scrape), and [Trigger.dev browser job](/cookbook/trigger-dev-browser-job).

**Python**

`BrowserWorkflow` is a Python Temporal Workflow that batches page captures without putting network calls in replayed code. The workflow module contains only dataclasses and the deterministic loop. `main.py` registers `capture_page` as an Activity, and that Activity calls Steel, downloads the screenshot, and writes the Markdown report.

The Python SDK's sandbox is the reason for the split. `workflows.py` does not import Steel or touch the filesystem. The worker imports both modules, registers the workflow and Activity, starts one workflow run, waits for the result, then shuts the local worker down.

## Run it

Start a local Temporal dev server:

```bash
temporal server start-dev
```

Run the Python worker and starter in another terminal:

```bash
cd examples/temporal-browser-workflow-py
cp .env.example .env
python -m venv .venv
source .venv/bin/activate
pip install -e .
python main.py
```

Set `STEEL_API_KEY` in `.env`. Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys).

Your output varies. Structure looks like this:

```text
Started Temporal workflow: steel-browser-py-1782740000000
Workflow result:
{
  "pages": [
    {
      "url": "https://news.ycombinator.com",
      "title": "Hacker News",
      "status_code": 200,
      "screenshot_path": "artifacts/news.ycombinator.com-2026-06-29T10-30-00.png"
    }
  ],
  "page_count": 2
}
```

Artifacts land in `ARTIFACT_DIR`:

```text
artifacts/
|-- news.ycombinator.com-2026-06-29T10-30-00.png
`-- news.ycombinator.com-2026-06-29T10-30-00.md
```

## Make it yours

- **Change the batch.** Set `TARGET_URLS` to a comma-separated list. The workflow caps each run at 10 URLs.
- **Return typed fields.** Add dataclass fields to `PageCapture`, then populate them inside `capture_page_sync`.
- **Tighten failure policy.** Adjust `RetryPolicy` in `workflows.py` when a target site should stop retrying sooner.
- **Move storage out of disk.** Replace `download` and `markdown_path.write_text` with object storage writes inside the Activity.

## Related

[temporal-browser-workflow-ts](/cookbook/temporal-browser-workflow), [temporal-browser-workflow-go](/cookbook/temporal-browser-workflow), and [temporal-browser-workflow-rs](/cookbook/temporal-browser-workflow) cover the same shape in other SDKs. See the [Temporal Python SDK](https://docs.temporal.io/develop/python) and [Steel scrape recipe](/cookbook/scrape).

**Rust**

This recipe uses Temporal's prerelease Rust SDK (`temporalio-sdk` 0.4). `BrowserWorkflow` is declared with `#[workflow]` and keeps only deterministic control flow. `SteelActivities::capture_page` is declared with `#[activities]`, and that is where Steel, HTTP downloads, timestamps, and filesystem writes happen.

The Rust SDK makes the workflow/activity boundary explicit:

```rust
let page = ctx
    .start_activity(
        SteelActivities::capture_page,
        CapturePageInput { url, link_limit, full_page_screenshot },
        activity_options.clone(),
    )
    .await?;
```

The binary has two modes because the prerelease Rust worker is not `Send`; run the worker and starter as separate commands.

## Run it

Start Temporal locally:

```bash
temporal server start-dev
```

Run the Rust worker:

```bash
cd examples/temporal-browser-workflow-rs
cp .env.example .env
cargo run -- worker
```

Start one workflow from another terminal:

```bash
cd examples/temporal-browser-workflow-rs
cargo run -- start
```

Set `STEEL_API_KEY` in `.env`. Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys). The first build pulls Temporal Core, the Rust SDK macros, `steel-rs`, and their transitive dependencies.

Your output varies. Structure looks like this:

```json
{
  "pages": [
    {
      "url": "https://news.ycombinator.com/",
      "title": "Hacker News",
      "statusCode": 200,
      "screenshotUrl": "https://...",
      "markdownPath": "artifacts/news-ycombinator-com-1782740000.md"
    }
  ],
  "pageCount": 2
}
```

## Make it yours

- **Keep the worker running.** `cargo run -- worker` already polls indefinitely. Put it under your process manager for a long-lived deployment.
- **Tune retries.** Edit the `RetryPolicy` in `BrowserWorkflow::run`.
- **Capture more artifacts.** Add PDF generation or object storage writes inside `capture_page_impl`.
- **Watch SDK churn.** The Rust SDK is prerelease, so pin the `temporalio-*` crate versions before deploying.

## Related

[temporal-browser-workflow-ts](/cookbook/temporal-browser-workflow), [temporal-browser-workflow-py](/cookbook/temporal-browser-workflow), and [temporal-browser-workflow-go](/cookbook/temporal-browser-workflow) cover the same workflow in stable SDKs. See the [Temporal Rust SDK crate](https://crates.io/crates/temporalio-sdk) and [Steel scrape recipe](/cookbook/scrape).

**Go**

This Go recipe keeps the Temporal pieces in one process for local runs. `main` connects to Temporal, starts a worker on `steel-browser-workflows-go`, registers `BrowserWorkflow` and `CapturePage`, then starts one workflow execution through the same SDK client.

`BrowserWorkflow` is pure workflow code. It configures `workflow.ActivityOptions`, clamps the batch size, and calls `workflow.ExecuteActivity` once per URL. `CapturePage` is regular Go code: it calls Steel's scrape and screenshot APIs, writes artifacts, and returns a typed `PageCapture`.

## Run it

Start Temporal locally:

```bash
temporal server start-dev
```

Run the Go worker and starter:

```bash
cd examples/temporal-browser-workflow-go
cp .env.example .env
go mod tidy
go run .
```

Set `STEEL_API_KEY` in `.env`. Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys).

Your output varies. Structure looks like this:

```text
Started Temporal workflow: steel-browser-go-1782740000000
Target URLs: https://news.ycombinator.com, https://example.com
Workflow result:
{Pages:[{URL:https://news.ycombinator.com/ Title:Hacker News ...}] PageCount:2}
```

The Temporal UI shows one Activity task for each URL, with the retry policy configured in `BrowserWorkflow`.

## Make it yours

- **Change the batch.** Set `TARGET_URLS` to a comma-separated list. The workflow caps each run at 10 URLs.
- **Keep the worker long-lived.** Remove `startWorkflow` from `main` when you want a worker process that only polls and executes tasks.
- **Add more Steel calls.** Put PDF generation, profile-backed sessions, or proxy options inside `CapturePage`.
- **Route artifacts elsewhere.** Replace `os.WriteFile` and `download` with S3, GCS, or your own blob store.

## Related

[temporal-browser-workflow-ts](/cookbook/temporal-browser-workflow), [temporal-browser-workflow-py](/cookbook/temporal-browser-workflow), and [temporal-browser-workflow-rs](/cookbook/temporal-browser-workflow) cover the same workflow in other SDKs. See the [Temporal Go SDK](https://docs.temporal.io/develop/go) and [Steel scrape recipe](/cookbook/scrape).

## Related recipes

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
- [Automate a cloud browser with headless_chrome](/cookbook/headless-chrome): Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.
- [Automate a cloud browser with chromedp](/cookbook/chromedp): Use Steel with chromedp to connect over CDP, navigate to Hacker News, extract the top stories, and capture a screenshot.


# Run a Steel browser job with Trigger.dev
URL: https://docs.steel.dev/cookbook/trigger-dev-browser-job


This recipe runs browser automation as a [queued background job](/cookbook/topics/triggerdev). The request
path only enqueues `steel-browser-job`; the task creates a Steel session,
connects Playwright over CDP, extracts a page summary, saves artifacts, and
releases the session in `finally`.

The core workflow lives in `src/trigger/browser-job.ts`:

```ts
export const browserJob = task({
  id: "steel-browser-job",
  maxDuration: 300,
  retry: { maxAttempts: 3 },
  queue: { concurrencyLimit: 2 },
  run: async (payload) => {
    session = await steel.sessions.create({ sessionTimeout: 600000 });
    browser = await chromium.connectOverCDP(
      `${session.websocketUrl}&apiKey=${steelApiKey}`
    );
    // browser work
  },
});
```

`maxDuration` caps runaway jobs at 5 minutes. `retry` gives transient page or
network failures another attempt. `queue.concurrencyLimit` keeps only two
browser jobs active at once, so a burst of requests does not create an
unbounded number of sessions.

## Run it

```bash
cd examples/trigger-dev-browser-job
cp .env.example .env
npm install
npm run dev
```

Set `STEEL_API_KEY`, `TRIGGER_SECRET_KEY`, and `TRIGGER_PROJECT_REF` in `.env`.
Get a Steel key at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys).
Use your Trigger.dev project ref from the Trigger.dev dashboard.

In another terminal, enqueue one run:

```bash
npm run trigger
```

The trigger script reads `TARGET_URL` and `LINK_LIMIT` from `.env`, calls
`tasks.trigger("steel-browser-job", payload)`, and prints the run id. Watch the
run in the Trigger.dev dashboard. Task output includes the Steel Live View URL,
a hosted screenshot URL, local artifact paths, the extracted links, and
duration in milliseconds.

Local artifacts are written to `ARTIFACT_DIR`:

```text
artifacts/
|-- browser-job-2026-06-29T10-30-00-000Z.png
`-- browser-job-2026-06-29T10-30-00-000Z.md
```

## Why the browser lives in the task

Browser sessions are slow compared to HTTP handlers. A page can take 20-60
seconds when the site hydrates, retries, or challenges automation. Putting that
work in a Trigger.dev task gives you a run record, logs, retries, a timeout,
and queue backpressure. The API caller gets a run id immediately instead of
waiting for the browser.

The task still releases the Steel session on every path:

```ts
finally {
  if (browser) await browser.close();
  if (session) await steel.sessions.release(session.id);
}
```

That cleanup is the cost control. If extraction throws after navigation, the
remote browser still shuts down instead of idling until the session timeout.

## Make it yours

- **Swap the extraction.** Replace the `page.evaluate` block with your site's
  selectors, form submission, or file download flow.
- **Store artifacts durably.** Keep the hosted screenshot URL for public pages,
  or upload the `page.screenshot()` bytes to your own object storage when the
  artifact depends on logged-in session state.
- **Tune concurrency.** Raise `queue.concurrencyLimit` for high-throughput
  crawls, or lower it when each job holds a logged-in profile.
- **Add idempotency.** Pass an idempotency key from the caller when the same
  URL should not create duplicate browser runs.

## Related

[Playwright recipe](/cookbook/playwright) |
[Files recipe](/cookbook/files) |
[Trigger.dev tasks](https://trigger.dev/docs/tasks/overview)

## Related recipes

- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
- [Automate a cloud browser with headless_chrome](/cookbook/headless-chrome): Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.
- [Automate a cloud browser with chromedp](/cookbook/chromedp): Use Steel with chromedp to connect over CDP, navigate to Hacker News, extract the top stories, and capture a screenshot.


# Stream a browser agent into a Next.js chat app
URL: https://docs.steel.dev/cookbook/vercel-ai-sdk-nextjs


A [Next.js](/cookbook/topics/nextjs) chat app where an [AI SDK v6](/integrations/ai-sdk) agent drives a Steel cloud browser server-side and streams every tool call back into the UI. `useChat` on the client posts to `/api/chat`; that route calls `streamText` with four Steel-backed tools (`openSession`, `navigate`, `snapshot`, `extract`). Each tool call surfaces as a typed `tool-*` part on the message stream, and a Live View iframe on the right lights up the moment the agent opens a session.

```
app/
├── api/chat/route.ts   # streamText + Steel tools, Node runtime
├── page.tsx            # useChat, tool-call rendering, Live View iframe
├── layout.tsx          # Geist fonts, dark theme
└── globals.css
```

`app/api/chat/route.ts` pins `runtime = "nodejs"` (Playwright will not run on Edge) and `maxDuration = 120`. `next.config.mjs` lists `playwright`, `playwright-core`, and `steel-sdk` under `serverExternalPackages` so Next skips bundling them into the server build.

The `POST` handler builds a per-request closure around three variables (`session`, `browser`, `page`) shared by every tool's `execute`, and `streamText` runs up to 15 steps (`stopWhen: stepCountIs(15)`). Both `onFinish` and `onAbort` call `cleanup()` to close the browser and `steel.sessions.release(session.id)`.

A fifth tool, `submitForm`, carries `needsApproval: true`. Its body only runs if your approval UI confirms the call. It ships as a demo hook; wire it up when you add real destructive actions.

## Phase-gating with `prepareStep`

Tool misuse by the model is a real failure mode. A second `openSession` mid-run would leak a browser; a `navigate` before any session exists throws. `prepareStep` constrains the active tool set per step:

```ts
prepareStep: async ({ stepNumber, steps }) => {
  const sessionOpened = steps.some((s) =>
    s.toolCalls?.some((tc) => tc.toolName === "openSession")
  );
  if (stepNumber === 0 || !sessionOpened) {
    return { activeTools: ["openSession"] };
  }
  return { activeTools: ["navigate", "snapshot", "extract", "submitForm"] };
},
```

## Run it

```bash
cd examples/vercel-ai-sdk-nextjs
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
npm install
npx playwright install chromium
npm run dev
```

Get keys at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). Open [http://localhost:3000](http://localhost:3000) and try one of the seeded prompts:

> Go to https://github.com/trending/python and tell me the top 3 AI/ML repos.

A typical run takes ~20 seconds: `openSession` (~3s), `navigate` (~2s), `snapshot` (~1s), `extract` (~1s), then the model writes its reply. Server console logs each step:

```text
  step: openSession | 412 tokens
  step: navigate | 1083 tokens
  step: snapshot | 2847 tokens
  step: extract | 3104 tokens
  step: (text) | 3298 tokens
```

## Deploying to Vercel

Push to GitHub, import into Vercel, add `STEEL_API_KEY` and `ANTHROPIC_API_KEY` as environment variables. Playwright's Chromium has to be downloaded during the build, so set the Build Command to:

```
npx playwright install chromium && next build
```

The `/api/chat` route already declares `maxDuration = 120` and `runtime = "nodejs"`.

## Make it yours

- **Change the model.** Swap `anthropic("claude-haiku-4-5")` for any model in `@ai-sdk/*`. The Zod tool schemas stay the same.
- **Add a screenshot tool.** `await page.screenshot({ type: "png" })` returns a Buffer; return it base64-encoded and render it as an `<img>` in the tool-call panel.
- **Stream a plan step.** Add a `plan` tool with no side effects and a string input. The model can narrate its intent before executing.
- **Turn on stealth.** Pass `useProxy`, `solveCaptcha`, or `sessionTimeout` options to `steel.sessions.create()` inside `openSession`.
- **Wire the approval UI.** `needsApproval: true` on `submitForm` pauses execution and surfaces the call as a `tool-submitForm` part in `state: "input-available"`. Render an Approve/Reject pair and call `addToolResult` from `@ai-sdk/react` to resume.

## Related

[Plain TS version](/cookbook/vercel-ai-sdk) · [AI SDK agents](https://ai-sdk.dev/docs/agents/overview) · [Loop control](https://ai-sdk.dev/docs/agents/loop-control) · [Next.js App Router](https://nextjs.org/docs/app)

## Related recipes

- [Build a browser agent with Google ADK](/cookbook/google-adk): Use Steel with Google's Agent Development Kit (ADK) for Go to build a tool-using browser agent that drives a chromedp session over CDP and reads Hacker News.
- [Build a typed browser agent with Pydantic AI](/cookbook/pydantic-ai): Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection.
- [Build a typed browser agent with LangGraph](/cookbook/langgraph): Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node.


# Build a typed browser agent with the Vercel AI SDK
URL: https://docs.steel.dev/cookbook/vercel-ai-sdk


The [Vercel AI SDK v6](https://ai-sdk.dev/docs/agents/overview) ships `ToolLoopAgent`, a [typed agent](/cookbook/topics/typed-output) that picks a tool, calls it, observes the result, and decides the next step. Give it tools that drive a Playwright page connected to a Steel session and you get a terminal browser agent with no UI scaffolding in the way. Steel's [AI SDK integration](/integrations/ai-sdk) covers the same pairing on its own.

```typescript
const researchAgent = new ToolLoopAgent({
  model: anthropic("claude-haiku-4-5"),
  instructions: "You operate a Steel cloud browser via tools...",
  stopWhen: [stepCountIs(15), hasToolCall("reportFindings")],
  tools: { openSession, navigate, snapshot, extract, reportFindings },
  onStepFinish: async ({ stepNumber, toolCalls, usage }) => { ... },
});

const result = await researchAgent.generate({ prompt: "..." });
```

`reportFindings` is the terminator. Its `inputSchema` is a Zod object (a `summary` string and an array of repo records) and it has **no `execute`**. In AI SDK v6, a tool with no `execute` stops the loop the moment the model calls it, and the call's `input` is your final typed answer. This sidesteps the Anthropic-on-tools issue where forcing JSON response format disables tool calling.

## Run it

```bash
cd examples/vercel-ai-sdk-ts
cp .env.example .env          # set STEEL_API_KEY and ANTHROPIC_API_KEY
npm install
npm start
```

Get keys at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys) and [console.anthropic.com](https://console.anthropic.com/). The demo task is wired into `main()`: find the top 3 AI/ML repos on `github.com/trending/python?since=daily` and return name, URL, stars, and description.

Your output varies. Structure looks like this:

```text
Steel + AI SDK v6 (ToolLoopAgent) Starter
============================================================
    openSession: session=842ms cdp=411ms
  step 1: openSession | 1183 tokens
    navigate: 1621ms
  step 2: navigate | 1402 tokens
    snapshot: 124ms (3892 chars, 48 links)
  step 3: snapshot | 5104 tokens
    extract: 98ms (10 rows)
  step 4: extract | 2881 tokens
  step 5: reportFindings | 3150 tokens

Agent finished.

Structured output:
{
  "summary": "The top trending Python repos today center on...",
  "repos": [
    { "name": "owner/repo", "url": "...", "stars": "1,204", "description": "..." },
    ...
  ]
}

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/ab12cd34...
```

A full run takes ~20 seconds and costs a few cents of Steel session time plus a small number of Anthropic tokens. The `finally` block in `main` calls `steel.sessions.release()`.

## Make it yours

- **Swap the task.** Change the `prompt` in `main()` and the `reportFindings` schema. Everything else is task-agnostic.
- **Swap the model.** `claude-haiku-4-5` is the default. For harder tasks, try `anthropic("claude-sonnet-4-6")`, `openai("gpt-5")`, or `google("gemini-2.5-pro")`. You can also use the [AI Gateway](https://vercel.com/docs/ai-gateway) string form, like `"anthropic/claude-haiku-4-5"`, to route through Vercel.
- **Add tools.** A `click` tool wrapping `page.click`, a `fill` tool over `page.fill`, a `screenshot` tool that returns a base64 PNG for vision models.
- **Phase-gate steps.** Use `prepareStep` to restrict which tools are callable on a given step. See the AI SDK's [loop control](https://ai-sdk.dev/docs/agents/loop-control) page.
- **Turn on stealth.** Pass `useProxy`, `solveCaptcha`, or `sessionTimeout` to `steel.sessions.create({...})` inside `openSession` for sites with anti-bot.

## Related

[Next.js version](/cookbook/vercel-ai-sdk-nextjs) · [AI SDK agents docs](https://ai-sdk.dev/docs/agents/overview) · [ToolLoopAgent reference](https://ai-sdk.dev/docs/agents/building-agents)

## Related recipes

- [Build a browser agent with Google ADK](/cookbook/google-adk): Use Steel with Google's Agent Development Kit (ADK) for Go to build a tool-using browser agent that drives a chromedp session over CDP and reads Hacker News.
- [Build a typed browser agent with Pydantic AI](/cookbook/pydantic-ai): Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection.
- [Build a typed browser agent with LangGraph](/cookbook/langgraph): Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node.


# Combine You.com search with Steel browser actions
URL: https://docs.steel.dev/cookbook/you-com-search


A [search-then-act agent](/cookbook/topics/search): [You.com](https://you.com/) handles discovery and static extraction, Steel handles real browser actions. The agent gets five tools across two cost tiers and is told to prefer the cheap tier. The Steel session is opened lazily on the first `navigate` call, so a question that resolves on search alone never spins up a browser.

```python
tools = [youcom_search, youcom_contents, navigate, snapshot, click_text]

SYSTEM = (
    "You answer research-style questions by combining You.com APIs with a "
    "Steel cloud browser. Prefer the cheap path first: youcom_search to find "
    "candidate URLs, then youcom_contents to read them. Only call navigate, "
    "snapshot, or click_text when the page is JS-rendered, login-walled, or "
    "you need to interact (filters, toggles, form fields). ..."
)
```

You.com Search returns LLM-shaped JSON for any web query (`$5/1k calls`). You.com Contents fetches up to ten URLs of clean Markdown in one round trip (`$1/1k pages`). Both run in milliseconds against a CDN. Steel's cloud browser is the slow, expensive option you reach for when the page needs a real Chromium: a click, a form submit, a JS-rendered table, an auth wall the Contents API can't see through. Routing the agent through this hierarchy keeps token spend, latency, and session billing low on the questions that don't need a browser.

## The two tiers

`youcom_search` and `youcom_contents` are plain `httpx` calls to `ydc-index.io/v1`. No SDK, no session lifecycle. Each tool prints its latency so you can see the cost gap.

```python
@tool
async def youcom_contents(urls: list[str]) -> dict:
    """Fetch clean Markdown for up to ~10 URLs in one call. Cheap, no browser,
    no JS rendering.

    Call this AFTER youcom_search to read static pages. If a page needs JS
    (login walls, dynamic data, interaction), escalate to navigate + snapshot.
    """
    async with httpx.AsyncClient(timeout=60) as client:
        r = await client.post(
            f"{YOU_BASE}/contents",
            json={"urls": urls, "formats": ["markdown"]},
            headers={"X-API-Key": YOUCOM_API_KEY, ...},
        )
    ...
```

The browser tools (`navigate`, `snapshot`, `click_text`) all funnel through `_ensure_session`, which creates the Steel session on demand and reuses it across calls. If the agent never escalates, `_session` stays `None` and the `finally` block prints "No Steel session was opened (cheap path only)."

```python
async def _ensure_session() -> Page:
    global _session, _browser, _page, _playwright
    if _page is not None:
        return _page
    _session = steel.sessions.create()
    _playwright = await async_playwright().start()
    _browser = await _playwright.chromium.connect_over_cdp(
        f"{_session.websocket_url}&apiKey={STEEL_API_KEY}"
    )
    ...
    return _page
```

## What the agent escalates for

`youcom_contents` returns the markdown a server would serve to a curl request. That covers most blog posts, docs sites, GitHub READMEs, news articles, and SEO-friendly product pages. It misses anything client-rendered: dashboards, SPAs without server fallbacks, paywalled content, in-page filters and toggles, anything behind a login.

`snapshot` reads `document.body.innerText` from the live DOM after the page settles, so it sees JS-rendered text the Contents API cannot. `click_text` is a thin wrapper over Playwright's `get_by_text(...).first.click(...)` for buttons, tabs, and filters whose effect You.com cannot replay. The agent decides when the gap matters: the docstrings make the routing explicit, the system prompt reinforces it.

The result is a graceful fall-through. Cheap question (a fact you can cite from a static page): one search, one contents, done in a few seconds for a fraction of a cent. Expensive question (something behind a click): the agent escalates exactly as far as it needs to and releases the session at the end.

## The agent loop

`create_tool_calling_agent` builds a tool-calling prompt from the schemas LangChain extracts from each `@tool`'s signature and docstring. `AgentExecutor` runs the loop: model picks tools, executes them, feeds results back, stops when the model emits a text-only response.

```python
agent = create_tool_calling_agent(model, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, max_iterations=10, verbose=False)

result = await executor.ainvoke({"input": question})
```

`max_iterations=10` is a safety net for runaway loops. `verbose=False` keeps the output clean; the per-tool latency lines from each tool give enough trace to follow the flow. For a richer trace, flip `verbose=True` or set `LANGSMITH_API_KEY` and `LANGSMITH_TRACING=true` in `.env`.

## Run it

```bash
cd examples/you-com-search
cp .env.example .env          # set STEEL_API_KEY, ANTHROPIC_API_KEY, YOUCOM_API_KEY
uv sync
uv run playwright install chromium
uv run main.py
```

Get keys at [app.steel.dev/settings/api-keys](https://app.steel.dev/settings/api-keys), [console.anthropic.com](https://console.anthropic.com/), and [you.com/platform](https://you.com/platform). New You.com accounts get $100 in credits with no card.

Your output varies. The shape is illustrative, not literal:

```text
Steel + You.com Search-Act Starter
============================================================
    youcom_search: <ms> (<n> results)
    youcom_contents: <ms> (<n> pages)
    open_session: <ms> (live view: https://app.steel.dev/sessions/...)
    navigate: <ms>
    snapshot: <ms> (<chars> chars, <n> links)

Agent finished.

<the agent's free-text answer, citing the URLs it used>

Releasing Steel session...
Session released. Replay: https://app.steel.dev/sessions/...
```

If the agent answers from search and contents alone, the `open_session`, `navigate`, and `snapshot` lines (and the release line) won't appear; the run ends with `No Steel session was opened (cheap path only).` instead. You.com bills per call (search and contents are cheap); Steel bills per session minute, which is why the lazy-open matters.

## Make it yours

- **Swap the cheap tool.** Replace `youcom_search` with the Research API (`https://api.you.com/v1/research`) when you want a multi-step reasoned answer instead of raw results. The agent gets fewer URLs but better-synthesized inputs.
- **Add more browser actions.** `click_text` is the minimum interactive primitive. Add `fill(selector, text)`, `press(key)`, `wait_for_selector`, or `scroll` when the agent needs to operate forms or trigger lazy-loaded content.
- **Tighten the routing.** The `verbose=False` agent re-derives the routing each turn. For repeatable production flows, replace the agent with an explicit two-step pipeline: always run `youcom_search`, always run `youcom_contents` on the top hit, only call browser tools if a heuristic flag (response too short, contains "Please enable JavaScript", etc.) trips.
- **Trace with LangSmith.** Set `LANGSMITH_API_KEY` and `LANGSMITH_TRACING=true` in `.env`. No code changes; every tool call shows up at [smith.langchain.com](https://smith.langchain.com).
- **Swap the model.** `ChatOpenAI(model="gpt-5-mini")` works without touching the tools. The system prompt is generic.
- **Self-host.** Both Steel (open-source browser infra) and the cookbook are deployable; the You.com APIs are remote. For a fully self-hostable variant, replace `youcom_contents` with a local extractor (Trafilatura, Readability) and a search frontend like SearXNG, and keep Steel for the browser tier.

## Related

[CrewAI](/cookbook/crewai) for a multi-agent research-and-report flow that uses Steel's `scrape` API instead of search. [LangGraph](/cookbook/langgraph) for an explicit state-machine version of the same browser loop. [You.com API docs](https://you.com/docs).

## Related recipes

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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.


# AgentKit
URL: https://docs.steel.dev/integrations/agentkit

AgentKit is Inngest's TypeScript framework for [building agent networks](/cookbook/topics/agents): single agents or coordinated teams that share state and route work through code or model-driven routers. The Steel integration runs each agent's browser actions on a Steel cloud session, so AgentKit handles the orchestration and Steel handles the browser.

You can also expose MCP servers as tools and stream tokens and tool steps to a UI as the agent runs.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Node.js**: v20+
*   **Packages**: `@inngest/agent-kit`, `inngest`
*   **Model provider key**: OpenAI, Anthropic, Google Gemini, or any OpenAI-compatible endpoint

### Connect Steel to AgentKit

Inside an AgentKit tool handler, create a Steel session and hand a Playwright `page` to your agent:

```typescript
import { chromium } from "playwright";
import Steel from "steel-sdk";

const steel = new Steel({ steelAPIKey: process.env.STEEL_API_KEY! });
const session = await steel.sessions.create({});
const browser = await chromium.connectOverCDP(
  `${session.websocketUrl}&apiKey=${process.env.STEEL_API_KEY}`,
);
const page = browser.contexts()[0].pages()[0];
// `page` is your Steel browser. Call it from AgentKit tool handlers
```

Full runnable starter: [Steel + AgentKit recipe →](/cookbook/agentkit)

### FAQ

### Do I need to change my existing AgentKit code to use Steel?

No — AgentKit keeps handling orchestration, routing, and shared state. Inside a tool handler you create a Steel session, connect Playwright over CDP, and call the resulting `page` from your handlers.

### How do I connect AgentKit to a Steel browser session?

Call `steel.sessions.create()`, connect with `chromium.connectOverCDP()` using the session's `websocketUrl` plus your `apiKey`, and take the first page of `browser.contexts()[0]` as the page your AgentKit tool handlers drive.

### Does AgentKit work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — pass them to `sessions.create()` (e.g. `useProxy`, `solveCaptcha`, `stealthConfig`). They're Steel-side session options, so your agent network and routers don't change.

### Resources

*   [AgentKit documentation](https://agentkit.inngest.com/overview) – Official documentation for AgentKit
*   [Examples gallery](https://agentkit.inngest.com/examples/overview) – Starter projects (support agent, SWE-bench, coding agent, web search)
*   [LLMs docs bundle](https://agentkit.inngest.com/llms-full.txt) – Markdown doc set for IDEs/LLMs
*   [Inngest Dev Server](https://agentkit.inngest.com/getting-started/local-development) – Live traces and I/O logs
*   [Steel Sessions API reference](/api-reference) – Programmatic session control
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Agno
URL: https://docs.steel.dev/integrations/agno

Agno is a Python framework for building [multi-agent systems](/cookbook/topics/agents) with shared memory, knowledge, and reasoning. The Steel integration wraps a Steel browser as an Agno toolkit, so Agno agents can navigate, fill forms, extract data, and combine browsing with retrieval-augmented reasoning.

Agno is model-agnostic and natively multi-modal, which pairs well with Steel's reliable, sandboxed cloud browsers.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Model provider key**: OpenAI, Anthropic, or any Agno-supported provider
*   **Python**: 3.10+

### Connect Steel to Agno

Wrap a Steel-backed Playwright `page` in an Agno `Toolkit`:

```python
import os
from playwright.sync_api import sync_playwright
from steel import Steel

steel = Steel(steel_api_key=os.environ["STEEL_API_KEY"])
session = steel.sessions.create()

playwright = sync_playwright().start()
browser = playwright.chromium.connect_over_cdp(
    f"{session.websocket_url}&apiKey={os.environ['STEEL_API_KEY']}"
)
page = browser.contexts[0].pages[0]
# `page` is your Steel browser. Wrap it in an Agno Toolkit
```

Full runnable starter: [Steel + Agno recipe →](/cookbook/agno)

### FAQ

### Do I need to change my existing Agno code to use Steel?

No — Steel is exposed to Agno as a regular `Toolkit`. You connect Playwright to a Steel session over CDP and wrap the resulting `page` in the toolkit; agents, teams, memory, and reasoning stay as they were.

### How do I connect Agno to a Steel browser session?

Create a session with `steel.sessions.create()`, connect with `playwright.chromium.connect_over_cdp(f"{session.websocket_url}&apiKey={STEEL_API_KEY}")`, take `browser.contexts[0].pages[0]` as your page, and wrap it in an Agno `Toolkit`.

### Does Agno work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — set them at session creation (e.g. `use_proxy`, `solve_captcha`, `stealth_config` on `sessions.create()`). Agno only sees the Playwright page, so these options don't touch your agent code.

### Which model providers can I use with Agno on Steel?

Any Agno-supported provider — Agno is model-agnostic and natively multi-modal, so OpenAI, Anthropic, or others all work. You just need the provider key plus your Steel API key.

### Resources

*   [Agno documentation](https://docs.agno.com/) – Concepts, APIs, and examples for agents, teams, memory, and reasoning
*   [Steel Sessions API reference](/api-reference) – Manage Steel browser sessions programmatically
*   [Steel Discord](https://discord.gg/steel-dev) – Get help, share recipes, and discuss best practices


# Browser Tool for the Vercel AI SDK
URL: https://docs.steel.dev/integrations/ai-sdk

The Vercel AI SDK is a TypeScript toolkit for building AI applications with typed tools, streaming, and a unified provider model. The Steel integration runs each tool against a Steel cloud session (open, navigate, snapshot, extract, return typed results), so you can stand up a [typed browser agent](/cookbook/topics/agents) in a few hundred lines.

Pair the agent with a Next.js chat UI and embed Steel's Live View iframe alongside the chat to watch the browser as the agent runs.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Node.js**: v20+
*   **Packages**: `ai`, a provider package (e.g., `@ai-sdk/anthropic`), `steel-sdk`, `playwright`, `zod`
*   **Model provider key**: Anthropic, OpenAI, or any AI SDK-supported provider

### Connect Steel to the Vercel AI SDK

Open a Steel session inside a typed AI SDK `tool()`:

```typescript
import { tool } from "ai";
import { chromium } from "playwright";
import Steel from "steel-sdk";
import { z } from "zod";

const steel = new Steel({ steelAPIKey: process.env.STEEL_API_KEY! });

const openSession = tool({
  description: "Open a Steel cloud browser session.",
  inputSchema: z.object({}),
  execute: async () => {
    const session = await steel.sessions.create({});
    const browser = await chromium.connectOverCDP(
      `${session.websocketUrl}&apiKey=${process.env.STEEL_API_KEY}`,
    );
    return { sessionId: session.id, liveViewUrl: session.sessionViewerUrl };
  },
});
```

Full runnable starters:

*   Server-only typed agent: [Steel + Vercel AI SDK recipe →](/cookbook/vercel-ai-sdk)
*   Next.js chat UI with embedded Steel Live View: [Steel + Vercel AI SDK + Next.js recipe →](/cookbook/vercel-ai-sdk-nextjs)

### FAQ

### Do I need to change my existing Vercel AI SDK code to use Steel?

No — Steel slots in as a typed `tool()` from the `ai` package. The tool's `execute` opens a Steel session and connects Playwright over CDP; your streaming, provider setup, and the rest of the agent stay the same.

### How do I connect the Vercel AI SDK to a Steel browser session?

Inside a `tool()`'s `execute`, call `steel.sessions.create()` and connect with `chromium.connectOverCDP()` using the session's `websocketUrl` plus your `apiKey`. Subsequent tools (navigate, snapshot, extract) drive that browser and return typed results.

### Does the Vercel AI SDK work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — pass them to `sessions.create()` (e.g. `useProxy`, `solveCaptcha`, `stealthConfig`). They're session-creation options and don't touch the AI SDK's tool or streaming layers.

### Can users watch the browser while the agent runs in a chat UI?

Yes — the tool returns `liveViewUrl` (`session.sessionViewerUrl`), and the Next.js recipe embeds Steel's Live View iframe alongside the chat so users watch the browser as the agent works.

### Which model providers can I use?

Any AI SDK-supported provider — install a provider package like `@ai-sdk/anthropic` and bring an Anthropic, OpenAI, or other provider key. Steel only needs the CDP connection, so the model choice is independent. For current model recommendations, see the [Steel leaderboard](https://leaderboard.steel.dev/).

### Resources

*   [Vercel AI SDK documentation](https://ai-sdk.dev/) – Tools, agents, streaming, and providers
*   [Steel Sessions API reference](/api-reference) – Programmatic session control for Steel browsers
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Run Browser Use in the Cloud on Steel
URL: https://docs.steel.dev/integrations/browser-use

The Browser Use integration connects Steel's browser infrastructure with the [Browser Use agent framework](/cookbook/topics/browser-use), enabling AI models to perform complex web interactions. Agents can navigate websites, fill forms, click buttons, extract data, and complete multi-step tasks – all while leveraging Steel's reliable cloud browsers for execution. This integration bridges the gap between AI capabilities and real-world web applications without requiring custom API development.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Python**: 3.11+
*   **Supported models**: vision-capable models like GPT-5, Claude Sonnet 4, or Gemini 3 Pro

### Connect Steel to Browser Use

Pass Steel's CDP URL into a Browser Use `BrowserSession`:

```python
from browser_use import Agent, BrowserSession
from browser_use.llm import ChatOpenAI
from steel import Steel

client = Steel(steel_api_key=STEEL_API_KEY)
session = client.sessions.create()
cdp_url = f"{session.websocket_url}&apiKey={STEEL_API_KEY}"

agent = Agent(
    task="Find the latest news on Steel.dev",
    llm=ChatOpenAI(model="gpt-5", api_key=OPENAI_API_KEY),
    browser_session=BrowserSession(cdp_url=cdp_url),
)
result = await agent.run()
```

Full runnable starters:

*   Connect Browser Use to a Steel cloud browser: [Steel + Browser Use recipe →](/cookbook/browser-use)
*   Auto-solve captchas with Steel + Browser Use: [Captcha auto recipe →](/cookbook/browser-use-captcha-auto)
*   Hand off captchas to a human via Steel Live View: [Manual captcha recipe →](/cookbook/browser-use-captcha-manual)

### FAQ

### Do I need to change my existing Browser Use code to use Steel?

No — pass a `BrowserSession(cdp_url=...)` into your `Agent` and Browser Use drives Steel's cloud browser instead of a local one. Your task, LLM config, and `agent.run()` loop stay the same.

### How do I connect Browser Use to a Steel browser session?

Create a session with `client.sessions.create()`, build the CDP URL as `f"{session.websocket_url}&apiKey={STEEL_API_KEY}"`, and pass it to `BrowserSession(cdp_url=cdp_url)` on the `Agent`.

### Does Browser Use work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — these are session-creation options (e.g. `use_proxy`, `solve_captcha`, `stealth_config` on `sessions.create`), invisible to Browser Use itself. The page links dedicated recipes for auto-solving CAPTCHAs and handing them to a human.

### Which LLMs work with Browser Use on Steel?

Vision-capable models — the page calls out GPT-5, Claude Sonnet 4, and Gemini 3 Pro, and the example wires `ChatOpenAI(model="gpt-5")`. You also need Python 3.11+. For up-to-date model recommendations for browser agents, check the [Steel leaderboard](https://leaderboard.steel.dev/).

### Can a human take over when the agent hits a CAPTCHA?

Yes — Steel's Live View lets a person solve the CAPTCHA in the running session while the agent waits. The page links a manual-captcha recipe showing this hand-off pattern.

### Resources

*   [Browser Use documentation](https://docs.browser-use.com/) – Comprehensive guide to the browser-use library
*   [Browser Use examples](https://github.com/browser-use/browser-use/tree/main/examples) – Working example implementations
*   [Browser Use Discord](https://link.browser-use.com/discord) – Join discussions and get support
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Claude Agent SDK + Steel Cloud Browsers
URL: https://docs.steel.dev/integrations/claude-agent-sdk

The Claude Agent SDK is the engine behind Claude Code, exposed as a Python and TypeScript library. You hand `query()` a prompt and an options object and iterate the typed messages it streams back. The Steel integration exposes a cloud browser as in-process MCP tools, so the SDK runs the agent loop and Steel handles the browser.

Available in TypeScript and Python.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Anthropic API Key**: Access to a Claude 4 model
*   **Runtime**: Node.js 20+ or Python 3.10+
*   **Packages**: `@anthropic-ai/claude-agent-sdk` or `claude-agent-sdk`, plus `steel-sdk` and `playwright`

### Connect Steel to the Claude Agent SDK

Wrap a Steel session in a `tool()` and bundle it into an in-process MCP server:

```typescript
import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
import { chromium } from "playwright";
import Steel from "steel-sdk";

const steel = new Steel({ steelAPIKey: STEEL_API_KEY });

const openSession = tool(
  "open_session",
  "Open a Steel cloud browser session.",
  {},
  async () => {
    const session = await steel.sessions.create({});
    const browser = await chromium.connectOverCDP(
      `${session.websocketUrl}&apiKey=${STEEL_API_KEY}`,
    );
    return {
      content: [
        { type: "text", text: JSON.stringify({ sessionId: session.id }) },
      ],
    };
  },
);

const steelServer = createSdkMcpServer({
  name: "steel",
  version: "1.0.0",
  tools: [openSession],
});
```

Pass the server into `query()` via `mcpServers` and pre-approve calls with `allowedTools: ["mcp__steel__*"]`. Drop the SDK's built-in tools with `tools: []` so the agent only sees Steel.

Full runnable starter: [Steel + Claude Agent SDK recipe →](/cookbook/claude-agent-sdk)

Steel runs the same way across [other agent frameworks](/cookbook/topics/agents).

### FAQ

### Do I need to change my existing Claude Agent SDK code to use Steel?

No — Steel is exposed as in-process MCP tools. Wrap a Steel session in a `tool()`, bundle it with `createSdkMcpServer`, and your `query()` loop and message handling stay exactly the same.

### How do I connect the Claude Agent SDK to a Steel browser session?

Define a `tool()` that calls `steel.sessions.create()` and connects via `chromium.connectOverCDP()` with the session's `websocketUrl` plus `apiKey`, bundle it into an in-process MCP server with `createSdkMcpServer`, then pass that server to `query()` via `mcpServers` and pre-approve calls with `allowedTools`.

### Does the Claude Agent SDK work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — enable them when your tool creates the session (e.g. `useProxy`, `solveCaptcha`, `stealthConfig` on `sessions.create()`). The SDK's agent loop and MCP plumbing are unaffected.

### Is the Steel integration available in both TypeScript and Python?

Yes — use `@anthropic-ai/claude-agent-sdk` for TypeScript or `claude-agent-sdk` for Python, plus `steel-sdk` and `playwright`. You'll also need an Anthropic API key with access to a Claude 4 model.

### Resources

*   [Claude Agent SDK documentation](https://platform.claude.com/docs/en/agent-sdk/overview) – Agent loop, custom MCP tools, hooks, subagents
*   [Steel Sessions API reference](/api-reference) – Programmatic session control for Steel browsers
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Claude Code
URL: https://docs.steel.dev/integrations/claude-code

The Claude Code integration gives Claude Code a real cloud browser through the Steel CLI. From a Claude Code session, you can start and control Steel sessions, scrape rendered pages, run computer-use actions, watch live browser state through the session viewer, and turn successful runs into repeatable scripts.

Claude Code already works well with shell commands; Steel adds the browser surface for sites that need JavaScript, session state, or interactive navigation. Steel works the same way with [other agents and frameworks](/cookbook/topics/agents), from CrewAI to LangGraph.

### Requirements

*   **Node.js**: Version 18 or higher
*   **Claude Code**: Installed locally
*   **Steel CLI**: Installed locally
*   **Steel API Key**: Active Steel account

### Setup

#### Step 1: Install Steel CLI

```bash
curl -LsSf https://setup.steel.dev | sh
```

#### Step 2: Log in

```bash
steel login
```

#### Step 3: Install the browser skill (recommended)

The `steel-browser` skill gives Claude Code better command discovery and more reliable browser workflows.

```bash
npx skills add steel-dev/skills --skill steel-browser -a claude-code -g
```

Restart Claude Code after installing so it can discover the skill.

### Authenticated workflows

For authenticated sites, prepare reusable auth state in Steel ahead of time rather than asking Claude Code to handle login from scratch during every run.

See:

*   [Profiles API](/overview/profiles-api/overview)
*   [Reusing Auth Context](/overview/sessions-api/reusing-auth-context)

### Live debugging

Steel sessions return a viewer URL that makes it easier to monitor what the agent is doing in real time. This is useful when Claude Code reaches a modal, a sign-in wall, or a page that does not behave as expected.

### Repeatable workflows

After a successful run, Claude Code can help turn the browser workflow into something more repeatable:

*   A bash script for local execution
*   A project-specific command or workflow
*   A documented runbook for recurring tasks

### Constraints

*   **Command approvals depend on your Claude Code settings.** Shell access may require approval depending on your permission mode.
*   **First runs are usually the roughest.** Dynamic web apps often need a few retries before the workflow is stable.
*   **Authenticated sites work best with prepared Steel auth state.** Reusing profiles or auth context is generally more reliable than repeated interactive logins.

### FAQ

### Do I need to write integration code to use Steel with Claude Code?

No — the integration works through the Steel CLI. Install it with `curl -LsSf https://setup.steel.dev | sh`, run `steel login`, and Claude Code can start and control Steel sessions, scrape rendered pages, and run computer-use actions from the terminal.

### How does Claude Code connect to a Steel browser session?

Through Steel CLI commands in its shell. For better command discovery and more reliable workflows, install the `steel-browser` skill with `npx skills add steel-dev/skills --skill steel-browser -a claude-code -g` and restart Claude Code so it discovers the skill.

### Does Claude Code work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — those are properties of the Steel session itself, not the client driving it. Sessions Claude Code starts run on the same Sessions API that supports `useProxy`, `solveCaptcha`, and `stealthConfig` at creation time.

### How should I handle sites that require login?

Prepare reusable auth state in Steel ahead of time via the Profiles API or Reusing Auth Context, rather than asking Claude Code to log in from scratch every run. The page calls prepared auth state more reliable than repeated interactive logins.

### Can I watch what Claude Code is doing in the browser?

Yes — Steel sessions return a viewer URL for monitoring the browser in real time. That's especially useful when Claude Code hits a modal, a sign-in wall, or a page that misbehaves, and successful runs can then be turned into scripts or runbooks.

### Resources

*   [Give Claude Code a real browser](https://steel.dev/blog/give-claude-code-a-real-browser) – Blog post on using Claude Code with Steel
*   [Claude Code overview](https://code.claude.com/docs/en/overview) – Official Claude Code documentation
*   [How Claude Code works](https://code.claude.com/docs/en/how-claude-code-works) – Built-in tools, permissions, and execution model
*   [Steel CLI docs](/overview/steel-cli) – Full command reference and workflows
*   [Steel Skills](/overview/skills) – Installable skills for coding agents
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Claude Computer Use
URL: https://docs.steel.dev/integrations/claude-computer-use

The Claude Computer Use integration runs Anthropic's [vision-based agent loop](/cookbook/topics/computer-use) on a Steel browser session. Claude takes screenshots through Steel, decides the next action (click, type, scroll), and Steel executes it, so you can automate complex web tasks without writing custom selectors.

It pairs well with Steel's anti-bot capabilities, proxy support, and sandboxed environments.

### Requirements

*   **Anthropic API Key**: A Claude 4 model with computer use
*   **Steel API Key**: Active Steel subscription
*   **Runtime**: Python 3.10+ or Node.js 20+
*   **Note**: Anthropic's computer-use tool is in beta

### Connect Steel to Claude

Steel's `sessions.computer` API takes screenshots and executes actions; pair it with Claude's `computer_20251124` tool (with the `computer-use-2025-11-24` beta header):

```typescript
import { Steel } from "steel-sdk";

const steel = new Steel({ steelAPIKey: STEEL_API_KEY });
const session = await steel.sessions.create({
  dimensions: { width: 1024, height: 768 },
});

// Take a screenshot via Steel
const { base64_image } = await steel.sessions.computer(session.id, {
  action: "take_screenshot",
});

// Send to Claude with the computer-use tool, then route Claude's
// returned actions back through `steel.sessions.computer({ action: ... })`.
```

Full runnable starters:

*   Build a Claude Computer Use loop on Steel: [Steel + Claude Computer Use recipe →](/cookbook/claude-computer-use)
*   Drive a mobile-viewport Steel session with Claude: [Mobile recipe →](/cookbook/claude-computer-use-mobile)

### FAQ

### Do I need Playwright or CSS selectors to use Claude Computer Use with Steel?

No — Claude's computer-use loop is vision-based. Claude looks at screenshots, decides actions like click, type, or scroll, and Steel executes them through its `sessions.computer` API, so there are no custom selectors to write.

### How do I connect Claude Computer Use to a Steel browser session?

Create a session with explicit `dimensions` (the example uses 1024x768), take screenshots via `steel.sessions.computer(session.id, { action: "take_screenshot" })`, send the `base64_image` to Claude's `computer_20251124` tool, and route Claude's returned actions back through `steel.sessions.computer({ action: ... })`.

### Does Claude Computer Use work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — the page notes it pairs well with Steel's anti-bot capabilities, proxy support, and sandboxed environments. These are enabled at session creation (e.g. `useProxy`, `solveCaptcha`, `stealthConfig`) and don't change the agent loop.

### How does the screenshot/action loop actually work?

Steel takes a screenshot, you send it to Claude with the computer-use tool, Claude returns the next action (click, type, scroll), and Steel executes it via `sessions.computer` — then you screenshot again and repeat until the task completes.

### Resources

*   [Anthropic Computer Use documentation](https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool) – Official documentation from Anthropic
*   [Steel Sessions API reference](/api-reference) – Technical details for managing Steel browser sessions
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Codex
URL: https://docs.steel.dev/integrations/codex

The Codex integration gives Codex a real cloud browser through the Steel CLI. From a Codex session, you can start and control Steel sessions, scrape rendered pages, run computer-use actions, and turn one-off runs into reusable scripts.

Codex and Steel fit together well because both are command-line tools. The same CLI setup works with [other agents and frameworks](/cookbook/topics/agents) too. Once Steel CLI is on your `PATH`, Codex can inspect command help, run the commands it needs, and verify results from the terminal.

### Requirements

*   **Node.js**: Version 18 or higher
*   **Codex CLI**: Installed locally
*   **Steel CLI**: Installed locally
*   **Steel API Key**: Active Steel account

### Setup

#### Step 1: Install Steel CLI

```bash
curl -LsSf https://setup.steel.dev | sh
```

#### Step 2: Log in

```bash
steel login
```

#### Step 3: Install the browser skill (recommended)

The `steel-browser` skill gives Codex better command discovery and more consistent browser workflows.

```bash
npx skills add steel-dev/skills --skill steel-browser -a codex -g
```

Restart Codex after installing so it can discover the skill.

### Automation workflow

After a successful manual run, you can ask Codex to turn the workflow into a script:

```
Write a bash script based on what you just did.
```

That works well for jobs like recurring research, internal reporting, or lightweight monitoring. A common pattern is:

1.  Run the task once interactively
2.  Convert it into a script
3.  Schedule it with cron or another runner

### Constraints

*   **Command approvals depend on your Codex settings.** Codex may ask before running shell commands unless you have configured a more permissive approval mode.
*   **Authenticated workflows usually need preconfigured Steel auth state.** For reusable login state, see [Profiles API](/overview/profiles-api/overview) and [Reusing Auth Context](/overview/sessions-api/reusing-auth-context).

### FAQ

### Do I need to write integration code to use Steel with Codex?

No — the integration works through the Steel CLI. Install it with `curl -LsSf https://setup.steel.dev | sh`, run `steel login`, and Codex can start and control Steel sessions, scrape rendered pages, and run computer-use actions from the terminal.

### How does Codex connect to a Steel browser session?

Once Steel CLI is on your `PATH`, Codex inspects command help, runs the commands it needs, and verifies results from the terminal. For better command discovery, install the `steel-browser` skill with `npx skills add steel-dev/skills --skill steel-browser -a codex -g` and restart Codex.

### Does Codex work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — those are properties of the Steel session, not of Codex. Sessions started from the CLI run on the same Sessions API that supports `useProxy`, `solveCaptcha`, and `stealthConfig` at creation time.

### How do I turn a one-off Codex browser run into a recurring job?

After a successful manual run, prompt Codex with "Write a bash script based on what you just did", then schedule the script with cron or another runner. The page recommends this run-once, convert, schedule pattern for recurring research, reporting, and monitoring.

### What should I know before pointing Codex at authenticated sites?

Authenticated workflows usually need preconfigured Steel auth state — set up reusable login state via the Profiles API or Reusing Auth Context instead of interactive logins each run. Also note Codex may ask for command approvals depending on your approval mode.

### Resources

*   [Codex + Steel + Resend blog post](https://steel.dev/blog/codex-wired-steel-and-resend-into-a-daily-newsletter) – Example workflow for building a daily newsletter with Codex
*   [Codex CLI docs](https://developers.openai.com/codex/cli) – Install and use Codex locally
*   [Steel CLI docs](/overview/steel-cli) – Full command reference and workflows
*   [Steel Skills](/overview/skills) – Installable skills for coding agents
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Build a CrewAI Browser Agent on Steel
URL: https://docs.steel.dev/integrations/crewai

CrewAI is a Python framework for orchestrating [multi-agent workflows](/cookbook/topics/agents) with autonomous teams (crews) and event-driven flows. The Steel integration exposes a Steel browser as a CrewAI tool, so your crew can search, navigate, fill forms, extract data, and validate results across collaborating agents.

You can mix autonomy with precise control, share memory across steps, return structured outputs, and add human-in-the-loop checkpoints for sensitive actions.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **LLM API key**: OpenAI or any CrewAI-supported provider
*   **Python**: 3.10–3.13

### Connect Steel to CrewAI

Expose Steel as a CrewAI `BaseTool` your agents can call:

```python
import os
from crewai.tools import BaseTool
from steel import Steel

class SteelScrapeTool(BaseTool):
    name: str = "Steel web scrape"
    description: str = "Scrape webpages with Steel and return markdown"

    def __init__(self):
        super().__init__()
        self._steel = Steel(steel_api_key=os.environ["STEEL_API_KEY"])

    def _run(self, url: str):
        return self._steel.scrape(url=url, format=["markdown"])
```

Full runnable starter: [Steel + CrewAI recipe →](/cookbook/crewai)

### FAQ

### Do I need to change my existing CrewAI code to use Steel?

No — Steel plugs in as a regular CrewAI tool. Define a `BaseTool` subclass whose `_run` method calls Steel, hand it to your agents, and your crews, flows, and memory setup stay untouched.

### How do I connect CrewAI to Steel?

Expose Steel as a CrewAI `BaseTool`: instantiate the Steel client in `__init__` and call it from `_run`. The page's example tool scrapes a URL with `self._steel.scrape(url=url, format=["markdown"])` and returns markdown to the agent.

### Does CrewAI work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — when your tool creates Steel sessions you can enable these at session creation (e.g. `use_proxy`, `solve_captcha`, `stealth_config`). They're Steel-side options, so CrewAI's orchestration doesn't change.

### Do I need a full browser session, or is scraping enough for a crew?

For read-only research, the page's tool uses Steel's `scrape` API to return page markdown — no session management needed. For interactive flows (forms, clicks, multi-step navigation), create a Steel session inside the tool instead.

### Resources

*   [CrewAI documentation](https://docs.crewai.com/) – Official documentation for CrewAI
*   [CrewAI examples repo](https://github.com/crewAIInc/crewAI-examples) – Real-world starter crews (trip planner, stock analysis, job posts)
*   [Steel Sessions API reference](/api-reference) – Programmatically manage Steel browser sessions
*   [Steel Discord](https://discord.gg/steel-dev) – Share recipes and get help


# Gemini Computer Use
URL: https://docs.steel.dev/integrations/gemini-computer-use

The Gemini Computer Use integration runs Gemini 3's [vision-based agent loop](/cookbook/topics/computer-use) on a Steel browser session. Gemini takes screenshots through Steel, decides the next action (click, type, scroll), and Steel executes it, so you can automate complex web tasks without writing custom selectors.

It pairs well with Steel's anti-bot capabilities, proxy support, and sandboxed environments.

### Requirements

*   **Gemini API Key**: A Gemini 3 model with computer use
*   **Steel API Key**: Active Steel subscription
*   **Runtime**: Python 3.10+ or Node.js 20+

### Connect Steel to Gemini

Steel's `sessions.computer` API takes screenshots and executes actions; pair it with Gemini 3's built-in computer use:

```typescript
import { Steel } from "steel-sdk";

const steel = new Steel({ steelAPIKey: STEEL_API_KEY });
const session = await steel.sessions.create({
  dimensions: { width: 1280, height: 800 },
});

// Take a screenshot via Steel
const { base64_image } = await steel.sessions.computer(session.id, {
  action: "take_screenshot",
});

// Send to Gemini with the computer-use tool, then route Gemini's
// returned actions back through `steel.sessions.computer({ action: ... })`.
```

Full runnable starter: [Steel + Gemini Computer Use recipe →](/cookbook/gemini-computer-use)

### FAQ

### Do I need Playwright or CSS selectors to use Gemini Computer Use with Steel?

No — Gemini's loop is vision-based. Gemini reads screenshots, decides actions like click, type, or scroll, and Steel executes them via its `sessions.computer` API, so no custom selectors are needed.

### How do I connect Gemini Computer Use to a Steel browser session?

Create a session with explicit `dimensions` (the example uses 1280x800), take a screenshot with `steel.sessions.computer(session.id, { action: "take_screenshot" })`, send the `base64_image` to Gemini 3 with its built-in computer-use tool, and route Gemini's returned actions back through `steel.sessions.computer({ action: ... })`.

### Does Gemini Computer Use work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — the page notes it pairs well with Steel's anti-bot capabilities, proxy support, and sandboxed environments. These are session-creation options (e.g. `useProxy`, `solveCaptcha`, `stealthConfig`) and don't change the agent loop.

### Which Gemini model do I need, and how does the loop work?

A Gemini 3 model with computer use — the tool is built in. Steel screenshots the session, Gemini decides the next action, Steel executes it via `sessions.computer`, and the loop repeats until the task completes. Consult the [Steel leaderboard](https://leaderboard.steel.dev/) for the most recent model recommendations.

### Resources

*   [Gemini Computer Use documentation](https://ai.google.dev/gemini-api/docs/computer-use) – Official documentation from Google
*   [Steel Sessions API reference](/api-reference) – Technical details for managing Steel browser sessions
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Hermes Agent
URL: https://docs.steel.dev/integrations/hermes-agent

The Steel integration for Hermes is currently pending upstream. It is being developed in [NousResearch/hermes-agent PR #5555](https://github.com/NousResearch/hermes-agent/pull/21182) and may change before it lands in a Hermes release. Upvote or comment on the PR to signal interest.

The pending Hermes integration adds Steel as a cloud browser provider inside Hermes. Based on the current upstream pull request, the integration is expected to provide:

*   Steel as a selectable browser provider during Hermes setup
*   Steel-backed browser sessions for web navigation and interaction
*   A `steel_scrape` tool for server-side content extraction
*   Support for Steel-specific options such as proxying and CAPTCHA solving
*   Viewer URL support so you can monitor browser sessions while the agent runs

If this ships as proposed, Hermes users will be able to route browser tasks through Steel by setting `STEEL_API_KEY` and selecting Steel in Hermes configuration. Meanwhile, [other agent frameworks](/cookbook/topics/agents) already route browser tasks through Steel today.

### Requirements

*   **Hermes Agent**: A build that includes the Steel integration from PR #5555 or a future release that ships it
*   **Steel API Key**: Active Steel account

### Expected setup flow

The workflow below reflects the current pull request, not a released Hermes build.

```bash
hermes setup
```

During setup:

1.  Choose your model provider
2.  Select Steel as the browser provider
3.  Add your `STEEL_API_KEY`

If you are using a current release of Hermes and do not see Steel in the setup flow, that is expected until the integration lands upstream. To try it now, check out the PR branch and run Hermes from source.

### Expected workflow

Once the integration is available, Hermes should be able to use Steel for tasks that need a real browser:

```
Find a hotel near Grand Central and compare the top options.
```

In the proposed implementation, Hermes would start a Steel session, browse the relevant sites, and return a viewer URL alongside the task output so you can inspect the session while it runs.

### Constraints

*   **This integration is not yet part of a released Hermes build.** Check the upstream PR or Hermes changelog before relying on it.
*   **The setup flow may change before release.** Provider names, environment variables, or supported options may still be updated.
*   **Documentation should be validated against the released Hermes version once the PR lands.**

### Resources

*   [Steel is now a native browser provider in Hermes](https://steel.dev/blog/steel-is-now-a-native-browser-provider-in-hermes) – Blog post covering the Hermes integration
*   [Hermes Agent repository](https://github.com/NousResearch/hermes-agent) – Upstream project
*   [Steel integration PR #5555](https://github.com/NousResearch/hermes-agent/pull/5555) – Proposed Steel provider implementation
*   [Steel CLI docs](/overview/steel-cli) – Steel browser workflows from the terminal
*   [Steel Sessions API reference](/api-reference) – Programmatic session management
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Integrations
URL: https://docs.steel.dev/integrations



# Build a LangGraph Browser Agent on Steel
URL: https://docs.steel.dev/integrations/langgraph

[LangGraph](https://langchain-ai.github.io/langgraph/) builds agents as state machines: nodes do work, edges route control, and the agent loop is something you compose explicitly rather than something the framework hides for you. LangChain ships the model wrappers and `@tool` decorator; LangGraph ships the runtime, plus prebuilt `ToolNode` and `tools_condition` helpers that turn three nodes and four edges into a [tool-calling agent](/cookbook/topics/agents). The Steel integration runs each `@tool` against a Steel cloud session, so the agent loop drives a real browser.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Model provider key**: Anthropic, OpenAI, or any other LangChain-supported provider
*   **Python**: 3.10+
*   **Packages**: `langgraph`, `langchain-anthropic` (or another `langchain-*` provider), `steel-sdk`, `playwright`

### Connect Steel to LangGraph

Define `@tool` functions that drive a Steel-backed Playwright `page`, build a `StateGraph` with `agent` and `tools` nodes, and wire `tools_condition` between them:

```python
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
from playwright.async_api import async_playwright
from steel import Steel

steel = Steel(steel_api_key=STEEL_API_KEY)

@tool
async def open_session() -> dict:
    """Open a Steel cloud browser session."""
    global _page
    session = steel.sessions.create()
    playwright = await async_playwright().start()
    browser = await playwright.chromium.connect_over_cdp(
        f"{session.websocket_url}&apiKey={STEEL_API_KEY}"
    )
    _page = browser.contexts[0].pages[0]
    return {"session_id": session.id, "live_view_url": session.session_viewer_url}

tools = [open_session]
model = ChatAnthropic(model="claude-haiku-4-5").bind_tools(tools)

async def agent_node(state: MessagesState) -> dict:
    return {"messages": [await model.ainvoke(state["messages"])]}

graph = StateGraph(MessagesState)
graph.add_node("agent", agent_node)
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
graph.add_edge("tools", "agent")

app = graph.compile()
```

`tools_condition` routes to `"tools"` when the assistant message has tool calls and to `END` when it doesn't. The `tools -> agent` edge is what makes it a loop. For a typed final answer, add a `format` node that runs `model.with_structured_output(Schema)` on the conversation; for one-line wiring, swap the explicit graph for `langgraph.prebuilt.create_react_agent(model, tools, response_format=Schema)`.

Full runnable starter: [Steel + LangGraph recipe →](/cookbook/langgraph)

### FAQ

### Do I need to change my existing LangGraph code to use Steel?

No — your `StateGraph`, nodes, and edges stay the same. Steel lives inside your `@tool` functions: a tool creates a session and attaches Playwright via `connect_over_cdp`, and the agent loop drives that page.

### How do I connect LangGraph to a Steel browser session?

Inside a `@tool`, call `steel.sessions.create()` and connect Playwright with `chromium.connect_over_cdp(f"{session.websocket_url}&apiKey={STEEL_API_KEY}")`, then use `browser.contexts[0].pages[0]` as the shared page for your other tools.

### Does LangGraph work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — enable them when the tool creates the session (e.g. `use_proxy`, `solve_captcha`, `stealth_config` on `sessions.create()`). The graph and `ToolNode` wiring are unaffected.

### How does the agent loop actually work in the LangGraph example?

`tools_condition` routes to the `"tools"` node when the assistant message contains tool calls and to `END` when it doesn't, and the `tools -> agent` edge closes the loop. Three nodes and four edges give you a complete tool-calling browser agent.

### Resources

*   [LangGraph documentation](https://langchain-ai.github.io/langgraph/) – State graphs, prebuilts, checkpointing, and streaming
*   [LangChain `@tool` reference](https://python.langchain.com/docs/concepts/tools/) – Tool definitions and Pydantic schemas
*   [LangSmith](https://smith.langchain.com) – Tracing for every node and tool call
*   [Steel Sessions API reference](/api-reference) – Programmatic session control for Steel browsers
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Magnitude
URL: https://docs.steel.dev/integrations/magnitude

Magnitude is a TypeScript [browser-agent framework](/cookbook/topics/agents) that turns natural-language prompts into typed actions. The Steel integration runs Magnitude's planning loop on a Steel cloud browser, so you can drive a real session from prompts and end with structured outputs validated against a schema.

Good fit for research-style flows that mix navigation and extraction.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Anthropic API Key**: Magnitude's default planning model
*   **Node.js**: v20+

### Connect Steel to Magnitude

Pass Steel's CDP URL into Magnitude's `startBrowserAgent` config:

```typescript
import { startBrowserAgent } from "magnitude-core";
import { Steel } from "steel-sdk";

const client = new Steel({ steelAPIKey: STEEL_API_KEY });
const session = await client.sessions.create({});

const agent = await startBrowserAgent({
  url: "https://github.com/steel-dev/leaderboard",
  llm: {
    provider: "anthropic",
    options: { model: "claude-sonnet-4-6", apiKey: ANTHROPIC_API_KEY },
  },
  browser: { cdp: `${session.websocketUrl}&apiKey=${STEEL_API_KEY}` },
});
```

Full runnable starter: [Steel + Magnitude recipe →](/cookbook/magnitude)

### FAQ

### Do I need to change my existing Magnitude code to use Steel?

No — add `browser: { cdp: ... }` to your `startBrowserAgent` config and Magnitude's planning loop runs on the Steel cloud browser. Prompts, typed actions, and structured outputs work as before.

### How do I connect Magnitude to a Steel browser session?

Create a session with `client.sessions.create()`, then set `browser.cdp` in the `startBrowserAgent` config to the session's `websocketUrl` with your `apiKey` appended.

### Does Magnitude work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — configure them when creating the session (e.g. `useProxy`, `solveCaptcha`, `stealthConfig` on `sessions.create()`). Magnitude just connects to the resulting CDP endpoint.

### Which model does Magnitude use for planning?

Anthropic is Magnitude's default planning provider — the example configures `llm: { provider: "anthropic", options: { model: "claude-sonnet-4-6" } }`. You need an Anthropic API key plus Node.js 20+.

### Resources

*   [Magnitude documentation](https://docs.magnitude.run/) – Concepts, agent APIs, and examples
*   [Steel Sessions API reference](/api-reference) – Programmatic session control for Steel browsers
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Mastra
URL: https://docs.steel.dev/integrations/mastra

[Mastra](https://mastra.ai/) is a TypeScript framework that wraps the Vercel AI SDK with its own primitives: typed `createTool` definitions with input *and* output schemas, a top-level `Mastra` registry that wires agents into storage and observability, a Model Router that turns `'anthropic/claude-haiku-4-5'` into a working client without a provider package install, and a built-in Studio playground (`mastra dev`) for chatting with your agents and replaying traces. The Steel integration runs each tool against a Steel cloud session, so the agent's tool surface is "drive a real browser" without leaving the Mastra primitives.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Node.js**: v22.13+
*   **Packages**: `@mastra/core`, `steel-sdk`, `playwright`, `zod`
*   **Model provider key**: Anthropic, OpenAI, Google, or any other provider supported by the Mastra Model Router

### Connect Steel to Mastra

Define a `createTool` that opens a Steel session, register it on an `Agent`, and attach the agent to a top-level `Mastra` registry:

```typescript
import { Mastra } from "@mastra/core";
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { chromium } from "playwright";
import Steel from "steel-sdk";
import { z } from "zod";

const steel = new Steel({ steelAPIKey: process.env.STEEL_API_KEY! });

const openSession = createTool({
  id: "open-session",
  description: "Open a Steel cloud browser session.",
  inputSchema: z.object({}),
  outputSchema: z.object({
    sessionId: z.string(),
    liveViewUrl: z.string(),
  }),
  execute: async () => {
    const session = await steel.sessions.create({});
    const browser = await chromium.connectOverCDP(
      `${session.websocketUrl}&apiKey=${process.env.STEEL_API_KEY}`,
    );
    return { sessionId: session.id, liveViewUrl: session.sessionViewerUrl };
  },
});

const researchAgent = new Agent({
  id: "research-agent",
  name: "Steel Research",
  instructions: "You operate a Steel cloud browser via tools. ...",
  model: "anthropic/claude-haiku-4-5",
  tools: { openSession },
});

export const mastra = new Mastra({ agents: { researchAgent } });
```

Tools are passed as a record (not an array): the keys are what the model sees as tool names. With the registry wired up, `npx mastra dev` opens Studio at `http://localhost:4111` so you can chat with `research-agent` and inspect its tool calls live.

Full runnable starter: [Steel + Mastra recipe →](/cookbook/mastra)

Steel runs the same way across [other agent frameworks](/cookbook/topics/agents).

### FAQ

### Do I need to change my existing Mastra code to use Steel?

No — Steel lives inside a standard `createTool` definition whose `execute` opens a session and connects Playwright over CDP. Your `Agent`, the `Mastra` registry, Model Router strings, and Studio all work unchanged.

### How do I connect Mastra to a Steel browser session?

Define a `createTool` whose `execute` calls `steel.sessions.create()` and connects via `chromium.connectOverCDP()` with the session's `websocketUrl` plus `apiKey`, register it on an `Agent` via `tools`, and attach the agent to a top-level `Mastra` registry.

### Does Mastra work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — pass the options to `sessions.create()` inside your tool (e.g. `useProxy`, `solveCaptcha`, `stealthConfig`). Mastra's tool layer is unaffected because these are Steel session settings.

### Resources

*   [Mastra documentation](https://mastra.ai/docs) – Agents, tools, workflows, memory, and Studio
*   [Mastra Model Router](https://mastra.ai/blog/model-router) – Provider-agnostic model strings
*   [Mastra Studio](https://mastra.ai/docs/studio/overview) – Local playground for agents and workflows
*   [Steel Sessions API reference](/api-reference) – Programmatic session control for Steel browsers
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Notte
URL: https://docs.steel.dev/integrations/notte

Notte is a Python framework for building [reliable web agents](/cookbook/topics/agents) with stable navigation and structured outputs. The Steel integration connects a Notte session to a Steel browser, so Notte handles the agent loop and Steel handles the cloud browser plumbing.

Use it for tasks where reliable navigation and typed outputs matter more than autonomy.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Gemini API Key** (or another supported provider): Notte's default model is Gemini
*   **Python**: 3.11+

### Connect Steel to Notte

Pass Steel's CDP URL into a `notte.Session`:

```python
import notte
from steel import Steel

client = Steel(steel_api_key=STEEL_API_KEY)
session = client.sessions.create()
cdp_url = f"{session.websocket_url}&apiKey={STEEL_API_KEY}"

with notte.Session(cdp_url=cdp_url) as notte_session:
    agent = notte.Agent(
        session=notte_session,
        reasoning_model="gemini/gemini-2.5-flash",
    )
    response = agent.run(task="Go to Wikipedia and search for machine learning")
```

Full runnable starter: [Steel + Notte recipe →](/cookbook/notte)

### FAQ

### Do I need to change my existing Notte code to use Steel?

No — pass `cdp_url` into `notte.Session` and Notte runs its agent loop against the Steel browser. Your `notte.Agent` setup and `agent.run(task=...)` calls stay the same.

### How do I connect Notte to a Steel browser session?

Create a session with `client.sessions.create()`, build `cdp_url = f"{session.websocket_url}&apiKey={STEEL_API_KEY}"`, and open `notte.Session(cdp_url=cdp_url)` as a context manager, passing it to `notte.Agent(session=notte_session)`.

### Does Notte work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — enable them on `sessions.create()` (e.g. `use_proxy`, `solve_captcha`, `stealth_config`). Notte connects over the CDP URL and is unaware of how the session was provisioned.

### Which model does Notte use by default?

Gemini — the requirements call for a Gemini API key (or another supported provider), and the example sets `reasoning_model="gemini/gemini-2.5-flash"` on the agent. Python 3.11+ is required.

### Resources

*   [Notte documentation](https://docs.notte.cc) – Concepts, agent APIs, and examples
*   [Steel Sessions API reference](/api-reference) – Programmatic session control for Steel browsers
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# OpenAI Agents SDK + Steel Cloud Browsers
URL: https://docs.steel.dev/integrations/openai-agents-sdk

The OpenAI Agents SDK is OpenAI's official toolkit for building agents with typed tools, handoffs, guardrails, and tracing. The Steel integration runs each tool against a Steel cloud session, so you can stand up a [typed browser agent](/cookbook/topics/agents) that opens a session, navigates, extracts data, and returns a validated final report.

Available in TypeScript and Python.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **OpenAI API Key**: A model supported by the Agents SDK
*   **Runtime**: Node.js 20+ or Python 3.10+

### Connect Steel to the OpenAI Agents SDK

Open a Steel session inside a typed Agents SDK `tool()`:

```typescript
import { tool } from "@openai/agents";
import { chromium } from "playwright";
import Steel from "steel-sdk";
import { z } from "zod";

const steel = new Steel({ steelAPIKey: STEEL_API_KEY });

const openSession = tool({
  name: "open_session",
  description: "Open a Steel cloud browser session.",
  parameters: z.object({}),
  execute: async () => {
    const session = await steel.sessions.create({});
    const browser = await chromium.connectOverCDP(
      `${session.websocketUrl}&apiKey=${STEEL_API_KEY}`,
    );
    return { sessionId: session.id, liveViewUrl: session.sessionViewerUrl };
  },
});
```

Full runnable starter: [Steel + OpenAI Agents SDK recipe →](/cookbook/openai-agents)

### FAQ

### Do I need to change my existing OpenAI Agents SDK code to use Steel?

No — Steel slots in as a typed `tool()`. The tool's `execute` opens a Steel session and connects Playwright over CDP; your agents, handoffs, guardrails, and tracing work as before.

### How do I connect the OpenAI Agents SDK to a Steel browser session?

Inside a `tool()`'s `execute`, call `steel.sessions.create()` and attach Playwright via `chromium.connectOverCDP()` using the session's `websocketUrl` with your `apiKey` appended. Subsequent tools drive that browser.

### Does the OpenAI Agents SDK work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — pass the options when creating the session (e.g. `useProxy`, `solveCaptcha`, `stealthConfig` on `sessions.create()`). They're transparent to the SDK's tool-calling loop.

### Can I watch what the agent is doing in the browser?

Yes — the example tool returns `liveViewUrl` from `session.sessionViewerUrl`, which you can open to watch the session live alongside the agent run.

### Is the Steel integration available in both TypeScript and Python?

Yes — the Agents SDK integration works in TypeScript and Python (Node.js 20+ or Python 3.10+). The page's example is TypeScript; the cookbook recipe has the full runnable starter.

### Resources

*   [OpenAI Agents SDK documentation](https://openai.github.io/openai-agents-js/) – Agents, tools, handoffs, tracing
*   [Steel Sessions API reference](/api-reference) – Programmatic session control for Steel browsers
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# OpenAI Computer Use
URL: https://docs.steel.dev/integrations/openai-computer-use

The OpenAI Computer Use integration runs OpenAI's [vision-based agent loop](/cookbook/topics/computer-use) on a Steel browser session. The model takes screenshots through Steel, decides the next action (click, type, scroll), and Steel executes it, so you can automate complex web tasks without writing custom selectors.

It pairs well with Steel's anti-bot capabilities, proxy support, and sandboxed environments.

### Requirements

*   **OpenAI API Key**: An OpenAI model with computer use
*   **Steel API Key**: Active Steel subscription
*   **Runtime**: Python 3.10+ or Node.js 20+

### Connect Steel to OpenAI

Steel's `sessions.computer` API takes screenshots and executes actions; pair it with OpenAI's Responses API computer-use tool:

```typescript
import { Steel } from "steel-sdk";

const steel = new Steel({ steelAPIKey: STEEL_API_KEY });
const session = await steel.sessions.create({
  dimensions: { width: 1024, height: 768 },
});

// Take a screenshot via Steel
const { base64_image } = await steel.sessions.computer(session.id, {
  action: "take_screenshot",
});

// Send to OpenAI's Responses API with computer-use, then route returned
// actions back through `steel.sessions.computer({ action: ... })`.
```

Full runnable starter: [Steel + OpenAI Computer Use recipe →](/cookbook/openai-computer-use)

### FAQ

### How do I connect OpenAI Computer Use to a Steel browser session?

Create a session with explicit `dimensions` (the example uses 1024x768), take a screenshot with `steel.sessions.computer(session.id, { action: "take_screenshot" })`, send the `base64_image` to OpenAI's Responses API computer-use tool, and route returned actions back through `steel.sessions.computer({ action: ... })`.

### Does OpenAI Computer Use work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — the page notes it pairs well with Steel's anti-bot capabilities, proxy support, and sandboxed environments. These are session-creation options (e.g. `useProxy`, `solveCaptcha`, `stealthConfig`) and don't change the agent loop.

### How does the screenshot/action loop work?

Steel screenshots the session, you send the image to the Responses API with the computer-use tool, the model returns the next action, and Steel executes it via `sessions.computer` — then the loop repeats until the task is done.

### Resources

*   [OpenAI Computer Use documentation](https://platform.openai.com/docs/guides/tools-computer-use) – Official documentation from OpenAI
*   [Steel Sessions API reference](/api-reference) – Technical details for managing Steel browser sessions
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# OpenClaw
URL: https://docs.steel.dev/integrations/openclaw

The OpenClaw integration gives OpenClaw a real cloud browser through the Steel CLI. Once Steel CLI is on your `PATH`, OpenClaw can run multi-step web tasks, scrape rendered pages, watch live browser state, and work through forms and dynamic pages without writing custom integration code.

Good fit for application forms, operational workflows, and browser-driven research. The same setup works with [other agent frameworks](/cookbook/topics/agents) like CrewAI and LangGraph.

### Requirements

*   **Node.js**: Version 18 or higher
*   **OpenClaw**: Installed locally
*   **Steel CLI**: Installed locally
*   **Steel API Key**: Active Steel account

### Setup

#### Step 1: Install Steel CLI

```bash
curl -LsSf https://setup.steel.dev | sh
```

#### Step 2: Log in

```bash
steel login
```

#### Step 3: Install the browser skill (recommended)

The `steel-browser` skill gives OpenClaw better command discovery and more reliable browser workflows.

```bash
npx skills add steel-dev/skills --skill steel-browser -a opencode -g
```

Restart OpenClaw after installing so it can discover the skill.

### Example workflow

OpenClaw works well on browser tasks that need real interaction. One example is filling out a conference CFP form: finding the right page, inspecting the fields, drafting responses, and working through the submission flow.

You can start with a prompt like:

```
I would like to submit an application for the call for speakers for AI Engineer World's Fair. Could you figure out what the fields are, what we need, and how I can apply to become a speaker?
```

From there, OpenClaw can start a Steel session, navigate the form, inspect fields with snapshots, and work through the page step by step.

### Watching the session

Steel sessions return a viewer URL so you can watch the browser while the agent works. This is useful on form-heavy flows, especially when the page changes dynamically, a modal blocks progress, or the agent needs a second attempt to recover from a mistake.

For longer workflows, Steel also keeps the full session history so you can inspect what happened after the fact.

### Where OpenClaw works best

OpenClaw is a strong fit for:

*   Standard web forms
*   Multi-step browser workflows
*   Pages that need JavaScript rendering before the agent can reason about them

For tasks that only need page content, `steel scrape` is often the faster option:

```bash
steel scrape https://example.com
```

### Authenticated workflows

For sites behind login, prepare reusable auth state in Steel ahead of time rather than asking the agent to log in from scratch every time.

See:

*   [Profiles API](/overview/profiles-api/overview)
*   [Reusing Auth Context](/overview/sessions-api/reusing-auth-context)

### Constraints

*   **Command approvals depend on your OpenClaw settings.** Shell access may require approval depending on your configuration.
*   **Form-heavy workflows can still take time.** Dynamic fields, validation errors, and bot checks add retries and extra browser steps.
*   **Authenticated sites work best with prepared Steel auth state.** Reusing profiles or auth context is generally more reliable than repeated interactive logins.

### FAQ

### Do I need to write integration code to use Steel with OpenClaw?

No — the integration works through the Steel CLI. Install it with `curl -LsSf https://setup.steel.dev | sh`, run `steel login`, and once the CLI is on your `PATH` OpenClaw can run multi-step web tasks, scrape rendered pages, and work through forms without custom integration code.

### How does OpenClaw connect to a Steel browser session?

Via Steel CLI commands in its shell. Install the `steel-browser` skill for better command discovery — `npx skills add steel-dev/skills --skill steel-browser -a opencode -g` — then restart OpenClaw so it discovers the skill.

### Does OpenClaw work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — those are properties of the Steel session, not the agent. Sessions OpenClaw starts run on the same Sessions API that supports `useProxy`, `solveCaptcha`, and `stealthConfig` at creation time; the page notes bot checks can still add retries on form-heavy flows.

### When should I use `steel scrape` instead of a full OpenClaw browser session?

When you only need page content — `steel scrape https://example.com` is often faster than spinning up an interactive session. Save full sessions for forms, multi-step workflows, and pages that need JavaScript rendering before the agent can reason about them.

### How do I monitor or debug what OpenClaw is doing in the browser?

Steel sessions return a viewer URL so you can watch the browser live — useful on form-heavy flows when a modal blocks progress or the agent needs a second attempt. For longer workflows, Steel keeps the full session history for after-the-fact inspection.

### Resources

*   [OpenClaw + Steel blog post](https://steel.dev/blog/openclaw-steel-browser-let-your-ai-agent-fill-the-forms) – Case study using OpenClaw to work through a CFP submission flow
*   [Steel CLI docs](/overview/steel-cli) – Full command reference and workflows
*   [Steel Skills](/overview/skills) – Installable skills for coding agents
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Pi Agent
URL: https://docs.steel.dev/integrations/pi-agent

This integration is part of Steel's experiments effort. Defaults can change without notice, and stability is not guaranteed.

[Pi](https://pi.dev/) is a minimal, extension-first coding agent that ships with no built-in browser; capabilities arrive through `pi install`. The Pi integration installs as a native Pi extension that gives Pi a Steel cloud browser, so Pi can navigate, scrape rendered pages, extract structured data, capture screenshots and PDFs, and fill forms across prompts. Steel plugs into [other agents and frameworks](/cookbook/topics/agents) the same way, with a runnable recipe for each.

### Requirements

*   **Node.js**: Version 18 or higher
*   **Pi**: Installed locally
*   **Steel API Key**: Active Steel account

### Setup

#### Step 1: Install the extension

```bash
pi install npm:@steel-dev/pi-steel
```

Pi picks up the extension on the next run and the Steel browser tools become available automatically.

#### Step 2: Authenticate

Set `STEEL_API_KEY` in your environment.

Grab a free API key at [app.steel.dev](https://app.steel.dev) if you do not have one yet.

### Available tools

Once the extension is installed, Pi has access to a full browser toolset:

*   `steel_navigate` and `steel_scrape` for fetching pages as text, markdown, or HTML
*   `steel_extract` for structured data from a JSON schema
*   `steel_fill_form` for submitting forms
*   Playwright-backed computer actions (click, scroll, type) for pages that scraping cannot reach
*   Screenshot and PDF capture, returned as Pi artifacts
*   `steel_pin_session` to keep a browser alive across prompts, or `STEEL_SESSION_MODE=session` for persistent mode

CAPTCHA handling is built in, so most bot-protected pages work without extra configuration.

### Example workflow

Pi works well on research tasks that span multiple pages and require both scraping and interaction. A prompt like:

```
Visit apple.com and compare all recent MacBook models.
```

Pi will navigate the Mac lineup, follow links into each model page, and fall back to computer actions and screenshots when sticky navigation or viewport-dependent sections block plain scraping.

For bot-protected docs:

```
Visit OpenAI docs and tell us how we can use the latest model.
```

Steel handles the bot-protection layer so Pi sees a normal webpage, reads the current API reference, and returns an up-to-date code example rather than a stale one from training data.

### Structured extraction

When you would otherwise parse markdown for prices, specs, or listings, `steel_extract` with a JSON schema is usually the better call. Pi gets typed output directly, which is easier to reason about across multi-step workflows.

### Constraints

*   **Command approvals depend on your Pi settings.** Extension tools may require approval depending on your configuration.
*   **First runs are usually the roughest.** Dynamic web apps often need a few retries before the workflow is stable.
*   **Authenticated sites work best with prepared Steel auth state.** Reusing profiles or auth context is generally more reliable than repeated interactive logins.

### Resources

*   [pi-steel: we gave Pi a real browser in one command](https://steel.dev/blog/we-gave-pi-agent-a-real-browser) – Blog post on the extension and example runs
*   [`@steel-dev/pi-steel` on GitHub](https://github.com/steel-dev/pi-steel) – Source and issues
*   [`@steel-dev/pi-steel` on npm](https://www.npmjs.com/package/@steel-dev/pi-steel) – Package page
*   [Steel CLI docs](/overview/steel-cli) – Full command reference and workflows
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Run Playwright on Steel Cloud Browsers
URL: https://docs.steel.dev/integrations/playwright

Playwright is Microsoft's [cross-browser automation library](/cookbook/topics/playwright). The Steel integration attaches Playwright to a Steel cloud session over the Chrome DevTools Protocol, so the rest of your script — `page.goto`, locators, `expect`, tracing — drives a remote browser with stealth, proxies, and a live viewer instead of a local Chromium.

No `playwright install`, no headful display, no Chrome on your machine.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Runtime**: Node.js 20+ or Python 3.10+
*   **Package**: `playwright` (TypeScript) or `playwright` (Python)

### Connect Steel to Playwright

Pass Steel's CDP URL into `chromium.connectOverCDP()`. Steel returns a context with a page already open, so reuse `browser.contexts()[0]` instead of creating a new one:

```typescript
import { chromium } from "playwright";
import Steel from "steel-sdk";

const client = new Steel({ steelAPIKey: STEEL_API_KEY });
const session = await client.sessions.create();

const browser = await chromium.connectOverCDP(
  `${session.websocketUrl}&apiKey=${STEEL_API_KEY}`,
);
const page = browser.contexts()[0].pages()[0];
```

```python
from playwright.sync_api import sync_playwright
from steel import Steel

client = Steel(steel_api_key=STEEL_API_KEY)
session = client.sessions.create()

playwright = sync_playwright().start()
browser = playwright.chromium.connect_over_cdp(
    f"{session.websocket_url}&apiKey={STEEL_API_KEY}"
)
page = browser.contexts[0].new_page()
```

Full runnable starter: [Steel + Playwright recipe →](/cookbook/playwright)

### FAQ

### Do I need to change my existing Playwright code to use Steel?

No — swap your local launch for `chromium.connectOverCDP()` (or `connect_over_cdp` in Python) pointed at the Steel session's `websocketUrl`. The rest of your script — `page.goto`, locators, `expect`, tracing — runs unchanged against the remote browser.

### How do I connect Playwright to a Steel browser session?

Create a session with `client.sessions.create()`, then connect with `chromium.connectOverCDP()` in Node or `playwright.chromium.connect_over_cdp()` in Python, passing the session's `websocketUrl` with your `apiKey` appended as a query parameter.

### Does Playwright work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — those are options on `sessions.create()` (e.g. `useProxy`, `solveCaptcha`, `stealthConfig`), not Playwright settings. Playwright just sees a normal CDP browser; stealth, proxies, and the live viewer come from the Steel session.

### Do I still need to run `playwright install` or have Chrome installed locally?

No — the browser runs in Steel's cloud, so there's no `playwright install`, no headful display, and no Chrome on your machine. You only need the `playwright` package and a Steel API key.

### Resources

*   [Playwright documentation](https://playwright.dev) – Official Playwright docs for TypeScript and Python
*   [Steel Sessions API reference](/api-reference#tag/sessions) – Technical details for managing Steel browser sessions
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Connect Puppeteer to Steel Cloud Browsers (CDP)
URL: https://docs.steel.dev/integrations/puppeteer

Puppeteer is Chrome's reference [Node.js automation library](/cookbook/topics/browser-automation). The Steel integration attaches Puppeteer to a Steel cloud session through `puppeteer.connect()`, so `page.goto`, `page.evaluate`, `page.waitForSelector`, and the rest of the surface drive a remote browser. Stealth, proxies, and the live session viewer come from Steel without extra wiring.

The package is `puppeteer-core` — there's no Chromium to download because the browser lives on Steel.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Runtime**: Node.js 20+
*   **Package**: `puppeteer-core`

### Connect Steel to Puppeteer

Pass Steel's CDP URL into `puppeteer.connect()` as `browserWSEndpoint`. Open a fresh tab with `browser.newPage()`:

```typescript
import puppeteer from "puppeteer-core";
import Steel from "steel-sdk";

const client = new Steel({ steelAPIKey: STEEL_API_KEY });
const session = await client.sessions.create();

const browser = await puppeteer.connect({
  browserWSEndpoint: `${session.websocketUrl}&apiKey=${STEEL_API_KEY}`,
});
const page = await browser.newPage();
```

Full runnable starter: [Steel + Puppeteer recipe →](/cookbook/puppeteer)

### FAQ

### Do I need to change my existing Puppeteer code to use Steel?

No — replace `puppeteer.launch()` with `puppeteer.connect()` and pass Steel's CDP URL as `browserWSEndpoint`. `page.goto`, `page.evaluate`, `page.waitForSelector`, and the rest of the API work unchanged against the remote browser.

### How do I connect Puppeteer to a Steel browser session?

Create a session with `client.sessions.create()`, then call `puppeteer.connect()` with `browserWSEndpoint` set to the session's `websocketUrl` (with your `apiKey` appended) and open a tab with `browser.newPage()`.

### Resources

*   [Puppeteer documentation](https://pptr.dev) – Official Puppeteer API reference
*   [Steel Sessions API reference](/api-reference#tag/sessions) – Technical details for managing Steel browser sessions
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Build a Pydantic AI Browser Agent on Steel
URL: https://docs.steel.dev/integrations/pydantic-ai

[Pydantic AI](https://ai.pydantic.dev/) is the Pydantic team's [agent framework](/cookbook/topics/agents). It's provider-agnostic and reuses the Pydantic models you'd already validate API I/O with for tool arguments and final outputs, so adding an agent to a typed Python codebase doesn't introduce a parallel schema layer. The Steel integration passes a Playwright `Page` through `RunContext.deps`, so every tool in an agent run sees the same cloud browser without module globals.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Model provider key**: OpenAI, Anthropic, Google, or any other Pydantic AI-supported provider
*   **Python**: 3.10+

### Connect Steel to Pydantic AI

Define a `BrowserDeps` dataclass holding the Steel-backed Playwright `Page`, register tools that read `ctx.deps.page`, and pass `deps=` to `agent.run`:

```python
from dataclasses import dataclass
from playwright.async_api import Page, async_playwright
from pydantic_ai import Agent, RunContext
from steel import Steel

@dataclass
class BrowserDeps:
    page: Page

async def navigate(ctx: RunContext[BrowserDeps], url: str) -> dict:
    """Navigate to a URL and wait for the page to load."""
    await ctx.deps.page.goto(url, wait_until="domcontentloaded")
    return {"url": ctx.deps.page.url, "title": await ctx.deps.page.title()}

agent = Agent(
    "openai:gpt-5-mini",
    deps_type=BrowserDeps,
    tools=[navigate],
)

steel = Steel(steel_api_key=STEEL_API_KEY)
session = steel.sessions.create()
playwright = await async_playwright().start()
browser = await playwright.chromium.connect_over_cdp(
    f"{session.websocket_url}&apiKey={STEEL_API_KEY}"
)
page = browser.contexts[0].pages[0]

result = await agent.run("Open example.com and report the title.", deps=BrowserDeps(page=page))
```

Full runnable starter: [Steel + Pydantic AI recipe →](/cookbook/pydantic-ai)

### FAQ

### Do I need to change my existing Pydantic AI code to use Steel?

No — Steel enters through dependency injection. You connect Playwright to a Steel session over CDP and pass the resulting `Page` in via `deps=BrowserDeps(page=page)`; your agent, tools, and output models stay the same.

### How do I connect Pydantic AI to a Steel browser session?

Create a session with `steel.sessions.create()`, connect via `chromium.connect_over_cdp(f"{session.websocket_url}&apiKey={STEEL_API_KEY}")`, take `browser.contexts[0].pages[0]`, and pass it as `deps=BrowserDeps(page=page)` to `agent.run()`.

### Does Pydantic AI work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — set them on `sessions.create()` (e.g. `use_proxy`, `solve_captcha`, `stealth_config`). The agent only sees a Playwright `Page` through `ctx.deps`, so session options never leak into tool code.

### Resources

*   [Pydantic AI documentation](https://ai.pydantic.dev/) – Agents, tools, output validators, retries, and Logfire integration
*   [Steel Sessions API reference](/api-reference) – Programmatic session control for Steel browsers
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# Replit
URL: https://docs.steel.dev/integrations/replit

Run [Steel browser automation scripts](/cookbook/topics/browser-automation) in Replit's cloud, no local setup. Templates ship for Python and Node.js with Playwright, Puppeteer, and Selenium.

A good fit for prototyping, scheduled jobs, and sharing runnable examples.

### Requirements

*   **Steel API Key**: Any plan; get a free key [here](https://app.steel.dev/settings/api-keys)
*   **Replit account**: Free tier available
*   **Languages**: Python and Node.js (full list of supported languages [here](https://replit.com/templates/languages))

### Starter Templates

*   [**Steel Puppeteer Starter**](https://replit.com/@steel-dev/steel-puppeteer-starter) – Node.js template using Puppeteer
*   [**Steel Playwright Starter**](https://replit.com/@steel-dev/steel-playwright-starter) – Node.js template using Playwright
*   [**Steel Playwright Python Starter**](https://replit.com/@steel-dev/steel-playwright-python-starter) – Python template using Playwright
*   [**Steel Selenium Starter**](https://replit.com/@steel-dev/steel-selenium-starter) – Python template using Selenium

### Running a template

1.  Hit "Remix this Template" to fork the template (requires a free Replit account)
2.  Add your `STEEL_API_KEY` to the secrets pane (located under "Tools" on the left hand pane)
3.  Hit Run

Don't have an API key? Get a free key at [app.steel.dev/settings/api-keys](http://app.steel.dev/settings/api-keys).

### FAQ

### Do I need any local setup to run Steel automation on Replit?

No — everything runs in Replit's cloud. Remix a Steel starter template, add your `STEEL_API_KEY` to the secrets pane, and hit Run.

### Where do I put my Steel API key in Replit?

Add `STEEL_API_KEY` in the secrets pane, found under "Tools" in the left-hand pane. Any plan works — you can get a free key at app.steel.dev/settings/api-keys.

### What is the Replit integration good for?

Prototyping, scheduled jobs, and sharing runnable examples — the templates give you working Steel scripts in Playwright, Puppeteer, or Selenium without configuring a local environment. A free Replit account is enough to remix and run them.

### Resources

*   [Replit documentation](https://docs.replit.com) – Learn more about Replit's features
*   [Steel Sessions API overview](/overview/sessions-api/overview) – Learn about Steel's Sessions API
*   [Steel Discord](https://discord.gg/steel-dev) – Get help from the Steel team


# Run Selenium in the Cloud on Steel
URL: https://docs.steel.dev/integrations/selenium

Selenium speaks the W3C WebDriver protocol over HTTP, not CDP. Steel runs a WebDriver endpoint at `http://connect.steelbrowser.com/selenium`. Point `webdriver.Remote` at it, attach the Steel API key and session ID as headers on every request, and the rest is plain Selenium 4.

Sessions for Selenium need `is_selenium=True` on creation — that flag provisions a WebDriver-compatible node. Without it you get a CDP browser that Selenium cannot drive.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **Runtime**: Python 3.10+
*   **Package**: `selenium` 4+

### Connect Steel to Selenium

Subclass `RemoteConnection` to inject `steel-api-key` and `session-id` headers on every WebDriver request, then point `webdriver.Remote` at Steel's WebDriver endpoint:

```python
import os
from selenium import webdriver
from selenium.webdriver.remote.remote_connection import RemoteConnection
from steel import Steel

class SteelRemoteConnection(RemoteConnection):
    def __init__(self, remote_server_addr: str, session_id: str):
        super().__init__(remote_server_addr)
        self._session_id = session_id

    def get_remote_connection_headers(self, parsed_url, keep_alive=False):
        headers = super().get_remote_connection_headers(parsed_url, keep_alive)
        headers["steel-api-key"] = os.environ["STEEL_API_KEY"]
        headers["session-id"] = self._session_id
        return headers

client = Steel(steel_api_key=STEEL_API_KEY)
session = client.sessions.create(is_selenium=True)

driver = webdriver.Remote(
    command_executor=SteelRemoteConnection(
        remote_server_addr="http://connect.steelbrowser.com/selenium",
        session_id=session.id,
    ),
    options=webdriver.ChromeOptions(),
)
```

Each command is an HTTP round-trip, so prefer `WebDriverWait` with `expected_conditions` over blind `time.sleep` to avoid compounding latency.

Full runnable starter: [Steel + Selenium recipe →](/cookbook/selenium)

Steel drives [Playwright and Puppeteer](/cookbook/topics/browser-automation) the same way.

### FAQ

### Do I need to change my existing Selenium code to use Steel?

Mostly no — your test logic stays plain Selenium 4. The change is in the connection: point `webdriver.Remote` at Steel's WebDriver endpoint and inject the `steel-api-key` and `session-id` headers on every request via a small `RemoteConnection` subclass.

### How do I connect Selenium to a Steel browser session?

Steel runs a WebDriver endpoint at `http://connect.steelbrowser.com/selenium`. Create a session with `client.sessions.create(is_selenium=True)`, then pass a `RemoteConnection` subclass that adds `steel-api-key` and `session-id` headers as the `command_executor` for `webdriver.Remote`.

### Does Selenium work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — those are set when you create the session (e.g. `use_proxy`, `solve_captcha`, `stealth_config` on `sessions.create`), so Selenium doesn't need to know about them. Your WebDriver commands run against the session however it was provisioned.

### Why does my session need `is_selenium=True`?

Because Selenium speaks the W3C WebDriver protocol over HTTP, not CDP. `is_selenium=True` provisions a WebDriver-compatible node — without it you get a CDP browser that Selenium cannot drive.

### Resources

*   [Selenium Python documentation](https://selenium-python.readthedocs.io) – Official Python bindings reference
*   [WebDriver protocol](https://w3c.github.io/webdriver/) – W3C specification
*   [Steel Sessions API reference](/api-reference#tag/sessions) – Technical details for managing Steel browser sessions
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# StackBlitz / Bolt.new
URL: https://docs.steel.dev/integrations/stackblitz-bolt.new

Run [Steel browser automation scripts](/cookbook/topics/browser-automation) in StackBlitz directly in your browser, with no local setup or installation. Well-suited for quick prototyping, sharing running examples, and collaborative development.

With [Bolt.new](http://bolt.new/), StackBlitz's AI-powered web development agent, you can write and modify Steel scripts in natural language alongside your code.

### Requirements

*   **Steel API Key**: Any plan; get a free key [here](https://app.steel.dev/settings/api-keys)
*   **Languages**: JavaScript and TypeScript (Steel templates only; StackBlitz has limited Python support)
*   **No account required** to run code (only to save changes)

### Starter Templates

*   [**Steel Puppeteer Starter**](https://stackblitz.com/edit/steel-puppeteer-starter) – Node.js template using Puppeteer
*   [**Steel Playwright Starter**](https://stackblitz.com/edit/steel-playwright-starter) – Node.js template using Playwright

### Running a template

1.  Click on the template link above to open it in StackBlitz
2.  Set your `STEEL_API_KEY` in one of two ways:
    *   Export it in the terminal: `export STEEL_API_KEY=your_key_here`
    *   Create a `.env` file and add: `STEEL_API_KEY=your_key_here`
3.  Run `npm run` in the terminal to run the script

No account is required to run or even edit the templates. You only need to sign in if you want to save your changes.

### AI-powered development with Bolt.new

All our StackBlitz templates can be opened in [Bolt.new](http://bolt.new/), an AI-powered web development agent built on StackBlitz's WebContainer technology. With Bolt.new you can:

*   Use natural language prompts to modify Steel automation scripts
*   Build full-stack applications around Steel's capabilities
*   Get AI assistance while developing your browser automation workflows
*   Deploy your projects with zero configuration

Look for the *Open in Bolt.new* button on our templates to get started with AI-assisted development.

### FAQ

### Do I need a StackBlitz account or any installation to run Steel templates?

No — the templates run directly in your browser with no local setup, and no account is required to run or even edit them. You only need to sign in if you want to save your changes.

### How do I set my Steel API key in StackBlitz?

Two ways: export it in the terminal with `export STEEL_API_KEY=your_key_here`, or create a `.env` file containing `STEEL_API_KEY=your_key_here`. Then run `npm run` in the terminal to execute the script.

### How does Bolt.new fit into the Steel integration?

All Steel StackBlitz templates can be opened in Bolt.new, StackBlitz's AI web-development agent built on WebContainer technology. From there you can modify Steel scripts with natural-language prompts, build full-stack apps around Steel, and deploy with zero configuration — look for the "Open in Bolt.new" button on the templates.

### Resources

*   [StackBlitz documentation](https://developer.stackblitz.com/) – Learn more about StackBlitz's features
*   [Steel Sessions API overview](/overview/sessions-api/overview) – Learn about Steel's Sessions API
*   [Steel Discord](https://discord.gg/steel-dev) – Get help from the Steel team


# Run Stagehand on Steel Cloud Browsers
URL: https://docs.steel.dev/integrations/stagehand

Stagehand is an open-source library for writing [browser automations](/cookbook/topics/browser-automation) in natural language using `act`, `extract`, and `observe` calls. The Steel integration drives Stagehand against a Steel cloud session, so you can replace fragile selectors with instructions like "click the login button" or "extract the top 3 stories". Same API in TypeScript or Python.

Good fit for research, scraping, and form workflows.

### Requirements

*   **Steel API Key**: Active Steel subscription
*   **OpenAI API Key**: Stagehand's default model provider
*   **Runtime**: Node.js 20+ or Python 3.10+

### Connect Steel to Stagehand

Pass Steel's CDP URL as `localBrowserLaunchOptions.cdpUrl`:

```typescript
import { Stagehand } from "@browserbasehq/stagehand";
import Steel from "steel-sdk";

const client = new Steel({ steelAPIKey: STEEL_API_KEY });
const session = await client.sessions.create({});

const stagehand = new Stagehand({
  env: "LOCAL",
  localBrowserLaunchOptions: {
    cdpUrl: `${session.websocketUrl}&apiKey=${STEEL_API_KEY}`,
  },
  model: { modelName: "openai/gpt-5", apiKey: OPENAI_API_KEY },
});
await stagehand.init();
```

Full runnable starter: [Steel + Stagehand recipe →](/cookbook/stagehand)

### FAQ

### Do I need to change my existing Stagehand code to use Steel?

No — your `act`, `extract`, and `observe` calls stay the same. The only change is constructing `Stagehand` with `env: "LOCAL"` and Steel's CDP URL in `localBrowserLaunchOptions.cdpUrl`.

### How do I connect Stagehand to a Steel browser session?

Create a session with `client.sessions.create()`, then set `cdpUrl` inside `localBrowserLaunchOptions` to the session's `websocketUrl` with your `apiKey` appended, and call `stagehand.init()`.

### Does Stagehand work with Steel's proxies, stealth mode, and CAPTCHA solving?

Yes — set them when creating the Steel session (e.g. `useProxy`, `solveCaptcha`, `stealthConfig` on `sessions.create`). Stagehand connects over CDP and is unaware of how the session was provisioned.

### Resources

*   [Stagehand documentation](https://docs.stagehand.dev/first-steps/introduction) – Official documentation for Stagehand
*   [Steel Sessions API reference](/api-reference#tag/sessions) – Technical details for managing Steel browser sessions
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share what you build


# x402
URL: https://docs.steel.dev/integrations/x402

The x402 integration lets you call [Steel's web actions](/cookbook/topics/steel-apis) — scrape, screenshot, and PDF — and pay per request with USDC on Base or Solana. It's built on the [x402 protocol](https://www.x402.org/), so there are no API keys or accounts: you pay for each call with your wallet.

**Endpoint:** `https://x402.steel.dev`

### How it works

1.  **Send a request** to one of the endpoints below. The server responds with `402 Payment Required` and the payment details (amount, networks, and recipient).
2.  **Sign a payment authorization** for the requested amount with your wallet. An x402 client library does this for you.
3.  **Resend the request** with the signed `X-PAYMENT` header. You receive your data with a `200 OK` response.

### Available endpoints

All endpoints take a `POST` with a JSON body and return JSON.

| Endpoint              | What it does                                                                 |
|-----------------------|------------------------------------------------------------------------------|
| `POST /v1/scrape`     | Fetch a rendered page as HTML, Markdown, or readable text — optionally with a screenshot or PDF |
| `POST /v1/screenshot` | Capture a screenshot of a page                                               |
| `POST /v1/pdf`        | Render a page to PDF                                                         |

#### Request parameters

| Parameter    | Type       | Endpoints     | Description                                                              |
|--------------|------------|---------------|--------------------------------------------------------------------------|
| `url`        | `string`   | all           | URL of the page to load. **Required.**                                   |
| `format`     | `string[]` | scrape        | Output format(s): `html`, `readability`, `cleaned_html`, `markdown`. Defaults to `html`. |
| `screenshot` | `boolean`  | scrape        | Include a screenshot in the response.                                    |
| `pdf`        | `boolean`  | scrape        | Include a PDF in the response.                                           |
| `fullPage`   | `boolean`  | screenshot    | Capture the full scrollable page.                                        |
| `delay`      | `number`   | all           | Delay before acting, in milliseconds.                                    |
| `useProxy`   | `boolean`  | all           | Route through a Steel-provided residential proxy.                        |
| `region`     | `string`   | all           | Region to run the action in.                                             |

### Pricing

Each request costs **$0.01 in USDC** (`10000` base units), charged on whichever network you pay from. There's no subscription and no per-hour billing — you pay only for the requests you make.

### Requirements

*   **Wallet**: Base or Solana wallet with USDC

### Supported tokens and networks

| Network                | USDC Contract Address                             |
|------------------------|---------------------------------------------------|
| Base (mainnet)         | **0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913**    |
| Solana (mainnet)       | **EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v**  |

### Quickstart

First, see the payment requirements by sending an unpaid request:

```bash
curl -i -X POST https://x402.steel.dev/v1/scrape \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "format": ["markdown"]}'
```

You'll get a `402 Payment Required` describing the amount and the Base and Solana payment options.

To complete the call, use an x402 client library — it reads the `402`, signs the payment, and retries automatically. For example, with [`x402-fetch`](https://www.x402.org/):

```ts
import { wrapFetchWithPayment } from "x402-fetch";
import { privateKeyToAccount } from "viem/accounts";

const account = privateKeyToAccount(process.env.PRIVATE_KEY);
const fetchWithPay = wrapFetchWithPayment(fetch, account);

const res = await fetchWithPay("https://x402.steel.dev/v1/scrape", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ url: "https://example.com", format: ["markdown"] }),
});

const data = await res.json();
```

### FAQ

### Do I need a Steel account or API key to use x402?

No — x402 is pay-per-use with cryptocurrency. You call the scrape, screenshot, and PDF endpoints by paying with USDC on Base or Solana, with no API keys or accounts required.

### How do I make a request over x402?

Send a `POST` to an endpoint like `https://x402.steel.dev/v1/scrape`; the server responds with `402 Payment Required`. Sign a payment authorization for the requested amount with your wallet (an x402 client does this for you), resend the request with the signed `X-PAYMENT` header, and you get your data with a `200 OK`.

### How much does it cost?

$0.01 in USDC per request, paid on Base or Solana. All you need is a wallet holding USDC.

### Resources

*   [x402 protocol](https://www.x402.org/) – Learn more about the x402 protocol
*   [Steel API reference](/api-reference) – Full reference for the scrape, screenshot, and PDF endpoints
*   [Steel Discord](https://discord.gg/steel-dev) – Get help and share your implementations


# Authentication
URL: https://docs.steel.dev/overview/authentication

Every request to Steel is authenticated with an API key tied to your organization. This page covers how to get a key, how to use it with the REST API, SDKs, and WebSocket connections, and how to manage and rotate keys over time.

### Overview

Steel uses API key authentication. Once you've created a key in the dashboard, you pass it to Steel one of three ways depending on the interface you're using:

- **REST API**: as the `steel-api-key` HTTP header
- **SDKs (Node.js / Python)**: as a client option, or via the `STEEL_API_KEY` environment variable
- **Browser connections (CDP over WebSocket)**: as the `apiKey` query parameter on `wss://connect.steel.dev`

A single key grants access to your entire organization's Steel resources: sessions, files, credentials, profiles, and everything else. Treat it like a password.

### Getting Your API Key

1. Sign in at [app.steel.dev](https://app.steel.dev).
2. Open **Settings → API Keys** ([direct link](https://app.steel.dev/settings/api-keys)).
3. Click **Create API Key**, give it a descriptive name (e.g. `production`, `local-dev`, `ci`), and copy the value.

You can also provision a Steel project and `STEEL_API_KEY` from the Stripe CLI with
[`stripe projects add steel/browser`](/overview/stripe-projects).

### Save your key somewhere safe

The full key is only shown once at the moment of creation. If you lose it, you'll need to delete the key and create a new one.

### Setting Up Environment Variables

Both SDKs and most example code in these docs assume your key is available as the `STEEL_API_KEY` environment variable. The easiest setup is a `.env` file in your project root:

```bash
STEEL_API_KEY=ste-your-api-key-here
```

Make sure `.env` is listed in your `.gitignore` so the key never lands in version control.

### Using the API Key

#### SDKs

If `STEEL_API_KEY` is set in your environment, the official SDKs will pick it up automatically. You don't need to pass anything explicitly.

```typescript
import Steel from 'steel-sdk';

// Reads STEEL_API_KEY from the environment
const client = new Steel();

const session = await client.sessions.create();
```

```python
from steel import Steel

# Reads STEEL_API_KEY from the environment
client = Steel()

session = client.sessions.create()
```

You can also pass the key explicitly, which is useful when you manage multiple keys or fetch the value from a secrets manager at runtime:

```typescript
import Steel from "steel-sdk";

const client = new Steel({
  steelAPIKey: process.env.STEEL_API_KEY,
});
```

```python
import os
from steel import Steel

client = Steel(
    steel_api_key=os.environ["STEEL_API_KEY"],
)
```

#### REST API

When calling the REST API directly, send your key in the `steel-api-key` header.

```bash
curl https://api.steel.dev/v1/sessions \
  -H "steel-api-key: $STEEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

The same header works for every authenticated endpoint: sessions, files, credentials, profiles, extensions, and so on. See the [API Reference](/api-reference) for the full list.

#### Browser Connections (CDP over WebSocket)

When connecting an automation framework (Playwright, Puppeteer, Selenium) to a live Steel session over the Chrome DevTools Protocol, pass your key as the `apiKey` query parameter on the `wss://connect.steel.dev` endpoint:

```typescript
import { chromium } from "playwright";

const browser = await chromium.connectOverCDP(
  `wss://connect.steel.dev?apiKey=${process.env.STEEL_API_KEY}&sessionId=${session.id}`,
);
```

```python
import os
from playwright.sync_api import sync_playwright

playwright = sync_playwright().start()
browser = playwright.chromium.connect_over_cdp(
    f"wss://connect.steel.dev?apiKey={os.environ['STEEL_API_KEY']}&sessionId={session.id}"
)
```

The `sessionId` parameter is optional: if omitted, Steel will start a new session with default settings and connect you to it. See the framework-specific guides for full examples: [Puppeteer](/cookbook/puppeteer), [Playwright](/cookbook/playwright), [Playwright (Python)](/cookbook/playwright-python), [Selenium](/cookbook/selenium).

#### Steel CLI

The [Steel CLI](/overview/steel-cli) authenticates with the same keys. The easiest way is:

```bash
steel login
```

This walks you through signing in and stores a key locally. Alternatively, set `STEEL_API_KEY` in your environment and the CLI will use it directly: useful for CI and non-interactive environments.

### Managing Your API Keys

All key management happens in the [API Keys dashboard](https://app.steel.dev/settings/api-keys). From there you can:

- **Create** a new key: pick a clear name so you can later identify where it's being used.
- **View** the list of existing keys, when they were created, and when they were last used.
- **Delete** a key: this immediately revokes it. Any service still using that key will start receiving `401 Unauthorized` responses.

Steel does not currently expose key management through the public API: keys are only created and deleted through the dashboard.

#### Rotating Keys

To rotate a key without downtime:

1. Create a new API key in the dashboard.
2. Roll the new key out to all the places that use it (environment variables, secret managers, CI, etc.).
3. Verify traffic is flowing under the new key (the "Last used" timestamp in the dashboard will update).
4. Delete the old key.

We recommend using separate keys per environment (e.g. `production`, `staging`, `local-dev`) so you can rotate them independently and narrow the blast radius of a leak.

### Security Best Practices

- **Never commit keys to source control.** Use `.env` files (and `.gitignore` them), your platform's secret manager, or CI secrets.
- **Never ship keys in client-side code.** Steel keys are meant for trusted server-side environments. Exposing one in a browser bundle, mobile app, or public repo effectively makes it public.
- **Scope keys per environment.** One key per environment (prod, staging, dev) makes incidents easier to contain.
- **Rotate on suspicion.** If a key might have been exposed (in logs, a screenshare, a repo, a CI job), delete it and create a new one immediately.
- **Use the `name` field.** Naming your keys when you create them makes it much easier to audit and revoke the right one later.

### Troubleshooting

If Steel rejects your request, you'll get an HTTP `401 Unauthorized` with a short message explaining why:

- **`Invalid Steel API Key`**: the key you sent doesn't match any active key on your account. Double-check it for typos, trailing whitespace, or the wrong environment variable. If you recently deleted the key, it will no longer work.
- **`Missing API key`**: no `steel-api-key` header was sent. Make sure your SDK client was initialized with a key, or that the header is present on your raw HTTP request.
- **`Account suspended`** (`403 Forbidden`): your organization has been blocked. Reach out to [team@steel.dev](mailto:team@steel.dev?subject=Account%20Suspension) to resolve this.

A quick way to sanity-check a key is to hit the sessions endpoint:

```bash
curl -i https://api.steel.dev/v1/sessions \
  -H "steel-api-key: $STEEL_API_KEY"
```

A `200 OK` means you're authenticated. A `401` means the key is wrong or missing.

### Stuck on auth?

Ping us in the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section, or email [team@steel.dev](mailto:team@steel.dev?subject=Authentication%20Help).

### FAQ

### How do I authenticate requests to the Steel API?

Every request uses an API key tied to your organization. Pass it as the `steel-api-key` HTTP header for the REST API, via the `STEEL_API_KEY` environment variable (or client option) for the SDKs, or as the `apiKey` query parameter on `wss://connect.steel.dev` for CDP browser connections.

### Where do I get a Steel API key?

Sign in at app.steel.dev, open Settings → API Keys, click Create API Key, and copy the value. The full key is shown only once at creation, so save it somewhere safe; if you lose it you must delete it and create a new one.

### Why am I getting a 401 Unauthorized from Steel?

A 401 means your key is invalid (typo, trailing whitespace, wrong env var, or recently deleted) or missing (`Missing API key` means no `steel-api-key` header was sent). A quick sanity check is `curl -i https://api.steel.dev/v1/sessions` with your key header: 200 means authenticated.


# Intro to Steel
URL: https://docs.steel.dev/overview/intro-to-steel


### **Getting LLMs to use the web is _hard_**

We want AI products that can book us a flight, find us a sublet, buy us a prom suit, and get us an interview.

But if you’ve ever tried to build an AI app that can interact with the web today, you know the headaches:

*   **Dynamic Content:** Modern sites heavily rely on client-side rendering and lazy loading, requiring scrapers to wait for page hydration and execute JS to access the full content.

*   **Complex Navigation:** Reaching desired data often involves multi-step flows, simulating user actions like clicks, typing, and handling CAPTCHAs.

*   **Authentication:** High-value data and functionality frequently sits behind auth walls, necessitating robust identity management and auto-login capabilities.

*   **Infrastructure Overhead:** Efficiently scaling and managing headless browser fleets is complex, with issues like cold starts, resource contention, and reliability eating up valuable dev cycles.

*   **Lack of Web APIs:** Many critical sites still lack API access, forcing teams to build and maintain brittle custom scrapers for each target.

This is by design. Most of the web is designed to be anti-bot and human friendly.

But what if we flipped that?

### **A better way to take your LLMs online**

Steel is a headless browser API that lets AI engineers:

*   Control fleets of browser sessions in the cloud via API or Python/Node SDKs

*   Easily extract page data as cleaned HTML, markdown, PDFs, or screenshots

*   Access data behind logins with persistent cookies and automatic sign-in

*   Render complex client-side content with JavaScript execution

*   Bypass anti-bot measures with rotating proxies, stealth configs, and CAPTCHA solving

*   Reduce token usage and costs by up to 80% with optimized page formats

*   Reuse session and cookie data across multiple runs

*   Debug with ease using live session viewers, replays, and embeddings

All fully managed, and ready to scale, so you can focus on building shipping product, not babysitting browsers.

Under the hood, Steel’s cloud-native platform handles all the headaches of browser infrastructure:

*   Executing JavaScript to load and hydrate pages

*   Managing credentials, sign-in flows, proxies, CAPTCHAs, and cookies

*   Horizontal browser scaling and recovering from failures

*   Optimizing data formats to reduce LLM token usage

### Get started with Sessions API

- [Overview](/overview/sessions-api/overview)
- [Quickstart](/overview/sessions-api/quickstart)
- [Connect with Puppeteer](/cookbook/puppeteer)
- [Connect with Playwright](/cookbook/playwright)
- [Connect with Selenium](/cookbook/selenium)

### Reference

- [API Reference](/api-reference)

- [Python SDK Reference](/steel-python-sdk)
- [Node SDK Reference](/steel-js-sdk)

### FAQ

### What is Steel and what does it do?

Steel is an open-source browser API purpose-built for AI agents. It lets you control fleets of browser sessions in the cloud via API or Python/Node SDKs, handling JavaScript rendering, logins, proxies, CAPTCHAs, and scaling so you can focus on shipping product instead of babysitting browsers.

### Why is it so hard for AI agents to browse the web?

Modern sites rely on client-side rendering, multi-step navigation, CAPTCHAs, and auth walls, and many critical sites lack APIs entirely. Most of the web is deliberately anti-bot and human-friendly, which forces teams to build brittle custom scrapers and manage headless browser fleets themselves.

### Can Steel bypass anti-bot measures and CAPTCHAs?

Yes. Steel lets you bypass anti-bot measures with rotating proxies, stealth configs, and CAPTCHA solving, and it can access data behind logins using persistent cookies and automatic sign-in.

### Does Steel help reduce LLM token costs?

Yes. Steel can reduce token usage and costs by up to 80% with optimized page formats, extracting page data as cleaned HTML, markdown, PDFs, or screenshots instead of raw pages.


# Legal
URL: https://docs.steel.dev/overview/legal

Please visit our latest [Terms of Service](https://docs.google.com/document/d/1VuaLxBq150cR9vyiir9B4GUsvqSu0Rd64Vtu-HiSqp8/edit?tab=t.0#heading=h.nf9mun4iq7m9)

Please visit our latest [Privacy Policy](https://docs.google.com/document/d/1q3QBkFm4ke-_oqEO3wyP5yi64TazRBt6wbvIE_Zx69A/edit?usp=sharing)


# Need Help?
URL: https://docs.steel.dev/overview/need-help

- [Overview](/)
- [Changelog](/changelog)
- [API Reference](/api-reference)
- [Cookbook](https://github.com/steel-dev/steel-cookbook/)
- [Discord](https://discord.gg/steel-dev)
- [Github](https://github.com/steel-dev)
- [Dashboard](https://app.steel.dev/)

We’re here to support in any way we can!

You can connect with us on:

- [Discord](https://discord.gg/steel-dev)
- [GitHub](https://github.com/steel-dev)

or send an email to our team support at [team@steel.dev](mailto:team@steel.dev?subject=Steel%20Support%20Issue)


# Pricing/Limits
URL: https://docs.steel.dev/overview/pricinglimits

**Last Edit:** June 30th, 2026

### Pricing Table

| Tier       | Price          | Included usage                            |
| ---------- | -------------- | ----------------------------------------- |
| Launch     | $0 + usage     | $30 free usage credits, one time          |
| Scale      | $250 + usage   | $100 free usage credits / month           |
| Enterprise | Custom + usage | [Talk to the founders](https://cal.com/hussien-hussien-fjxt3x/intro-chat-w-steel-founders) |

| Category | Feature | Launch | Scale | Enterprise |
| --- | --- | --- | --- | --- |
| Rates | Browser hours | $0.10/hour | $0.08/hour | Custom |
| Rates | Proxy bandwidth | $10/GB | $6/GB | Custom |
| Rates | Captcha solves | $3/1k | $1/1k | Custom |
| Rates | Browser tools (`/scrape`, `/screenshot`, `/pdf`) | $5/1k | $5/1k | Custom |
| Features | Concurrent browser sessions | 10 | 100 | 1,000+ |
| Features | Stealth Browser | - | - | Included |
| Features | Dedicated IPs | - | $5/IP/month | Custom |
| Features | Max session time | 15 minutes | 1 hour | Up to 24 hours |
| Features | Number of seats | Up to 3 | Unlimited | Unlimited |
| Features | Reserved browser pools | - | - | Included |
| Limits | Requests per minute | 60 | 600 | Custom |
| Limits | Data retention | 7 days | 14 days | Custom |
| Support | Email support | Included | Included | Included |
| Support | Community support | Included | Included | Included |
| Support | Dedicated Slack channel | - | Included | Included |
| Support | 911 email | - | - | Included |
| Support | Support & uptime SLAs | - | - | Included |
| Security | Enterprise SSO | - | Included | Included |
| Security | HIPAA-ready BAA | - | Included | Included |

\* Browser hours are billed by the minute, rounded up.

### How Credits Work

Usage credits are applied to metered Steel usage, including browser hours, proxy bandwidth,
captcha solves, and Browser Tools usage.

Launch includes $30 in one-time free usage credits, valid for 90 days. Scale includes $100 in
free usage credits each month. Usage beyond included credits is billed at your plan's listed
rates.

Enterprise pricing, usage credits, and limits are customized for your workload.

You can monitor usage by meter in the dashboard, including session time, proxy bandwidth,
captcha solves, and remaining balance. You can also top up credits or enable auto top-up so
a run does not stop because the workspace runs out of balance.

### Verify your Launch account with a $10 deposit
To use CAPTCHA solving or Steel-provided proxies on Launch, verify your account by adding
$10 in paid balance. Free credits do not count. The deposit goes toward usage, not an extra
fee. Scale includes anti-bot; Enterprise is custom.

### Usage Examples

If you spent your included credits on a single usage category, you would get roughly:

#### Launch

*   300 browser hours
*   3GB proxy bandwidth
*   10,000 captcha solves
*   6,000 Browser Tools calls

#### Scale

*   1,250 browser hours
*   16.7GB proxy bandwidth
*   100,000 captcha solves
*   20,000 Browser Tools calls

\* In practice, most workloads use a mix of browser hours, proxies, captcha solves, and
Browser Tools calls.

**_Enterprise plans offer custom pricing, limits, and infrastructure for high-volume or
specialized workloads._**

### Need custom pricing or limits?
Talk to the founders about Enterprise pricing, reserved browser pools, custom limits, and
support requirements.

[Schedule a call](https://cal.com/hussien-hussien-fjxt3x/intro-chat-w-steel-founders)

### FAQ

### How much does Steel cost?

Steel has a Launch plan at $0 + usage with $30 in one-time free usage credits valid for 90
days, a Scale plan at $250 + usage with $100 in monthly free usage credits, and custom
Enterprise pricing.

### How long can a Steel browser session stay alive?

Launch sessions can run up to 15 minutes, Scale sessions can run up to 1 hour, and Enterprise
sessions can run up to 24 hours.

### How many concurrent browser sessions can I run on Steel?

Launch includes 10 concurrent browser sessions, Scale includes 100 concurrent browser sessions,
and Enterprise supports 1,000+ concurrent browser sessions.

### How is Steel browser time billed?

Browser hours are billed by the minute, rounded up. Launch is $0.10/hour, Scale is $0.08/hour,
and Enterprise is custom.

### How much do proxies cost?

Proxy bandwidth is $10/GB on Launch and $6/GB on Scale, with custom Enterprise pricing.

### How much do captcha solves cost?

Captcha solves are $3 per 1,000 solves on Launch and $1 per 1,000 solves on Scale, with custom
Enterprise pricing.

### How do I enable CAPTCHA solving on Launch?

Verify your Launch account by adding $10 in paid balance. This enables CAPTCHA solving and
Steel-provided proxies. Free credits do not count. The deposit goes toward usage, not an extra
fee. Scale includes anti-bot; Enterprise is custom.

### How much do Browser Tools cost?

Browser Tools usage for `/scrape`, `/screenshot`, and `/pdf` is $5 per 1,000 calls on Launch
and Scale, with custom Enterprise pricing.

### How much do Dedicated IPs cost?

Dedicated IPs are available on Scale for $5 per IP per month. Enterprise Dedicated IP pricing
is custom.

### What happens if I am already on a legacy plan?

Existing Starter, Developer, and Startup customers can keep their legacy plan in the dashboard
and switch when they choose.


# Steel CLI
URL: https://docs.steel.dev/overview/steel-cli

## Overview

The Steel CLI lets you run full browser workflows from the terminal, end-to-end.
You can start a browser session, navigate pages, click/fill/type, extract content, and
stop the session without wiring custom browser infrastructure.

What it enables:

- Run browser automation in cloud mode (default) or self-hosted/local mode
- Drive pages with terminal-first browser commands (`open`, `snapshot`, `fill`, `click`, etc.)
- Use API tools for `scrape`, `screenshot`, and `pdf`
- Bootstrap projects quickly with `forge` and run templates instantly with `run`

Under the hood, `steel browser` is directly integrated with the `agent-browser` runtime.
That means Steel adds session lifecycle, auth, and endpoint routing while preserving
familiar browser command behavior.

- GitHub: [steel-dev/cli](https://github.com/steel-dev/cli)

## Documentation Index (for Agents)

If you are using an AI agent and want a complete docs index before exploring pages:

- `https://docs.steel.dev/llms-full.txt`

This returns a flattened, agent-friendly text map of the docs site.

## Installation

Install with the official script (recommended):

```bash
curl -fsS https://setup.steel.dev | sh
```

This installs the native `steel` binary to `~/.steel/bin` and runs `steel init`
to log you in, verify connectivity, and install coding-agent skills.

## Quick Start

### Cloud Mode (Default)

```bash
steel login
steel browser start --session my-job
steel browser open https://example.com --session my-job
steel browser snapshot -i --session my-job
steel browser stop --session my-job
```

### Self-Hosted Endpoint

```bash
steel browser start --api-url https://steel.your-domain.dev/v1 --session my-job
steel browser open https://example.com --api-url https://steel.your-domain.dev/v1 --session my-job
```

### Local Runtime (`localhost`) Flow

```bash
steel dev install
steel dev start
steel browser start --local --session local-job
steel browser open https://example.com --session local-job
steel browser stop --session local-job
steel dev stop
```

## Skills for Coding Agents

Steel ships a first-party skills catalog for agent workflows:

- [Steel Skills](/overview/skills)
- [steel-dev/skills](https://github.com/steel-dev/skills)

This is designed for coding agents, including:

- Codex
- OpenCode
- OpenClaw
- Claude Code

Install from the public skills catalog:

```bash
npx skills add steel-dev/skills --skill steel-browser
```

Or through the Steel CLI helper:

```bash
steel skills install steel-browser
steel skills list
steel skills doctor
```

After installation, restart your agent client so it can discover newly installed skills.

What this skill gives agents:

- Mode-aware command planning (cloud vs self-hosted)
- Named-session lifecycle discipline (`start -> work -> stop`)
- Reliable command patterns for `steel browser` passthrough actions
- Migration guidance from `agent-browser`
- Troubleshooting playbooks for auth/session/CAPTCHA failures

### Typical Agent Skill Workflow

Most agent loops follow this pattern:

1. Start or attach a named session.
2. Open a page and inspect interactable elements (`snapshot -i`).
3. Perform actions (`fill`, `select`, `check`, `click`).
4. Wait for the post-action state and verify output.
5. Stop the session when done.

```bash
SESSION="signup-demo-$(date +%s)"
steel browser start --session "$SESSION"
steel browser open https://example.com/signup --session "$SESSION"
steel browser snapshot -i --session "$SESSION"
steel browser fill @e1 "Jane Doe" --session "$SESSION"
steel browser fill @e2 "jane@example.com" --session "$SESSION"
steel browser select @e3 "California" --session "$SESSION"
steel browser check @e4 --session "$SESSION"
steel browser click @e5 --session "$SESSION"
steel browser wait --load networkidle --session "$SESSION"
steel browser stop --session "$SESSION"
```

If you already know upstream `agent-browser`, the behavior is typically command-prefix only:

```bash
agent-browser open https://example.com/signup
agent-browser snapshot -i
agent-browser fill @e1 "Jane Doe"
agent-browser fill @e2 "jane@example.com"
agent-browser select @e3 "California"
agent-browser check @e4
agent-browser click @e5
agent-browser wait --load networkidle
```

## Command Model

### Steel-Owned Browser Lifecycle Commands

- `steel browser start`
- `steel browser stop`
- `steel browser sessions`
- `steel browser live`
- `steel browser captcha solve`

### Inherited Browser Commands (Passthrough)

All non-lifecycle `steel browser <command>` calls are routed through the vendored
`agent-browser` runtime.

Migration is usually command-prefix only:

- Before: `agent-browser <command> ...`
- After: `steel browser <command> ...`

### Essential Inherited Commands

These are the most common inherited commands agents use:

- Page navigation: `open`, `back`, `forward`, `reload`
- Page understanding: `snapshot`, `snapshot -i`
- Interaction: `click`, `fill`, `type`, `select`, `check`, `press`, `hover`
- Data retrieval: `get text`, `get html`, `get title`, `get url`
- Synchronization: `wait`, `wait --load networkidle`, `wait --text`
- Debugging: `screenshot`, `errors`, `console`

Use command help directly when needed:

```bash
steel browser --help
steel browser click --help
steel browser wait --help
```

For full command references:

- [Steel browser commands reference](https://github.com/steel-dev/cli/blob/main/docs/references/steel-browser-commands.md)
- [Steel browser session lifecycle reference](https://github.com/steel-dev/cli/blob/main/docs/references/steel-browser.md)

## Command Overview

| Group               | Commands                                                                                     |
| ------------------- | -------------------------------------------------------------------------------------------- |
| Onboarding          | `init`, `forge`                                                                              |
| Browser lifecycle   | `browser start`, `browser stop`, `browser sessions`, `browser live`, `browser captcha solve` |
| Browser passthrough | `steel browser <inherited-command>`                                                          |
| API tools           | `scrape`, `screenshot`, `pdf`                                                                |
| Local runtime       | `dev install`, `dev start`, `dev stop`                                                       |
| Profiles            | `profile import`, `profile sync`, `profile list`, `profile delete`                           |
| Credentials         | `credentials list`, `credentials create`, `credentials update`, `credentials delete`         |
| Account + utility   | `login`, `logout`, `config`, `doctor`, `cache`, `update`                                     |

## Common Workflows

### 1. Named Session Lifecycle

```bash
SESSION="job-$(date +%s)"
steel browser start --session "$SESSION"
steel browser open https://example.com --session "$SESSION"
steel browser snapshot -i --session "$SESSION"
steel browser get title --session "$SESSION"
steel browser stop --session "$SESSION"
```

### 2. CAPTCHA-Aware Sessions

```bash
# Manual solve mode
steel browser start --session my-job --session-solve-captcha
steel browser captcha solve --session my-job

# Auto solve mode (stealth preset)
steel browser start --session my-job --stealth
```

### 3. `agent-browser` Migration

```bash
# Before
agent-browser open https://example.com
agent-browser snapshot -i
agent-browser click @e3

# After
steel browser open https://example.com
steel browser snapshot -i
steel browser click @e3
```

### 4. API Tool Commands

```bash
# Scrape (markdown-first output by default)
steel scrape https://example.com

# Screenshot
steel screenshot https://example.com --full-page

# PDF
steel pdf https://example.com
```

## `forge` (Templates)

Use `forge` to scaffold a project from a template.

```bash
# Scaffold a project
steel forge playwright --name my-bot
```

List all templates and flags:

```bash
steel forge --help
```

## Endpoint Resolution

For browser lifecycle, passthrough bootstrap, and API tools (`scrape`, `screenshot`, `pdf`),
endpoint selection is deterministic.

Self-hosted precedence:

1. `--api-url <url>`
2. `STEEL_BROWSER_API_URL`
3. `STEEL_LOCAL_API_URL`
4. `browser.apiUrl` in `~/.config/steel/config.json`
5. `http://localhost:3000/v1`

Cloud precedence:

1. `STEEL_API_URL`
2. `https://api.steel.dev/v1`

Attach-flag override:

- If `--cdp` or `--auto-connect` is provided, Steel skips bootstrap injection and forwards
  passthrough arguments unchanged.

## Auth, Config, and Updates

If you provision Steel through Stripe Projects, `stripe projects add steel/browser` writes a
standard `STEEL_API_KEY` that the Steel CLI can read from your environment. See
[Stripe Projects](/overview/stripe-projects).

```bash
steel login
steel config
steel logout
steel cache --clean
steel update
steel update --check
steel update --force
```

Disable auto-update checks (24-hour cache window):

```bash
steel scrape https://example.com --no-update-check
STEEL_CLI_SKIP_UPDATE_CHECK=true steel scrape https://example.com
CI=true steel scrape https://example.com
NODE_ENV=test steel scrape https://example.com
```

## Runtime and Output Notes

- `steel scrape` defaults to markdown-first output; use `--raw` for full JSON payload.
- `steel browser start` and `steel browser sessions` return display-safe `connect_url` values
  with sensitive query parameters redacted.
- Browser command paths bypass auto-update checks to reduce interactive latency.

## Troubleshooting

- `Missing browser auth...`: run `steel login` or set `STEEL_API_KEY`.
- `Failed to reach Steel session API ...`: confirm mode and endpoint settings (`--local`,
  `--api-url`, env vars).
- Session reuse issues: use a consistent `--session <name>` across every step.
- Local runtime issues: run `steel dev install` once, then `steel dev start`.
- Stale state: run `steel browser stop --all` and start a fresh named session.

## References

- [Steel CLI README](https://github.com/steel-dev/cli/blob/main/README.md)
- [Generated CLI Reference](https://github.com/steel-dev/cli/blob/main/docs/cli-reference.md)
- [Steel Browser Reference](https://github.com/steel-dev/cli/blob/main/docs/references/steel-browser.md)
- [Steel Skills](/overview/skills)

### FAQ

### How do I install the Steel CLI?

Run `curl -fsS https://setup.steel.dev | sh`. This installs the native `steel` binary to `~/.steel/bin` and runs `steel init` to log you in, verify connectivity, and install coding-agent skills.

### Can I scrape or screenshot a page from the terminal without writing code?

Yes. The CLI ships one-shot API tools: `steel scrape <url>` (markdown-first output by default, `--raw` for full JSON), `steel screenshot <url> --full-page`, and `steel pdf <url>`.


# Stripe Projects
URL: https://docs.steel.dev/overview/stripe-projects

Steel is available through [Stripe Projects](https://docs.stripe.com/projects), a Stripe CLI
workflow for provisioning third-party services and syncing credentials into your project. Add
Steel with `stripe projects add steel/browser` to create a Steel project and project-scoped
`STEEL_API_KEY` for cloud browser sessions.

## Overview

Stripe Projects provisions provider resources from the terminal and writes credentials into your
active environment file. For Steel, the provisioned resource is a Steel project plus an API key
scoped to that project.

| Command | What it does |
| --- | --- |
| `stripe projects init` | Initializes a Stripe Projects workspace in your app directory. |
| `stripe projects add steel/browser` | Provisions a Steel project and syncs credentials into `.env`. |

After provisioning, your app uses the standard Steel API, SDKs, and authentication model. See
[authentication](/overview/authentication) and the [Steel CLI](/overview/steel-cli).

## Prerequisites

Install the [Stripe CLI](https://docs.stripe.com/stripe-cli/install), then install the Projects
plugin:

```bash
stripe plugin install projects
```

## Provision Steel

Initialize Stripe Projects in your app directory, then add Steel:

```bash
stripe projects init my-app
stripe projects add steel/browser
```

The add command links your Stripe account to a Steel account, creating one when needed, then
provisions a Steel project and API key inside the Steel account you own. Stripe Projects syncs the
returned credentials into your active environment output file, `.env` by default.

## What lands in `.env`

Steel returns a project-scoped API key plus resource metadata to Stripe Projects. The key your app
uses directly is:

```bash
STEEL_API_KEY=ste-...
```

Use `stripe projects env` to list synced variable names with values redacted:

```bash
stripe projects env
```

Keep `.env` out of version control. `stripe projects init` adds credential files to `.gitignore`,
but review your repository before committing.

## Use the credentials

The Steel SDK reads `STEEL_API_KEY` from the environment automatically:

```typescript
import Steel from 'steel-sdk';

const client = new Steel();

const session = await client.sessions.create();
```

```python
from steel import Steel

client = Steel()

session = client.sessions.create()
```

## Plans and pricing

Steel is available through Stripe Projects on Launch and Scale plans. Current plan limits,
included credits, billing requirements, and metered rates live on
[pricing and limits](/overview/pricinglimits).

Upgrade between available Steel plan tiers from the Stripe CLI:

```bash
stripe projects upgrade steel/browser
```

## Pull credentials on another machine

If you cloned the repo or a teammate provisioned Steel, pull the current credentials into your
local environment file:

```bash
stripe projects env --pull
```

## Limits and self-hosting

The `steel-browser` runtime is open source under Apache-2.0, so teams can inspect the code and run
it locally or self-hosted when they need a local path. Managed Steel Cloud features still require
Steel Cloud.

| Ships with the open-source runtime | Steel Cloud only |
| --- | --- |
| Browser automation core | Managed residential proxies |
| Session lifecycle and tracing | Credentials API |
| Local and self-hosted deployment | Higher concurrency and managed anti-bot features |

For plan limits such as concurrent sessions, session length, requests per minute, and data
retention, see [pricing and limits](/overview/pricinglimits).

### FAQ

### Do I need a Steel account before I run `stripe projects add`?

No. The command links your Stripe account to a Steel account, creating one when needed. The Steel
project and `STEEL_API_KEY` land in a Steel account you own.

### Where do my provisioned resources live?

In your own Steel account. `stripe projects add steel/browser` provisions a Steel project plus a
project-scoped API key inside the Steel organization tied to your Stripe account.

### Can I link an existing Steel project to a Stripe Project?

Not today. Steel provisions a fresh project for each Stripe Project rather than attaching an
existing Steel project.

### Do the provisioned credentials work with the Steel SDK and CLI?

Yes. Provisioning writes a standard `STEEL_API_KEY`, so the official SDKs, the REST API, and the
Steel CLI can use it from the environment.


# Agents
URL: https://docs.steel.dev/cookbook/topics/agents

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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](/cookbook/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](/cookbook/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.
- [Build a browser agent with rig](/cookbook/rig): Use Steel with rig to build an agent that drives a cloud browser over CDP with chromiumoxide through navigate and extract tools, then answers a multi-step web task.
- [Build a research agent with Swiftide](/cookbook/swiftide): Use Steel with Swiftide to build an agent whose tool reads the web through Steel's scrape endpoint, so the model works from clean Markdown with no browser library.
- [Build a browser agent with LangChainGo](/cookbook/langchaingo): Use Steel with LangChainGo's zero-shot ReAct (MRKL) agent and a string-in, string-out scrape tool so Claude reads a page and answers a question.
- [Build a browser agent with Google ADK](/cookbook/google-adk): Use Steel with Google's Agent Development Kit (ADK) for Go to build a tool-using browser agent that drives a chromedp session over CDP and reads Hacker News.
- [Build a browser agent with Microsoft Agent Framework](/cookbook/microsoft-agent-framework): Use Steel with Microsoft Agent Framework 1.0 (the successor to AutoGen and Semantic Kernel) to build a tool-using browser agent.
- [Chat with any webpage on Convex](/cookbook/convex-chat-with-page): Convex app that streams an AI agent's answer about any URL. The agent runs server-side with one Steel-backed scrape tool and pages through long articles via a chunked cache.
- [Deep research with Claude Agent SDK subagents](/cookbook/deep-research): Lead orchestrator dispatches parallel researcher subagents, each driving its own Steel browser, and synthesizes findings into a cited Markdown report.
- [Combine You.com search with Steel browser actions](/cookbook/you-com-search): Pair the You.com Search and Contents APIs with a Steel cloud browser in a search-then-act LangChain agent that prefers the cheap path and only opens a session when interaction is required.
- [Build a browser agent with the Claude Agent SDK](/cookbook/claude-agent-sdk): Use Steel with the Claude Agent SDK (TypeScript) to build a tool-using browser agent on Anthropic's first-party agent loop.
- [Build a typed browser agent with Pydantic AI](/cookbook/pydantic-ai): Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection.
- [Build a typed browser agent with LangGraph](/cookbook/langgraph): Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node.
- [Build a typed browser agent with Mastra](/cookbook/mastra): Use Steel with Mastra to build a typed browser agent with the Mastra Model Router and Studio playground.
- [Build a typed browser agent with the OpenAI Agents SDK](/cookbook/openai-agents): Use Steel with the OpenAI Agents SDK for TypeScript to build typed, tool-using browser agents.
- [Build a typed browser agent with the Vercel AI SDK](/cookbook/vercel-ai-sdk): Use Steel with the Vercel AI SDK v6 ToolLoopAgent for typed, tool-using browser agents.
- [Stream a browser agent into a Next.js chat app](/cookbook/vercel-ai-sdk-nextjs): A Next.js App Router chat app where a Vercel AI SDK agent drives a Steel cloud browser with embedded Live View.
- [Solve reCAPTCHA v2 manually with Browser Use](/cookbook/browser-use-captcha-manual): Manually solve reCAPTCHA v2 using Steel's CAPTCHA API with the browser-use framework.
- [Build a multi-agent browser workflow with CrewAI](/cookbook/crewai): Integrate Steel with the CrewAI multi-agent framework.
- [Build a browser agent with Inngest AgentKit](/cookbook/agentkit): Integrate Steel with Inngest's AgentKit framework.
- [Build a browser agent with Agno](/cookbook/agno): Integrate Steel with the Agno agent framework.
- [Control a browser with Notte's reasoning engine](/cookbook/notte): Control browsers with AI using Steel's infrastructure and Notte's reasoning engine.
- [Build an AI browser agent with Magnitude](/cookbook/magnitude): Use Steel with Magnitude for AI-powered browser automation.
- [Build a browser agent with Browser Use](/cookbook/browser-use): Integrate Steel with the browser-use framework for AI-driven web automation.
- [Solve CAPTCHAs automatically in a Browser Use agent](/cookbook/browser-use-captcha-auto): Build an AI agent with browser-use and Steel that solves CAPTCHAs automatically.


# Authentication
URL: https://docs.steel.dev/cookbook/topics/authentication

- [Persist authenticated sessions with Profiles](/cookbook/profiles): Maintain authenticated sessions across Steel browser instances using profiles.
- [Reuse authenticated sessions across browsers](/cookbook/auth-context): Maintain authenticated sessions across Steel browser instances by capturing and reusing cookies and local storage.
- [Automate logins with the Credentials API](/cookbook/credentials): Use the Steel Credentials API with Playwright to automate flows with stored credentials.


# Browser automation
URL: https://docs.steel.dev/cookbook/topics/browser-automation

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.
- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.
- [Automate a cloud browser with headless_chrome](/cookbook/headless-chrome): Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles.
- [Automate a cloud browser with chromedp](/cookbook/chromedp): Use Steel with chromedp to connect over CDP, navigate to Hacker News, extract the top stories, and capture a screenshot.
- [Automate a cloud browser with Rod](/cookbook/rod): Use Steel with Rod's fluent, chainable API to connect over CDP and scrape quotes.toscrape.com from a cloud browser.
- [Automate a cloud browser with chromiumoxide](/cookbook/chromiumoxide): Use Steel with chromiumoxide to connect over CDP, drive the handler task, extract page content, and capture a screenshot.
- [Automate browsing with natural-language instructions using Stagehand](/cookbook/stagehand): Use Steel with Stagehand for natural-language-driven AI browser automation.
- [Automate a cloud browser with Playwright](/cookbook/playwright): Use Steel with Playwright in TypeScript for cloud browser automation.
- [Automate a cloud browser with Puppeteer](/cookbook/puppeteer): Use Steel with Puppeteer in TypeScript for cloud browser automation.
- [Automate a cloud browser with Selenium](/cookbook/selenium): Use Steel with Selenium in Python for cloud browser automation.

## Related integrations

- [Steel + Playwright](/integrations/playwright): Drive a Steel browser with Playwright over CDP.
- [Steel + Puppeteer](/integrations/puppeteer): Drive a Steel browser with Puppeteer over CDP.
- [Steel + Selenium](/integrations/selenium): Drive a Steel browser with Selenium.


# Browser Use
URL: https://docs.steel.dev/cookbook/topics/browser-use

- [Solve reCAPTCHA v2 manually with Browser Use](/cookbook/browser-use-captcha-manual): Manually solve reCAPTCHA v2 using Steel's CAPTCHA API with the browser-use framework.
- [Build a browser agent with Browser Use](/cookbook/browser-use): Integrate Steel with the browser-use framework for AI-driven web automation.
- [Solve CAPTCHAs automatically in a Browser Use agent](/cookbook/browser-use-captcha-auto): Build an AI agent with browser-use and Steel that solves CAPTCHAs automatically.

## Related integrations

- [Steel + Browser Use](/integrations/browser-use): Set up Steel with the browser-use framework.


# Captchas
URL: https://docs.steel.dev/cookbook/topics/captchas

- [Solve reCAPTCHA v2 manually with Browser Use](/cookbook/browser-use-captcha-manual): Manually solve reCAPTCHA v2 using Steel's CAPTCHA API with the browser-use framework.
- [Solve CAPTCHAs automatically in a Browser Use agent](/cookbook/browser-use-captcha-auto): Build an AI agent with browser-use and Steel that solves CAPTCHAs automatically.

## Related guides

- [Solving CAPTCHAs](/overview/stealth/captcha-solving): How Steel detects and solves CAPTCHA challenges.
- [Proxies](/overview/stealth/proxies): Route traffic through proxies to reduce CAPTCHA challenges.


# Computer use
URL: https://docs.steel.dev/cookbook/topics/computer-use

- [Drive a browser with Gemini Computer Use](/cookbook/gemini-computer-use): Connect Google's Gemini Computer Use to a Steel browser session for autonomous web interactions.
- [Drive a mobile browser with Claude Computer Use](/cookbook/claude-computer-use-mobile): Claude Computer Use with Steel for autonomous task execution in mobile browser environments.
- [Drive a browser with Claude Computer Use](/cookbook/claude-computer-use): Connect Claude to a Steel browser session for autonomous web interactions.
- [Drive a browser with OpenAI Computer Use](/cookbook/openai-computer-use): Connect OpenAI's Computer Use Assistant to a Steel browser session for autonomous web interactions.

## Related integrations

- [Steel + Claude Computer Use](/integrations/claude-computer-use): Point Claude's computer-use loop at a Steel session.
- [Steel + OpenAI Computer Use](/integrations/openai-computer-use): Point OpenAI's Computer Use Assistant at a Steel session.
- [Steel + Gemini Computer Use](/integrations/gemini-computer-use): Point Gemini Computer Use at a Steel session.


# Convex
URL: https://docs.steel.dev/cookbook/topics/convex

- [Chat with any webpage on Convex](/cookbook/convex-chat-with-page): Convex app that streams an AI agent's answer about any URL. The agent runs server-side with one Steel-backed scrape tool and pages through long articles via a chunked cache.
- [Watch Claude pricing for divergent A/B variants](/cookbook/convex-price-watch): Convex cron plus two parallel Steel proxy probes against claude.com/pricing. Stores per-tier per-region snapshots and surfaces tiers where the probes disagree.


# MCP
URL: https://docs.steel.dev/cookbook/topics/mcp

- [Expose a Steel browser to any MCP client](/cookbook/mcp): 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.


# Mobile
URL: https://docs.steel.dev/cookbook/topics/mobile

- [Drive a mobile browser with Claude Computer Use](/cookbook/claude-computer-use-mobile): Claude Computer Use with Steel for autonomous task execution in mobile browser environments.

## Related integrations

- [Steel + Claude Computer Use](/integrations/claude-computer-use): Drive Steel's mobile browser with Claude's computer-use model.


# Next.js
URL: https://docs.steel.dev/cookbook/topics/nextjs

- [Stream a browser agent into a Next.js chat app](/cookbook/vercel-ai-sdk-nextjs): A Next.js App Router chat app where a Vercel AI SDK agent drives a Steel cloud browser with embedded Live View.


# Playwright
URL: https://docs.steel.dev/cookbook/topics/playwright

- [Automate a cloud browser with Playwright](/cookbook/playwright): Use Steel with Playwright in TypeScript for cloud browser automation.
- [Upload and run browser extensions](/cookbook/extensions): Use the Steel Extensions API with Playwright to upload and run browser extensions.
- [Move files between your machine and a cloud browser](/cookbook/files): Use the Steel Files API with Playwright to automate file uploads and downloads in the cloud.

## Related integrations

- [Steel + Playwright](/integrations/playwright): Set up Steel with Playwright and connect over CDP.


# Restate
URL: https://docs.steel.dev/cookbook/topics/restate

- [Run a durable browser agent with Restate](/cookbook/restate-agent): Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.


# Search
URL: https://docs.steel.dev/cookbook/topics/search

- [Combine You.com search with Steel browser actions](/cookbook/you-com-search): Pair the You.com Search and Contents APIs with a Steel cloud browser in a search-then-act LangChain agent that prefers the cheap path and only opens a session when interaction is required.


# Steel APIs
URL: https://docs.steel.dev/cookbook/topics/steel-apis

- [Scrape a page to Markdown, screenshot, and PDF](/cookbook/scrape): Use the Steel TypeScript SDK's direct API to scrape a page to clean Markdown for LLM context, plus screenshot and PDF, with no browser library.
- [Watch Claude pricing for divergent A/B variants](/cookbook/convex-price-watch): Convex cron plus two parallel Steel proxy probes against claude.com/pricing. Stores per-tier per-region snapshots and surfaces tiers where the probes disagree.
- [Persist authenticated sessions with Profiles](/cookbook/profiles): Maintain authenticated sessions across Steel browser instances using profiles.
- [Reuse authenticated sessions across browsers](/cookbook/auth-context): Maintain authenticated sessions across Steel browser instances by capturing and reusing cookies and local storage.
- [Automate logins with the Credentials API](/cookbook/credentials): Use the Steel Credentials API with Playwright to automate flows with stored credentials.
- [Upload and run browser extensions](/cookbook/extensions): Use the Steel Extensions API with Playwright to upload and run browser extensions.
- [Move files between your machine and a cloud browser](/cookbook/files): Use the Steel Files API with Playwright to automate file uploads and downloads in the cloud.


# Subagents
URL: https://docs.steel.dev/cookbook/topics/subagents

- [Deep research with Claude Agent SDK subagents](/cookbook/deep-research): Lead orchestrator dispatches parallel researcher subagents, each driving its own Steel browser, and synthesizes findings into a cited Markdown report.

## Related integrations

- [Steel + Claude Agent SDK](/integrations/claude-agent-sdk): Build the orchestrator and subagents on Anthropic's agent loop.


# Temporal
URL: https://docs.steel.dev/cookbook/topics/temporal

- [Run a durable browser workflow with Temporal](/cookbook/temporal-browser-workflow): Build a Temporal TypeScript Workflow that schedules retryable Steel browser Activities to capture page summaries, screenshots, and Markdown artifacts.


# Trigger.dev
URL: https://docs.steel.dev/cookbook/topics/triggerdev

- [Run a Steel browser job with Trigger.dev](/cookbook/trigger-dev-browser-job): Queue a Trigger.dev task that creates a Steel session, drives Playwright over CDP, saves artifacts, and releases the browser in cleanup.


# Typed output
URL: https://docs.steel.dev/cookbook/topics/typed-output

- [Build a browser agent with Google ADK](/cookbook/google-adk): Use Steel with Google's Agent Development Kit (ADK) for Go to build a tool-using browser agent that drives a chromedp session over CDP and reads Hacker News.
- [Build a typed browser agent with Pydantic AI](/cookbook/pydantic-ai): Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection.
- [Build a typed browser agent with LangGraph](/cookbook/langgraph): Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node.
- [Build a typed browser agent with Mastra](/cookbook/mastra): Use Steel with Mastra to build a typed browser agent with the Mastra Model Router and Studio playground.
- [Build a typed browser agent with the OpenAI Agents SDK](/cookbook/openai-agents): Use Steel with the OpenAI Agents SDK for TypeScript to build typed, tool-using browser agents.
- [Build a typed browser agent with the Vercel AI SDK](/cookbook/vercel-ai-sdk): Use Steel with the Vercel AI SDK v6 ToolLoopAgent for typed, tool-using browser agents.
- [Stream a browser agent into a Next.js chat app](/cookbook/vercel-ai-sdk-nextjs): A Next.js App Router chat app where a Vercel AI SDK agent drives a Steel cloud browser with embedded Live View.

## Related integrations

- [Steel + Pydantic AI](/integrations/pydantic-ai): Return typed, schema-validated agent results with Pydantic AI.
- [Steel + LangGraph](/integrations/langgraph): Return typed agent results with an explicit LangGraph state machine.
- [Steel + Mastra](/integrations/mastra): Return typed agent results with Mastra.


# Agent Traces API
URL: https://docs.steel.dev/overview/agent-traces/api

Use the Agent Traces API when you want the same activity timeline shown in the dashboard, but as JSON for analysis, replay, or internal tooling.

### GET /v1/sessions/\:id/agent-traces

Returns the trace timeline for a finished or in-progress session.

```bash
curl https://api.steel.dev/v1/sessions/$SESSION_ID/agent-traces \
  -H "steel-api-key: $STEEL_API_KEY"
```

```javascript
const response = await fetch(
  `https://api.steel.dev/v1/sessions/${sessionId}/agent-traces`,
  { headers: { "steel-api-key": process.env.STEEL_API_KEY } },
);

const trace = await response.json();
```

### Response

```json
{
  "events": [
    {
      "type": "click",
      "timestamp": "2026-05-22T18:03:21.345Z",
      "page": { "url": "https://example.com/login" },
      "target": {
        "role": "button",
        "accessibleName": "Sign in",
        "selector": { "css": "button[data-testid=sign-in]" }
      },
      "pointer": { "x": 520, "y": 410 }
    }
  ],
  "total": 1,
  "hasMore": false
}
```

| Field | Type | Notes |
| ----- | ---- | ----- |
| `events` | array | Timeline activities in chronological order. |
| `total` | number | Number of activities returned. |
| `hasMore` | boolean | Whether more activity data is available. |

Each activity includes a small common envelope:

| Field | Type | Notes |
| ----- | ---- | ----- |
| `type` | string | Activity type, such as `click`, `input`, `navigate`, `scroll`, `drag`, or `error`. |
| `timestamp` | ISO 8601 string | When the activity happened. |
| `endTimestamp` | ISO 8601 string | Present when the activity spans a range of time. |
| `page` | object | Page context, usually including `url`. |
| `target` | object | Element context when available. |

Activity-specific details appear when relevant. For example, click activities can include `pointer`, typing activities can include `value`, keyboard activities can include `keyboard`, navigation activities can include `navigation`, and error activities can include `error`.

### Target Details

When Steel can identify the element involved in an activity, `target` can include readable labels and useful selectors:

```json
{
  "role": "button",
  "accessibleName": "Sign in",
  "text": "Sign in",
  "attributes": {
    "data-testid": "sign-in",
    "type": "submit"
  },
  "selector": {
    "css": "button[data-testid=sign-in]"
  }
}
```

Use target details as hints for review or replay. Some activities, such as navigation or errors, may not include a target.

### Filtering by Time

You can limit results to a time range:

```bash
curl "https://api.steel.dev/v1/sessions/$SESSION_ID/agent-traces?startTime=2026-05-22T18:00:00.000Z&endTime=2026-05-22T18:10:00.000Z" \
  -H "steel-api-key: $STEEL_API_KEY"
```

| Query param | Notes |
| ----------- | ----- |
| `startTime` | Include activities at or after this ISO timestamp. |
| `endTime` | Include activities at or before this ISO timestamp. |

### Errors

| Status | Meaning |
| ------ | ------- |
| 401 | Missing or invalid `steel-api-key`. |
| 403 | The session belongs to a different organization. |
| 404 | No session with that ID. |
| 429 | Rate limit hit. Retry after the `Retry-After` header. |

### Need help with the Agent Traces API?
Reach out on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.


# Agent Traces: Observability for Browser Agents
URL: https://docs.steel.dev/overview/agent-traces/overview

Open any recorded session in the dashboard and you'll find an **Agent Traces** tab next to Console Logs and Network. It turns the run into a timeline of agent activity, so you can see what happened without scrubbing through the whole recording.

### What you get

| Capability | What it does |
| ---------- | ------------ |
| **Timeline** | One row per meaningful activity, with a verb, target label, page URL, and timestamp. |
| **Video sync** | Click a row to jump the recording to the moment that activity happened. |
| **Details** | Expand a row to inspect the relevant page, element, pointer, keyboard, or error details when available. |
| **Exports** | Copy the run as markdown, download JSON, or grab a ZIP with markdown plus screenshots. |

### How to use it

**Review the run.** Start with the timeline to understand the path the agent took: where it navigated, what it clicked, what it typed, where it paused, and where errors happened.

**Jump to the evidence.** Select any row to move the video to that point in the session. This makes it much faster to debug failures, unexpected navigation, or moments where the agent picked the wrong element.

**Hand off context.** Export the trace as markdown when you want another agent or teammate to understand and reproduce the run. Use JSON when you want to analyze the activity data programmatically.

### Built from the browser session
Agent Traces are based on what happened in the browser session, not on what the agent claimed it did. When page context is available, traces include readable target labels and element details to make the run easier to inspect or reproduce.

### Quick look at the API

Fetch the trace timeline for a session as JSON:

```bash
curl https://api.steel.dev/v1/sessions/$SESSION_ID/agent-traces \
  -H "steel-api-key: $STEEL_API_KEY"
```

```json
{
  "events": [
    {
      "type": "click",
      "timestamp": "2026-05-22T18:03:21.345Z",
      "page": { "url": "https://example.com/login" },
      "target": {
        "role": "button",
        "accessibleName": "Sign in",
        "selector": { "css": "button[data-testid=sign-in]" }
      }
    }
  ],
  "total": 1,
  "hasMore": false
}
```

See the [API reference](/overview/agent-traces/api) for the response shape and examples.

### Use cases

**Debugging an agent run.** Find the error or unexpected action in the timeline, click it, and the video jumps to the right moment. Two clicks instead of ten minutes of scrubbing.

**Reproducing a run as code.** Paste the markdown export into Claude Code, Codex, or Cursor with "write me a Steel script that reproduces this." The export is structured so an agent can follow the same path.

**Evaluating agent versions.** Replay the same task against different models or prompts, then compare traces to see which version took fewer steps or handled the page more reliably.

**Auditing what an agent actually did.** A timestamped, video-synced record of every interaction in one scrollable view. Useful for customer support investigations, safety reviews, and post-mortems.

### Where to go next
Read [Timeline and exports](/overview/agent-traces/timeline) for the dashboard UI and export formats, or jump to the [API reference](/overview/agent-traces/api) for the REST endpoint.

### Need help with Agent Traces?
Reach out on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### What are Agent Traces in Steel?

Agent Traces turn a recorded browser session into a readable timeline of agent activity, with one row per meaningful action showing a verb, target label, page URL, and timestamp. You'll find the Agent Traces tab next to Console Logs and Network when you open any recorded session in the dashboard.

### Can I export an agent trace?

Yes. You can copy the run as markdown, download JSON for programmatic analysis, or grab a ZIP containing markdown plus screenshots. The markdown export is structured so you can paste it into Claude Code, Codex, or Cursor and ask for a Steel script that reproduces the run.


# Timeline and exports
URL: https://docs.steel.dev/overview/agent-traces/timeline

The Agent Traces tab lives in the session viewer console, next to Console Logs and Network. Open a session from the dashboard and switch tabs.

### Anatomy of a row

Each row summarizes one meaningful browser activity. From left to right:

| Element | What it shows |
| ------- | ------------- |
| **Icon + verb** | The type of activity, such as `click`, `input`, `navigate`, `scroll`, `drag`, or `error`. |
| **Target label** | A readable name for the element when one is available, such as `Sign in` or `Email field`. |
| **Page context** | The URL the activity happened on. Consecutive rows on the same page are grouped together. |
| **Timestamp** | Wall clock plus an offset from session start for cross-referencing with the video. |

Rows read like sentences: `click on Sign in`, `type into Email field`, `navigate to /home`.

### Activity grouping

Agent Traces group noisy browser interactions into the units you would actually describe when reviewing a run. A burst of typing is shown as a single input activity, repeated navigation is easier to scan, and idle periods help separate thinking from acting in longer sessions.

This makes the timeline useful for review without turning it into a raw event log.

### Video sync

The timeline and the video are linked. Clicking a row seeks the recording to the moment that activity happened.

Activity markers appear on the video progress bar, so you can see where the run was dense with actions and where it slowed down. As the video plays, the matching timeline row highlights.

### Details

Click a row to expand it inline. Depending on the activity, details can include:

- The page URL at the moment the activity happened.
- Element context such as role, accessible name, visible text, and useful attributes.
- Selectors when available.
- Pointer, keyboard, navigation, or error details.
- A thumbnail from the recording near that moment.

For `navigate` rows, the **Open** button opens the URL in a new tab.

### Exports

Three export formats are available from the Agent Traces tab on a finished session.

| Export | What you get | Use when |
| ------ | ------------ | -------- |
| **Copy as markdown** | A single markdown document, built for pasting into another agent. | You want the next agent to read and reproduce the run. |
| **Download JSON** | The activity data for the session. | You want to filter, diff, or analyze the run programmatically. |
| **Download ZIP** | The markdown document plus screenshots, bundled in one archive. | You want a portable record for a bug report, support ticket, or audit. |

#### Copy as markdown

This is the export built for AI agents. The output reads like a recipe an automation agent can follow.

The format includes:

- A short preamble that explains the structure.
- `##` headings for each page visit.
- Numbered steps for each activity, with the time offset.
- Relevant element labels, identifiers, and selectors when available.
- Idle markers between longer pauses.
- Sensitive values, such as password fields and credit-card inputs, replaced with `<redacted>`.
- Optional screenshots of page states.

A trimmed example:

```markdown
# Browser session

Recording of a real browser session, structured for review and replay by an automation agent.

## https://example.com/login

1. `0:02.345` **Click** textbox "Email"
   - testId: `email`
   - CSS: `input[data-testid=email]`
2. `0:03.110` **Type** "user@example.com" into textbox "Email"
   - testId: `email`
   - CSS: `input[data-testid=email]`
3. `0:04.802` **Type** <redacted, 12 chars> into textbox "Password"
   - name: `password`
   - CSS: `input[name=password]`

*(idle 12s)*

4. `0:17.430` **Click** button "Sign in"
   - testId: `sign-in`
   - CSS: `button[data-testid=sign-in]`

## https://example.com/home
```

Paste it into Claude Code, Codex, OpenCode, or Cursor with `write me a Steel script that reproduces this run`.

#### Download JSON

The JSON export contains the session metadata and activity data. Use it when you want to filter, diff, or feed the run into your own tooling rather than another LLM.

#### Download ZIP

The ZIP bundles the markdown document with screenshots of page states. The screenshots are referenced from the markdown, so unzipping and opening the `.md` gives you a portable transcript.

### Redaction is automatic for known-sensitive fields
Password fields and credit-card inputs are replaced with `<redacted>` in both the markdown export and the ZIP. Other input values can appear in exports, so review before sharing if you ran the agent against real user data.

### Need help with Agent Traces?
Reach out on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.


# Browser Tools
URL: https://docs.steel.dev/overview/browser-tools/overview

Browser Tools are stateless, single-call endpoints that spin up a managed browser, perform one action against a URL, and return the result. They are the fastest way to pull content off a page when you do not need to drive a long-running session.

| Endpoint          | What it does                                                          |
| ----------------- | --------------------------------------------------------------------- |
| `POST /v1/scrape` | Loads a URL and returns HTML, cleaned HTML, Markdown, or Readability. |
| `POST /v1/screenshot` | Loads a URL and returns a hosted PNG of the page.                 |
| `POST /v1/pdf`    | Loads a URL and returns a hosted PDF of the page.                     |

Each call counts as 1 credit and is rate limited to 20 requests per minute per organization. Reach for [Sessions](/overview/sessions-api/overview) instead when you need cookies, multi-step navigation, or extension state.

### Scrape

`POST /v1/scrape` extracts the contents of a single page. The response always includes `content`, `metadata`, and `links`; pass `format` to control which content variants come back.

```typescript
import Steel from "steel-sdk";

const client = new Steel();

const result = await client.scrape({
  url: "https://docs.steel.dev",
  format: ["markdown", "readability"],
});

console.log(result.content.markdown);
console.log(result.metadata.title);
```

```python
from steel import Steel

client = Steel()

result = client.scrape(
    url="https://docs.steel.dev",
    format=["markdown", "readability"],
)

print(result.content.markdown)
print(result.metadata.title)
```

The supported formats map to fields on `content`:

| Format         | Field                  | Use when                                                  |
| -------------- | ---------------------- | --------------------------------------------------------- |
| `html`         | `content.html`         | You want the raw DOM after JS execution. Default if `format` is omitted. |
| `cleaned_html` | `content.cleaned_html` | You want stripped-down HTML without scripts, styles, ads. |
| `markdown`     | `content.markdown`     | You're feeding the page into an LLM.                      |
| `readability`  | `content.readability`  | You want Mozilla Readability's article extraction object. |

`metadata` carries the usual SEO fields (`title`, `description`, `canonical`, Open Graph tags, JSON-LD, `statusCode`, etc.) and `links` is a flat array of every anchor on the page.

```json
{
  "content": {
    "markdown": "# Steel Docs\n\n..."
  },
  "metadata": {
    "title": "Steel Docs",
    "description": "...",
    "statusCode": 200,
    "ogImage": "https://docs.steel.dev/og.png"
  },
  "links": [
    { "url": "https://docs.steel.dev/cookbook", "text": "Cookbook" }
  ]
}
```

#### Bundle a screenshot or PDF

Set `screenshot: true` or `pdf: true` on a scrape call to capture both content and a hosted file in a single request. The screenshot and PDF come back as hosted URLs alongside the scraped content.

```typescript
const result = await client.scrape({
  url: "https://docs.steel.dev",
  format: ["markdown"],
  screenshot: true,
  pdf: true,
});

console.log(result.screenshot?.url);
console.log(result.pdf?.url);
```

```python
result = client.scrape(
    url="https://docs.steel.dev",
    format=["markdown"],
    screenshot=True,
    pdf=True,
)

print(result.screenshot.url if result.screenshot else None)
print(result.pdf.url if result.pdf else None)
```

### Screenshot

`POST /v1/screenshot` returns a hosted PNG. Use `fullPage: true` to capture the entire scrollable page instead of just the viewport.

```typescript
const result = await client.screenshot({
  url: "https://docs.steel.dev",
  fullPage: true,
});

console.log(result.url); // https://files.steel.dev/v1/static/<id>.png
```

```python
result = client.screenshot(
    url="https://docs.steel.dev",
    full_page=True,
)

print(result.url)
```

The hosted URL is public and durable. Download it like any other PNG:

```typescript
import fs from "node:fs";

const response = await fetch(result.url);
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync("page.png", buffer);
```

### PDF

`POST /v1/pdf` renders the page and returns a hosted PDF URL. Same shape as screenshot: one URL in, one URL out.

```typescript
const result = await client.pdf({
  url: "https://docs.steel.dev",
});

console.log(result.url); // https://files.steel.dev/v1/static/<id>.pdf
```

```python
result = client.pdf(
    url="https://docs.steel.dev",
)

print(result.url)
```

### Shared options

All three endpoints accept the same handful of options for controlling how the page is loaded.

| Option     | Type      | What it does                                                                       |
| ---------- | --------- | ---------------------------------------------------------------------------------- |
| `useProxy` | `boolean` | Route the request through a Steel-managed residential proxy. Paid plans only.      |
| `delay`    | `number`  | Milliseconds to wait after navigation before capturing. Useful for hydrated sites. |

A common pattern for client-rendered pages is to combine `delay` with a residential proxy:

```typescript
const result = await client.scrape({
  url: "https://example.com/pricing",
  format: ["markdown"],
  delay: 5000,
  useProxy: true,
});
```

```python
result = client.scrape(
    url="https://example.com/pricing",
    format=["markdown"],
    delay=5000,
    use_proxy=True,
)
```

### Hobby plans cannot use Steel proxies on Browser Tools
`useProxy: true` returns `402 Payment Required` on the hobby plan. Upgrade, or pass your own proxy by opening a [Session](/overview/sessions-api/overview) and scraping through it.

### Errors

| Status | Meaning                                                          |
| ------ | ---------------------------------------------------------------- |
| 402    | `useProxy: true` requested on a plan that does not allow it.     |
| 408    | The browser timed out before completing the action.              |
| 429    | Concurrent session limit for your plan reached, or 20 RPM hit.   |
| 503    | No browser capacity available right now. Safe to retry.          |

### When to use Sessions instead

Browser Tools are stateless. Each call opens a fresh browser, performs the action, and tears down. Reach for a [Session](/overview/sessions-api/overview) when you need to:

- Persist cookies or local storage across navigations
- Submit forms, click through flows, or interact with the page
- Pin a proxy to a specific country (`useProxy: { geolocation: { country } }`)
- Reuse an authenticated profile or extension

You can still scrape, screenshot, or generate a PDF from inside a session. The Browser Tools endpoints are just the shortcut when you don't need the rest.

### Need help with Browser Tools?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### Should I use Browser Tools or Sessions?

Use Browser Tools (`/v1/scrape`, `/v1/screenshot`, `/v1/pdf`) for stateless one-shot actions against a URL; each call spins up a fresh browser and tears it down. Reach for Sessions when you need to persist cookies, click through multi-step flows, pin a proxy to a country, or reuse an authenticated profile or extension.

### What formats can the scrape endpoint return?

Pass `format` to get `html` (raw DOM after JS execution, the default), `cleaned_html`, `markdown` (best for feeding LLMs), or `readability` (Mozilla Readability's article object). Every response also includes `metadata` (SEO fields, status code) and a `links` array.


# Overview
URL: https://docs.steel.dev/overview/captchas-api/overview

Steel's CAPTCHA system is designed to work seamlessly with browser automation workflows, automatically detecting and solving CAPTCHAs without interrupting your automation flow.

Steel's CAPTCHAs API provides a robust solution for handling CAPTCHAs that appear during your automations. The system uses a bridge architecture that connects browser sessions with our CAPTCHA-solving capabilities, enabling real-time detection, solving, and state management.

CAPTCHA solving is particularly useful for:

*   Scraping jobs that encounter CAPTCHA challenges

*   Browser workflows that need to submit forms or handle authentication flows

*   AI agents that need to navigate CAPTCHA-protected websites

### Session Configuration

To enable autosolving, simply set `solveCaptcha: true` when creating a session.

```typescript
import Steel from 'steel-sdk';

const client = new Steel();

const session = await client.sessions.create({
  solveCaptcha: true
});
```

```python
from steel import Steel

client = Steel()
session = client.sessions.create(
    solve_captcha=True
)
```

To detect CAPTCHAs without automatically solving them, disable `autoCaptchaSolving` in the stealth config:

```typescript
const session = await client.sessions.create({
  solveCaptcha: true,
  stealthConfig: {
    autoCaptchaSolving: false
  }
});
```

```python
session = client.sessions.create(
    solve_captcha=True,
    stealth_config={
        "autoCaptchaSolving": False
    }
)
```

### How CAPTCHA Solving Works with the CAPTCHAs API

Steel's CAPTCHAs API operates through a bridge architecture that connects your browser sessions with our external CAPTCHA-solving capabilities. It helps with four key parts:

1.  **Detection**: The system automatically detects when CAPTCHAs appear on pages

2.  **State Management**: CAPTCHA states are tracked per page with real-time updates

3.  **Solving**: CAPTCHAs are then solved by us using various methods

4.  **Completion**: The system reports back when CAPTCHAs are solved or failed

### Getting CAPTCHA Status

You can check the current CAPTCHA status for any session to understand what CAPTCHAs are active and their current solving progress.

```typescript
import Steel from 'steel-sdk';

const client = new Steel();

const response = await client.sessions.captchas.status('sessionId');

console.log(response);
```

```python
from steel import Steel

client = Steel()
response = client.sessions.captchas.status(
    "sessionId",
)
print(response)
```

#### Response Format

The status endpoint returns an array of current pages and their CAPTCHA states. An example output might look like:

```json
[
   {
      "pageId":"page_12345",
      "url":"https://example.com/login",
      "isSolvingCaptcha":true,
      "tasks":[
         {
            "id":"task_67890",
            "type":"image_to_text",
            "status":"solving",
            "created":1640995200000,
            "url":"https://example.com/login",
            "pageId":"page_12345",
            "detectionTime":1640995200500,
            "totalDuration":5000,
            "solveTime":1640995205500
         }
      ],
      "created":1640995200000,
      "lastUpdated":1640995205500
   }
]
```

#### CAPTCHA Task Status

Tasks can have the following statuses:

*   `undetected`: CAPTCHA has not been detected

*   `detected`: CAPTCHA has been detected but solving hasn't started

*   `validating`: CAPTCHA is currently being validated

*   `validation_failed`: CAPTCHA token failed validation after submission

*   `solving`: CAPTCHA is currently being solved

*   `solved`: CAPTCHA has been successfully solved

*   `failed_to_detect`: CAPTCHA detection failed

*   `failed_to_solve`: CAPTCHA solving failed

### Manual Solving

If auto-solving is disabled, use the solve endpoint to trigger solving. You can solve all detected CAPTCHAs or target specific ones.

The `taskId`, `url`, and `pageId` required for targeting specific CAPTCHAs can be retrieved from the CAPTCHA status response. When using `taskId`, use the value from the task's `id` field.

```typescript
// Solve all detected CAPTCHAs
await client.sessions.captchas.solve('sessionId');

// Solve specific task
await client.sessions.captchas.solve('sessionId', { taskId: 'task_123' });

// Solve by URL
await client.sessions.captchas.solve('sessionId', { url: 'https://example.com' });

// Solve by Page ID
await client.sessions.captchas.solve('sessionId', { pageId: 'page_123' });
```

```python
# Solve all detected CAPTCHAs
client.sessions.captchas.solve("sessionId")

# Solve specific task
client.sessions.captchas.solve("sessionId", task_id="task_123")

# Solve by URL
client.sessions.captchas.solve("sessionId", url="https://example.com")

# Solve by Page ID
client.sessions.captchas.solve("sessionId", page_id="page_123")
```

### Solving Image CAPTCHAs

For image-based CAPTCHAs, you can provide XPath selectors to help the system locate and solve the CAPTCHA.

The `url` parameter is optional and defaults to the current page.

```typescript
import Steel from 'steel-sdk';

const client = new Steel();

const response = await client.sessions.captchas.solveImage('sessionId', {
  imageXPath: '//img[@id="captcha-image"]',
  inputXPath: '//input[@name="captcha"]',
});

console.log(response.success);
```

```python
from steel import Steel

client = Steel()
response = client.sessions.captchas.solve_image(
    session_id=session.id,
    image_x_path='//img[@id="captcha-image"]',
    input_x_path='//input[@name="captcha"]',
)
print(response.success)
```

#### Parameters

*   `imageXPath` (required): XPath selector for the CAPTCHA image element

*   `inputXPath` (required): XPath selector for the CAPTCHA input field

*   `url` (optional): URL where the CAPTCHA is located (defaults to current page)

#### Response

```json
{
	"success": true,
	"message": "Image captcha solve request sent"
}
```

### WebSocket Bridge

The CAPTCHA bridge uses WebSocket connections to maintain real-time communication between browser sessions and CAPTCHA-solving extensions. This enables:

*   **Real-time state updates**: Immediate notification when CAPTCHAs are detected or solved

*   **Bidirectional communication**: Extensions can send updates and receive solve requests

*   **Persistent connections**: Maintains connection throughout the session lifecycle

### State Management

The CAPTCHA bridge uses intelligent state management to handle complex scenarios:

#### Page-Based Tracking

States are tracked by `pageId` rather than URL to avoid duplicates and handle dynamic URLs effectively.

#### Task Merging

When multiple updates occur for the same CAPTCHA task, the system intelligently merges the information, preserving important details like:

*   Creation and detection timestamps

*   Solving duration calculations

*   Status progression

#### Duration Calculation

The system automatically calculates task durations based on:

*   `created` or `detectedTime`: When the CAPTCHA was first detected

*   `solveTime` or `failureTime`: When the CAPTCHA was solved or failed

*   Real-time updates during the solving process

### Integrating with Existing Automations

Steel's CAPTCHA system is designed to work seamlessly with your existing automations using Playwright/Puppeteer:

#### Monitoring CAPTCHA Progress

```typescript
async function waitForCaptchaSolution(sessionId, timeout = 30000) {
  const startTime = Date.now();

  while (Date.now() - startTime < timeout) {
    const status = await getCaptchaStatus(sessionId);

    const activeCaptchas = status.filter(state => state.isSolvingCaptcha);

    if (activeCaptchas.length === 0) {
      console.log('All CAPTCHAs solved!');
      return true;
    }

    // Log progress
    activeCaptchas.forEach(captcha => {
      console.log(`CAPTCHA on ${captcha.url}: ${captcha.tasks.length} tasks`);
    });

    await new Promise(resolve => setTimeout(resolve, 1000));
  }

  throw new Error('CAPTCHA solving timeout');
}
```

#### Basic Integration Pattern

```typescript
// Navigate to a page that might have CAPTCHAs
await page.goto('https://example.com/protected-page');

// Check if CAPTCHAs are present
const captchaStatus = await checkCaptchaStatus(sessionId);

if (captchaStatus.some(state => state.isSolvingCaptcha)) {
  // Wait for CAPTCHA to be solved
  await waitForCaptchaSolution(sessionId);
}

// Continue with automation
await page.click('#submit-button');
```

#### Handling Different CAPTCHA Types

The CAPTCHA bridge automatically handles most common CAPTCHA types. For image CAPTCHAs, you can use the image solving endpoint with specific XPath selectors.

The captcha types for each task are mapped to the CAPTCHA types we support like so:

*   `recaptchaV2`: Google's reCAPTCHA v2 with "I'm not a robot" checkbox and image challenges

*   `recaptchaV3`: Google's reCAPTCHA v3 with invisible background scoring and risk analysis

*   `turnstile`: Cloudflare Turnstile with minimal user interaction verification

*   `image_to_text:` Traditional text-based CAPTCHA requiring OCR of distorted characters

#### Best Practices

1.  **Monitor State Changes**: Regularly check CAPTCHA status during automation

2.  **Handle Timeouts**: Set reasonable timeouts for automatic CAPTCHA solving operations

3.  **Use Specific Selectors**: Provide accurate XPath selectors for image CAPTCHAs

4.  **Error Handling**: Implement proper error handling for failed CAPTCHA attempts

5.  **Logging**: Log CAPTCHA events for debugging and monitoring

The CAPTCHA system is designed to be as transparent as possible to your automation workflows, handling the complexity of CAPTCHA detection and solving while providing you with the control and visibility you need.

### Need help building with the Captchas API?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### Does Steel solve CAPTCHAs automatically?

Yes — set `solveCaptcha: true` when creating a session and Steel automatically detects and solves CAPTCHAs without interrupting your automation flow.

### Can Steel detect CAPTCHAs without solving them?

Yes — set `autoCaptchaSolving: false` in the session's `stealthConfig` to detect CAPTCHAs without auto-solving, then trigger solving manually via the solve endpoint (all detected CAPTCHAs, or a specific `taskId`, `url`, or `pageId`).

### What CAPTCHA types does Steel's CAPTCHAs API handle?

Tasks are mapped to four supported types: `recaptchaV2` (checkbox and image challenges), `recaptchaV3` (invisible scoring), `turnstile` (Cloudflare), and `image_to_text` (traditional distorted-character CAPTCHAs).

### How do I check if a CAPTCHA is being solved in my Steel session?

Call the status endpoint (`client.sessions.captchas.status('sessionId')`) — it returns each page's `isSolvingCaptcha` flag plus per-task statuses like `detected`, `solving`, `solved`, and `failed_to_solve`.

### How does Steel solve image-based CAPTCHAs?

Via the `solveImage` endpoint — you pass `imageXPath` (the CAPTCHA image element) and `inputXPath` (the answer field), both required, plus an optional `url` that defaults to the current page.


# Credentials API
URL: https://docs.steel.dev/overview/credentials-api/overview


# Overview

Securely store and inject login credentials into browser sessions without exposing them to agents or the page.

Steel's Credential system is currently in beta and is subject to improvements, updates, and changes. It will be free to use and store credentials during this period.
If you have feedback, join our Discord or open an issue on GitHub.

Steel's Credentials system is designed to allow developers to securely store credentials, inject them into sessions, and automatically sign-into websites. All without leaking sensitive data back to the agents, programs, or humans viewing a live session.

Some of the most important use-cases for AI agents are hidden behind an auth wall. Some of the data most important to both our work and personal lives live inside sign-in-protected applications. If we want browser agents to help us automate the most tedious aspects of our lives, they need access to those same applications.

The problem is sending your personal credentials (username/passwords, etc) to a browser-agent, powered by an opaque LLM API that may or may not be training on your data, represents a non-trivial security risk. Further, the process of logging in can be error prone and keeping/storing credentials on behalf of users, as an application developer, can represent a ton of responsibility and overhead.

That is the motivation behind Steel's Credentials system. Credentials are stored globally against your organization, so once created, you can reuse them in any session going forward – no need to constantly re-enter or re-provision them.

Steel's Credentials system is built around three core goals:

- Secure storage of credentials using enterprise-grade encryption.
- Controlled injection into browser sessions without exposing sensitive fields.
- Isolation mechanisms to prevent agents from extracting secrets post-injection.

### Table of Contents
- [Getting Started](#getting-started)
- [Injecting Credentials into a Session](#injecting-credentials-into-a-session)
- [TOTP Support](#totp-support)
- [How credentials are injected](#how-credentials-are-injected)
- [Envelope encryption](#envelope-encryption)
- [Using with Agent Frameworks](#using-with-agent-frameworks)

## Getting Started
Before credentials can be used in a browser session, they must first be uploaded and stored securely.

All credentials are stored globally against your organization. You only need to create them once.

To upload credentials:

```typescript
await client.credentials.create({
  origin: "https://app.example.com",
  value: {
    username: "test@example.com",
    password: "password123"
  }
});
```

```python
client.credentials.create(
    origin="https://app.example.com",
    value={
        "username": "test@example.com",
        "password": "password123"
    }
)
```

These credentials are encrypted and stored securely within Steel’s credential management service. The `namespace` field helps separate use cases for the same origin and must match the namespace used when creating the session. For more information on how namespaces work [visit the namespace section](#namespaces). You can optionally include a `totpSecret` field if your login flow uses one-time passwords (see [TOTP Support](#totp-support)).

## Injecting Credentials into a Session
When starting a session via `POST /sessions`, you can request credential injection using the optional `credentials` field:

```typescript
const session = await client.sessions.create({
  namespace: "default",
  credentials: {}
});
```

```python
client.sessions.create(
  namespace="default",
  credentials={}
)
```

If the `credentials` object is omitted, no credentials will be injected. If included as an empty object (`credentials: {}`), the default options apply:

```json
{
  "autoSubmit": true,
  "blurFields": true,
  "exactOrigin": true
}
```

- `autoSubmit`: If `true`, the form will automatically submit once filled.

- `blurFields`: If `true`, each filled field is blurred immediately after input, preventing access.

- `exactOrigin`: If `true`, credentials will only inject into pages that match the exact origin.

You can override any of these to suit your use-case. Remember to match the `namespace` with the one used in your credential creation, if omitted, it defaults to `"default"`.

Once the session is active and on the login page, credentials are typically injected within **2 seconds**. If `autoSubmit` is disabled, the agent or user must manually click the login button.

## TOTP Support
Steel supports auto-filling TOTP (Time-based One-Time Passwords). To use this feature, include a `totpSecret` in the `value` object when uploading credentials:

```json
{
  "username": "test@example.com",
  "password": "password123",
  "totpSecret": "JBSWY3DPEHPK3PXP"
}
```

The secret is securely stored and never exposed to the page. When a one-time password field is detected, Steel generates a valid code on-demand and injects it directly.

## How Credentials are Injected
The system is responsible for securely retrieving and injecting them into service webpages. This happens through a general background communication layer that connects to a secure credential service.

### Overview: how the service fills credentials in a page
1. The credential service loads a lightweight script into each active page and frame.

2. On startup, it watches for forms or login components using mutation observers and shadow DOM traversal.

3. When a valid credential target is detected, it is validated and ranked.

4. The top-ranked candidate is selected as the active target.

5. Observers are attached to the relevant input fields and forms.

6. The credential service requests credentials matching the current org, namespace, and target origin.

7. Once decrypted, credentials are injected directly into the selected form fields.

8. Inputs are updated programmatically, preserving synthetic events and page behavior.
    1. We detect and only inject credentials into a username, password, and one-time password field. The username field is generic and we try our best to map any identifier to this property (email, identifier, username, etc.).
    2. inputs are blurred once a value is inserted (configurable) to prevent vision agents from reading PII

9. The form is submitted either natively or via simulated interaction, depending on the form structure if autoSubmit is configured.

10. Updates to the DOM are continuously monitored to adapt to dynamic changes in the page.

## Envelope encryption
Envelope encryption is a secure and scalable pattern where data is encrypted using a randomly generated data key (usually with a symmetric algorithm like AES), and that data key is then encrypted with a master key managed by a key management store (KMS).

Each credential is protected with its own short‑lived AES‑256‑GCM key. The key is then encrypted with a private KMS key specific to an organization. The encrypted data and the encrypted key travel together.

At decryption time, the inverse happens where we then get the encrypted AES key, decrypt it using the specific key pair for the KMS and then use this decrypted AES key to decrypt the credential. The clear-text credentials are placed directly into the in-memory session and sent to the target service over our private WireGuard backbone ensuring end-to-end encryption and safe keeping of your credentials.

#### Additional authenticated data (AAD)
We bind the cipher-text to its context by including the org ID and credential origin as AAD. A mismatch during decrypt causes the operation to fail which blocks replay attacks across orgs.

## Namespaces
Namespaces allow you to differentiate between multiple credentials for the same origin. This is useful when you need to store and use separate login details for different users or use cases.

By default, all credentials and sessions are created under the `default` namespace. If you don’t specify a namespace, this is what will be used.

#### Why Use Namespaces?
If you have multiple credentials for the same website, namespaces help you control which one is used in a given session.

For example, say you have two users who log in to the same domain:

```json
// Credential A
{
  "namespace": "example:fred",
  "origin": "https://app.example.com",
  "value": {
    "username": "fred@example.com",
    "password": "hunter2"
  }
}

// Credential B
{
  "namespace": "example:jane",
  "origin": "https://app.example.com",
  "value": {
    "username": "jane@example.com",
    "password": "letmein"
  }
}
```

To use **Fred’s** credentials in a session:

```json
POST /sessions
{
  "namespace": "example:fred",
  "credentials": {}
}
```

This ensures only the credentials created under `example:fred` will be injected.

#### Best Practices
- Use simple, descriptive namespaces like `example:fred` or `test:jane`.

- Stick to a consistent pattern (e.g., `org:user`) for better organization.

- Always match the `namespace` in your session with the one used to create the credentials.

Namespace matching is exact. There is no inheritance or wildcard matching—only credentials in the exact namespace provided will be used.

## Using with Agent Frameworks
Steel is designed to integrate seamlessly with browser automation tools and agent frameworks such as `browser-use` and similar libraries.

While we don’t yet expose framework-specific SDKs or utilities, the process is straightforward and works out of the box with minimal setup.

#### How it Works
Once credentials are linked to your session, injection and login will occur automatically as part of the page lifecycle. To make use of this in your agent or script, follow this basic pattern:

1. **Navigate** to the login page of the target website.

2. **Wait** at least 2 seconds to allow Steel to detect and fill the form.

3. **Continue** once logged in.

If `autoSubmit` is enabled (which it is by default), the login form will be submitted automatically once the fields are populated and validated.

If `autoSubmit` is disabled, you must explicitly trigger the login action (e.g., click the login button) after credentials are filled.

#### Example Flow

```typescript
await page.goto("https://app.example.com/login");

// Optional: ensure login form is present
await page.waitForSelector("form");

// Wait for Steel to inject and (optionally) submit the form
await page.waitForTimeout(2000);

// Recommended: confirm login succeeded
await page.waitForSelector(".dashboard"); // or some element/text that confirms login
```

#### Notes
- Credential injection is bound to the session's namespace and the origin provided when the credential was created.

- Injection will only occur on exact origins if `exactOrigin: true` (default).

- The page must be fully loaded and interactive for injection to proceed reliably.

We plan to release official helpers and utilities for common frameworks like `browser-use`, `Playwright`, and `Puppeteer` soon. For now, you can build on this guide to integrate Steel into your existing automation workflows.

### Need help building with the Credentials API?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### How does Steel keep stored credentials secure?

With envelope encryption — each credential is protected by its own short-lived AES-256-GCM key, which is in turn encrypted with an organization-specific KMS key. The ciphertext is also bound to your org ID and credential origin as additional authenticated data (AAD), which blocks replay attacks across orgs.

### Can AI agents see the passwords Steel injects?

No — credentials are injected into login forms without being exposed to agents, programs, or humans viewing a live session, and each filled field is blurred immediately after input (`blurFields: true` by default) to prevent vision agents from reading PII.

### Does Steel's Credentials API support 2FA / TOTP logins?

Yes — include a `totpSecret` in the credential's `value` object and Steel generates a valid time-based one-time password on demand when a TOTP field is detected. The secret is securely stored and never exposed to the page.


# Overview
URL: https://docs.steel.dev/overview/extensions-api/overview

Steel’s Extensions system is currently in beta and is subject to improvements, updates, and changes. If you have feedback, join our Discord or open an issue on GitHub.

Steel's extensions are designed to enhance the functionality of Steel sessions by providing additional features and capabilities. These extensions can be used to automate tasks, enhance security, and improve the overall agent experience. They can be installed through the API for your organization and attached to any session.

Extensions have long been a part of the browser ecosystem, since the release of Internet Explorer version 4 in 1997, users have been able to create their own extensions and make their browser their own. With the advent of agentic browsing and browser agents, extensions have gained a whole new light. Allowing thousands of agents to extend their own browser sessions with custom functionality.

### Getting Started

Before extensions can be used in a browser session, they must first be uploaded either with a .zip/.crx file or downloaded from the Chrome Web Store.

All extensions are stored globally against your organization. You only need to upload them once. The supported formats include .zip and .crx

### Upload Extension From File

The extensions uploaded have a couple of requirements. They need a preliminary manifest.json file to define the extension's metadata and functionality. This file should include details such as the extension's name, version, and any permissions required.

```typescript
await client.extensions.upload({
    file: fs.readFileSync('extensions/recorder/recorder.zip')
  });
```

```python
with open("extensions/recorder/recorder.zip", "rb") as file:
    client.extensions.upload(
        file=file
    )
```

### Upload Extension from Chrome Web Store

Go to the Chrome Web Store and click on the extension you want to upload. Copy the URL and include it in the request below

```typescript
await client.extensions.upload({
   url: "https://chromewebstore.google.com/detail/.../..."
});
```

```python
client.extensions.upload(
    url="https://chromewebstore.google.com/detail/.../..."
)
```

Once they are installed for your organization, you can inject them into your sessions.

### Injecting Extensions into a Session

You can inject specific extensions into your sessions based on the `extensionId` field or you can pass `all_ext` to inject all extensions from your organization.

```typescript
const session = await client.sessions.create({
  extensionIds: ['all_ext'] // extensionIds=['extensionId_1', 'extensionId_2']
});
```

```python
client.sessions.create(
    extension_ids=['all_ext'] # extension_ids=['extensionId_1', 'extensionId_2']
)
```

And now your sessions have extensions!

These extensions will be injected into the Steel browser session that then runs with that session. Extensions are loaded and initialized when the session starts. They can communicate with the session using the Chrome DevTools Protocol (CDP) and interact with the browser environment.

### Updating Extensions From File

After using your extensions, you can update them by uploading a new version of the extension. You will need to specify the `extensionId` of the extension you want to update.

```typescript
await client.extensions.update("{extensionId}",{
    file: fs.readFileSync("extensions/recorder2/recorder2.zip")
  });
```

```python
with open("extensions/recorder2/recorder2.zip", "rb") as file:
    client.extensions.update("{extensionId}",
        file=file
    )
```

### Updating Extensions From Chrome Web Store

You will need to specify the `extensionId` of the extension you want to update

```typescript
await client.extensions.update("{extensionId}",{
    url: "https://chromewebstore.google.com/detail/.../..."
});
```

```python
client.extensions.update("{extensionId}",
    url="https://chromewebstore.google.com/detail/.../..."
)
```

### Seeing your Extensions

To see your organization's installed extensions, you can use the `GET /v1/extensions` endpoint.

```typescript
const extensions = await client.extensions.list();
```

```python
extensions = client.extensions.list()
```

### Deleting an Extension

To delete one of your organization's installed extensions, you can use the `DELETE /v1/extensions/{extensionId}` endpoint.

```typescript
await client.extensions.delete("{extensionId}")
```

```python
client.extensions.delete("{extensionId}")
```

### Deleting all Extensions

To delete all of your organization's installed extensions, you can use the `DELETE /v1/extensions/` endpoint.

```typescript
await client.extensions.deleteAll()
```

```python
client.extensions.deleteAll()
```

 ### Need help building with the Extensions API?
 Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### Can I use Chrome extensions in Steel browser sessions?

Yes. Upload an extension as a `.zip` or `.crx` file, or directly from a Chrome Web Store URL, then attach it to any session. Note the Extensions system is currently in beta.

### Do I have to upload an extension for every session?

No. Extensions are stored globally against your organization, so you only upload them once and can then inject them into any session.

### How do I add extensions to a session?

Pass specific extension IDs via the `extensionIds` field when creating the session, or pass `all_ext` to inject every extension installed for your organization. Extensions are loaded and initialized when the session starts.

### How do I update or remove an extension?

Update by calling `client.extensions.update` with the `extensionId` and a new file or Chrome Web Store URL. Delete a single extension with `DELETE /v1/extensions/{extensionId}` or remove all of them with `DELETE /v1/extensions/`.


# Overview
URL: https://docs.steel.dev/overview/files-api/overview


Steel provides two complementary file management systems: Session Files for working with files within active browser sessions, and Global Files for persistent file storage across your organization.

### Overview

Steel's file management system makes it easy to work with files in your automated workflows:

*   **Session-Based File Operations**: Upload files to active sessions for immediate use in browser automations, download files acquired during browsing

*   **Persistent File Storage**: Maintain a global file repository for reuse across multiple sessions and workflows

*   **Workspace Management**: Organize and access files generated across different automation runs

*   **Data Pipeline Integration**: Upload datasets once and reference them across multiple automation sessions

*   **File Archival**: Automatically preserve files from completed sessions for later access

### How It Works

#### Session Files System

Files uploaded to active sessions become available within that session's isolated VM environment. These files can be used immediately with web applications and browser automation tools. When files are downloaded from the internet during a session, they become accessible through the same API. Session files persist beyond session lifecycle - files are automatically backed up when sessions end.

#### Global Files System

The Global Files API provides persistent, organization-wide file storage independent of browser sessions. Files uploaded to global storage can be referenced and mounted in any session. All session files are automatically promoted to global storage when sessions are released, creating a comprehensive file workspace.

### Session Files API

This section outlines how to interact with the filesystem inside of the VM that your session is running from. All of these files are accessible from the browser.

#### Upload Files to Session File System

```typescript
// Upload file to session environment
const file = fs.createReadStream("./steel.png");
const session = await client.sessions.create();
const uploadedFile = await client.sessions.files.upload(session.id, {
  file: file, // or path in global files api or absolute url
});
```

```python
import requests

session_id = "YOUR_SESSION_ID"
api_key = "YOUR_API_KEY_HERE"
file_path = "./steel.png"

with open(file_path, "rb") as f:
    response = requests.post(
        f"https://api.steel.dev/v1/sessions/{session_id}/files",
        headers={"steel-api-key": api_key},
        files={"file": f}
    )
print(response.json())
```

#### List Files in a Session File System

```typescript
const files = await client.sessions.files.list(sessionId);
files.data.forEach((file) => {
  console.log(`${file.path} | Size: ${file.size} | Last Modified: ${file.lastModified}`);
});
```

```python
import requests

session_id = "YOUR_SESSION_ID"
api_key = "YOUR_API_KEY_HERE"

response = requests.get(
    f"https://api.steel.dev/v1/sessions/{session_id}/files",
    headers={"steel-api-key": api_key}
)
for file in response.json()["data"]:
    print(f"{file['path']} | Size: {file['size']} | Last Modified: {file['lastModified']}")
```

#### Download Files from Session File System

For raw HTTP requests, the `{path}` parameter is relative. Session file responses currently come back in a `/files/...` form, so strip that prefix before interpolating the value into the URL.

```typescript
// Download a specific file from a session
const response = await client.sessions.files.download(sessionId, "path/to/file");
const fileBlob = await response.blob();

// Download all files as zip archive
const archiveResponse = await client.sessions.files.downloadArchive(sessionId);
```

```python
import requests

session_id = "YOUR_SESSION_ID"
api_key = "YOUR_API_KEY_HERE"

# Download a specific file
file_resp = requests.get(
    f"https://api.steel.dev/v1/sessions/{session_id}/files/path/to/file",
    headers={"steel-api-key": api_key}
)
with open("downloaded_file", "wb") as f:
    f.write(file_resp.content)

# Download all files as zip archive
archive_resp = requests.get(
    f"https://api.steel.dev/v1/sessions/{session_id}/files.zip",
    headers={"steel-api-key": api_key}
)
with open("session_files.zip", "wb") as f:
    f.write(archive_resp.content)
```

#### Delete Files from Sessions File System

The raw HTTP delete endpoint also expects the relative path segment rather than the `/files/...` form returned by session file responses.

```typescript
// Delete a specific file from a session
const response = await client.sessions.files.delete(sessionId, "path/to/file");

// Delete all files in a session
const archiveResponse = await client.sessions.files.deleteAll(session.id);
```

```python
import requests

session_id = "YOUR_SESSION_ID"
api_key = "YOUR_API_KEY_HERE"

# Delete a specific file
del_resp = requests.delete(
    f"https://api.steel.dev/v1/sessions/{session_id}/files/path/to/file",
    headers={"steel-api-key": api_key}
)
print(del_resp.status_code)

# Delete all files in a session
del_all_resp = requests.delete(
    f"https://api.steel.dev/v1/sessions/{session_id}/files",
    headers={"steel-api-key": api_key}
)
print(del_all_resp.status_code)
```

### Global Files API

#### Upload File to Global Storage

```typescript
const file = fs.createReadStream("./dataset.csv");
const globalFile = await client.files.upload({
    file,
   // path: "dataset.csv" // optional
});
console.log(globalFile.path); // dataset.csv

// Using the file from Global Files API in a session
const session = await client.sessions.create();
const uploadedFile = await client.sessions.files.upload(session.id, {
  file: globalFile.path
});
```

```python
import requests

api_key = "YOUR_API_KEY_HERE"
file_path = "./dataset.csv"

with open(file_path, "rb") as f:
    response = requests.post(
        "https://api.steel.dev/v1/files",
        headers={"steel-api-key": api_key},
        files={"file": f}
    )
print(response.json())
```

#### List All Files

```typescript
const files = await client.files.list();
files.data.forEach((file) => {
  console.log(`${file.path} | Size: ${file.size} | Last Modified: ${file.lastModified}`);
});
```

```python
import requests

api_key = "YOUR_API_KEY_HERE"

response = requests.get(
    "https://api.steel.dev/v1/files",
    headers={"steel-api-key": api_key}
)
for file in response.json()["data"]:
    print(f"{file['path']} | Size: {file['size']} | Last Modified: {file['lastModified']}")
```

#### Download Global File

For raw HTTP requests, pass a relative `{path}` value here as well.

```typescript
const response = await client.files.download(file.path); // dataset.csv
const fileBlob = await response.blob();
```

```python
import requests

api_key = "YOUR_API_KEY_HERE"
file_path = "dataset.csv"

response = requests.get(
    f"https://api.steel.dev/v1/files/{file_path}",
    headers={"steel-api-key": api_key}
)
with open(file_path, "wb") as f:
    f.write(response.content)
```

#### Delete Global File

The raw HTTP delete endpoint expects the same relative `{path}` format used by the download endpoint.

```typescript
await client.files.delete(file.path);
```

```python
import requests

api_key = "YOUR_API_KEY_HERE"
file_path = "dataset.csv"

response = requests.delete(
    f"https://api.steel.dev/v1/files/{file_path}",
    headers={"steel-api-key": api_key}
)
print(response.status_code)
```

### Usage in Context

#### Set File Input Values

Reference uploaded files in file input elements using CDP (Chrome DevTools Protocol).

```typescript
// Create CDP session for advanced controls
const cdpSession = await currentContext.newCDPSession(page);
const document = await cdpSession.send("DOM.getDocument");

// Find the input element
const inputNode = await cdpSession.send("DOM.querySelector", {
  nodeId: document.root.nodeId,
  selector: "#file-input"
});

// Set the uploaded file as input
await cdpSession.send("DOM.setFileInputFiles", {
  files: [uploadedSessionFile.path],
  nodeId: inputNode.nodeId,
});

```

#### Standard Playwright/Puppeteer Upload

```typescript
// For simple/smaller file uploads,
// using standard automation library methods will look at local files
await page.setInputFiles("#file-input", [uploadedSessionFile.path]);
```

#### Browser-Use Example

Browser-use needs some setup before it can be used. This includes setting up the browser profile with the correct downloads path and adding in a step hook to extract downloaded files to your local machine if necessary.

```python
# Before agent main loop...

# Hook to extract downloaded files to local machine if necessary
async def step_hook_start(agent):
    if os.environ.get("BROWSER_PROVIDER") == "steel":
        await agent._check_and_update_downloads()
        if agent.available_file_paths and len(agent.available_file_paths) > 0:
            has_new_files = False
            for file_path in agent.available_file_paths:
                if file_path not in downloaded_files:
                    downloaded_files.append(file_path)
                    has_new_files = True
            if has_new_files:
                try:
                    extracted_files = await browser_service.extract_downloaded_files(DOWNLOAD_PATH)
                    logger.info(f"Extracted files: {extracted_files}")
                except Exception as e:
                    logger.error(f"Failed to extract downloaded files: {e}")

async def main():
    try:
        browser_session = Browser(cdp_url=cdp_url, downloads_path="/files")
        await browser_session.connect()
        await browser_session.cdp_client.send.Target.createBrowserContext()
        browser_context_ids_return = await browser_session.cdp_client.send.Target.getBrowserContexts()
        browser_context_ids = browser_context_ids_return['browserContextIds']
        browser_context_id = browser_context_ids[0]
        await browser_session.cdp_client.send.Browser.setDownloadBehavior(params={"behavior": "allow", "downloadPath": "/files", "eventsEnabled": True, "browserContextId": browser_context_id})
        agent = Agent(task=TASK, llm=model, browser_session=browser_session)
        agent.browser_session.browser_profile.downloads_path = LOCAL_DOWNLOAD_PATH
        agent_results = await agent.run(
            on_step_start=step_hook_start,
            max_steps=5
        )
    except Exception as e:
        print(f"Error: {e}")
    finally:
        # Clean up resources
        if session:
            client.sessions.release(session.id)
            print("Session released")
        print("Done!")
# Rest of code...
```

#### Complete Example

End-to-end workflow demonstrating global file management and session file operations.

```typescript
import dotenv from "dotenv";
import fs from "fs";
import { chromium } from "playwright";
import Steel from "steel-sdk";

dotenv.config();

const client = new Steel({
  steelAPIKey: process.env.STEEL_API_KEY,
});

async function main() {
  let session;
  let browser;

  try {
    // Upload dataset to global storage for reuse
    const datasetFile = new File(
      [fs.readFileSync("./data/stock-data.csv")],
      "stock-data.csv",
      { type: "text/csv" }
    );

    const globalFile = await client.files.upload({ file: datasetFile });
    console.log(`Dataset uploaded to global storage: ${globalFile.path}`);

    // Create session and mount global file
    session = await client.sessions.create();
    console.log(`Session created: ${session.sessionViewerUrl}`);

    const sessionFile = await client.sessions.files.upload(session.id, {
      file: globalFile.path
    });

    // Connect browser and use the file
    browser = await chromium.connectOverCDP(
      `wss://connect.steel.dev?apiKey=${process.env.STEEL_API_KEY}&sessionId=${session.id}`
    );

    const currentContext = browser.contexts()[0];
    const page = currentContext.pages()[0];

    // Navigate to data visualization tool
    await page.goto('https://www.csvplot.com/');

    // Upload file to web application using CDP
    const cdpSession = await currentContext.newCDPSession(page);
    const document = await cdpSession.send("DOM.getDocument");
    const inputNode = await cdpSession.send("DOM.querySelector", {
      nodeId: document.root.nodeId,
      selector: "#load-file",
    });

    await cdpSession.send("DOM.setFileInputFiles", {
      files: [sessionFile.path],
      nodeId: inputNode.nodeId,
    });

    // Wait for visualization and capture
    await page.waitForSelector("svg.main-svg");

    // Download all session files (original upload + any generated files)
    const archiveResponse = await client.sessions.files.downloadArchive(session.id);
    const zipBlob = await archiveResponse.blob();

    // Files are automatically available in global storage after session ends

  } catch (error) {
    console.error("Error:", error);
  } finally {
    if (browser) await browser.close();
    if (session) await client.sessions.release(session.id);

    // List all available files in global storage
    const allFiles = await client.files.list();
    console.log(`Total files in storage: ${allFiles.data.length}`);
  }
}

main();
```

### Need help building with the Files API?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### How do I upload a file into a website's file input from a Steel session?

Upload the file to the session first, then either use CDP's `DOM.setFileInputFiles` with the session file path, or for simpler cases use standard automation methods like Playwright's `page.setInputFiles` with the session file path.

### Can I download all files from a session at once?

Yes. Use `client.sessions.files.downloadArchive(sessionId)` in the SDK, or hit the `files.zip` endpoint via raw HTTP, to download every file in the session as a single zip archive.


# Overview
URL: https://docs.steel.dev/overview/profiles-api/overview

### Overview

Steel's profiles API allows you to create, update, and persist profiles across sessions. Profiles are used to store information about the browser session like auth, cookies, extensions, credentials, and browser settings.

Then you can keep reusing profiles across sessions for each different use case. Think a LinkedIn profile, a GitHub profile, or a Facebook profile.

This allows your agents to look more human, persist everything across sessions and frees you to focus on the most important part of your workflow.

Profiles preserve browser identity. [Dedicated IPs](/overview/sessions-api/dedicated-ips)
preserve network identity. For account-based agents, the strongest setup is usually one
profile plus one dedicated IP per account, so sites see the same cookies, storage, and a
familiar IP instead of a fresh browser from a new network every run.

### Limits
- There is a 300 MB limit on the size of a profile, if the upload fails after a session, the profile will be set to a `FAILED` state and cannot be used
- If a profile is not used after 30 days, it will be automatically deleted

### How Profiles Work

Profiles work by storing a snapshot of the browser's User Data Directory. This includes all the data that is stored in the browser, such as cookies, extensions, credentials, and browser settings.

1. Session gets created with a `persistProfile` flag
2. Initial profile gets created with some information on the session and gets stored in an `UPLOADING` state
3. After the session is released, the userDataDir is persisted and the additional information on the profile is updated and the profile is set to the `READY` state
4. Whenever a session is created with the `profileId`, the profile is loaded from the storage and the session is started with the same userDataDir and context

#### Persist a profile when starting a session

```typescript
// Start a session and persist the profile
const firstSession = await client.sessions.create({ persistProfile: true })
```

```python
# Start a session and persist the profile
first_session = client.sessions.create(persist_profile=True)
```

#### Start a second session with your new profile

```typescript
// Start a session with the persisted profile
const secondSession = await client.sessions.create({ profileId: firstSession.profileId })
```

```python
# Start a session with the persisted profile
second_session = client.sessions.create(profile_id=first_session.profile_id)
```

This will return a profileId from the session which will allow you to pass it into new sessions in the future.

### Persisting browser information automatically

Persisting additional information about the browser session like auth, cookies, extensions, credentials, and browser settings is not on by default, to keep building up context with each session, pass persistProfile=True along with your profileId.

#### Update your profile after a new session

```typescript
// Update the profile with new information, this will update the profile with whatever happens in the session
const thirdSession = await client.sessions.create({ profileId: firstSession.profileId, persistProfile: true })
```

```python
# Update the profile with new information, this will update the profile with whatever happens in the session
third_session = client.sessions.create(profile_id=first_session.profile_id, persist_profile=True)
```

### Persisting browser information manually

You can also manually create and update a profile via the Profiles API. This allows you to update the proxy, user-agent, or replace the entire userDataDir for your profile.

#### Create your profile

```typescript
// Create a new profile with new information
await client.profiles.create({ userDataDir: fs.readFileSync('path/to/userDataDir.zip'), userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'})
```

```python
# Create a new profile with new information
with open("path/to/userDataDir.zip", "rb") as file:
    client.profiles.create(user_data_dir=file, user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3')
```

#### Update your profile with some information

```typescript
// Update the profile with new information, this will be used next session
await client.profiles.update(firstSession.profileId, { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'})
```

```python
# Update the profile with new information, this will be used next session
client.profiles.update(first_session.profile_id, user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3')
```

### FAQ

### What does a Steel profile actually store?

A profile stores a snapshot of the browser's User Data Directory, which includes everything stored in the browser: auth, cookies, extensions, credentials, and browser settings. You can keep a separate profile per use case, like a LinkedIn profile or a GitHub profile.

### How do I create and reuse a profile across sessions?

Create a session with `persistProfile: true`; after release, the profile reaches the `READY` state and the session returns a `profileId`. Pass that `profileId` when creating future sessions to start them with the same user data directory and context.


# Clustering
URL: https://docs.steel.dev/overview/self-hosting/clustering



# Docker
URL: https://docs.steel.dev/overview/self-hosting/docker

# Overview

This guide provides step-by-step instructions to set up your own Steel Browser instance using Docker. The setup consists of multiple deployment options – from the traditional docker-compose setup to the new, simplified single Docker image deployment.

## Prerequisites

* Docker (20.10.0 or later)
* At least 4GB of RAM
* 10GB of free disk space

## Quick Start Using Docker Compose

1. Create a new directory for your Steel Browser instance:

```bash
mkdir steel-browser && cd steel-browser
```

2. Create the following file:

### docker-compose.yaml

```yaml
services:
  api:
    image: ghcr.io/steel-dev/steel-browser-api:latest
    ports:
      - "3000:3000"
      - "9223:9223"
    volumes:
      - ./.cache:/app/.cache
    networks:
      - steel-network

  ui:
    image: ghcr.io/steel-dev/steel-browser-ui:latest
    ports:
      - "5173:80"
    depends_on:
      - api
    networks:
      - steel-network

networks:
  steel-network:
    name: steel-network
    driver: bridge
```

3. Launch the containers:

```bash
docker compose up -d
```

4. Access Steel Browser by opening `http://localhost:5173` in your web browser.

## Alternative Deployment: Single Docker Image

Steel Browser can now be deployed using a single Docker image—no more complex docker-compose setup!

### Single Docker Image Deployment

Run the following command to launch Steel Browser:

```bash
docker run --rm -it -p 3000:3000 -p 9223:9223 ghcr.io/steel-dev/steel-browser:latest
```

This command will:
- Pull the latest Docker image from GitHub Container Registry.
- Expose the API on port 3000 and Chrome debugging on port 9223.
- Run the container interactively and remove it when stopped.

Access Steel Browser via your browser at `http://localhost:3000` and the UI at `http://localhost:3000/ui`.

## Building the Singular Docker Image Locally

If you wish to build the Docker image from source rather than relying on the pre-built image, follow these steps:

1. Clone the repository:

```bash
git clone https://github.com/steel-dev/steel-browser.git
cd steel-browser
```

2. Build the Docker image:

```bash
docker build -t steel-browser:local .
```

3. Run the newly built image:

```bash
docker run --rm -it -p 3000:3000 -p 9223:9223 steel-browser:local
```

This method gives you the flexibility to modify the image locally. Compared to the docker-compose setup where the API and UI are managed in separate containers, here everything runs within one container, simplifying deployment for testing and development.

## Advanced Setup

### Building From Source with Docker Compose

If you prefer to build the containers yourself with docker-compose:

1. Clone the repository:

```bash
git clone https://github.com/steel-dev/steel-browser.git
cd steel-browser
```

2. Create a `.env` file (optional).

3. Build and start using the development compose file:

```bash
docker compose -f docker-compose.dev.yml up -d --build
```

_The “-d” flag runs the containers in the background._

### Configuration Options

* **API Port**: Default is 3000 (internally also 3000). If changed in the compose file, update the API binding accordingly.
* **UI Port**: Default is 5173 (or 80 inside container). Adjust if needed.
* **Chrome Debugging Port**: Default is 9223. Required for browser communication.

### Volume Persistence

The `.cache` directory stores Chrome data and extensions. Mount it as a volume for persistence:

```yaml
volumes:
  - ./.cache:/app/.cache
```

## Architecture

Steel Browser consists of two main components when using docker-compose:

1. **API Container**: Runs Chrome in headless mode and provides CDP (Chrome DevTools Protocol) services.
2. **UI Container**: An Nginx-based frontend for interacting with the browser.

When using the single Docker image deployment, both the API and UI are integrated into one container.

## Customizing the Build

### Using a Different Chrome Version

The API container uses Chrome 128.0.6613.119 by default. To use a different version:

1. Create a custom Dockerfile based on the API one.
2. Modify the Chrome installation section:

```dockerfile
ARG CHROME_VERSION="128.0.6613.119"
RUN apt-get update && \
    DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
    wget \
    ca-certificates \
    curl \
    unzip \
    && CHROME_DEB="google-chrome-stable_${CHROME_VERSION}-1_amd64.deb" \
    && wget -q "https://mirror.cs.uchicago.edu/google-chrome/pool/main/g/google-chrome-stable/${CHROME_DEB}"
    # ...rest of the installation...
```

### Changing Node Version

Both containers use Node 22.13.0 by default. To use a different version, modify the build arguments:

```yaml
services:
  api:
    build:
      context: .
      dockerfile: ./api/Dockerfile
      args:
        NODE_VERSION: 18.19.0
```

## Troubleshooting

### Chrome Won't Start

Ensure your host has enough resources and check the API container logs:

```bash
docker logs steel-browser_api_1
```

Common issues include:
* Running on ARM architecture (There are official images for ARM, or build the image yourself)
* Insufficient memory
* Missing shared libraries
* Permission issues with the `.cache` directory

### Connectivity Issues

If the UI can't connect to the API:
1. Verify both containers are running.
2. Check if the API is accessible:

```bash
curl http://localhost:3000/api/health
```

3. Ensure the containers can communicate over the network:

```bash
docker exec steel-browser_ui_1 curl http://api:3000/api/health
```

## Production Deployment

For production environments:
1. Use specific image versions rather than `latest`.
2. Set up a proper reverse proxy with HTTPS.
3. Configure appropriate resource limits.

Example production compose file:

```yaml
services:
  api:
    image: ghcr.io/steel-dev/steel-browser-api:sha256:...
    restart: always
    ports:
      - "3000:3000"
    deploy:
      resources:
        limits:
          memory: 2G
    volumes:
      - ./data/.cache:/app/.cache
    networks:
      - steel-network

  ui:
    image: ghcr.io/steel-dev/steel-browser-ui:sha256:...
    restart: always
    ports:
      - "5173:80"
    networks:
      - steel-network

networks:
  steel-network:
    name: steel-network
    driver: bridge
```

## Security Considerations

* Avoid exposing the Chrome debugging port (9223) to the public internet.
* Consider not exposing the API if the UI and API are running within the same secured network.
* Set up proper authentication if deploying publicly.
* Keep containers updated with the latest versions.

## Updating

To update to the latest version:

```bash
docker compose pull
docker compose up -d
```

For custom builds:

```bash
git pull
docker compose -f docker-compose.dev.yml up -d --build
```

### Need help running locally?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.


# Extensions
URL: https://docs.steel.dev/overview/self-hosting/extensions



# Profiles
URL: https://docs.steel.dev/overview/self-hosting/profiles



# Self-Host Steel Browser on Railway
URL: https://docs.steel.dev/overview/self-hosting/railway

[Deploy the Template on Railway ↗](https://railway.com/deploy/steelbrowser?referralCode=Jwc4kg&utm_medium=integration&utm_source=template&utm_campaign=generic)

### Overview
Self-hosting Steel Browser on Railway provides a reliable, scalable environment for running headless Chrome instances. The Steel Browser API handles browser session management, proxy configuration, and CDP passthroughs while Railway provides extremely easy APIs to scale and handles resource allocation automatically. Running Steel Browser on Railway's infrastructure ensures your browser automations run consistently with minimal configuration, while providing automatic scaling and health monitoring for production workloads.

### Common Use Cases for Self-Hosted Steel Browser
- **Web Scraping:** Extract data from dynamic websites that require JavaScript rendering
- **Browser Automation:** Automate repetitive web tasks and workflows
- **End-to-End Testing:** Run automated browser tests for web applications
- **Screenshot & PDF Generation:** Capture screenshots or generate PDFs from web content
- **Data Collection:** Gather information from multiple web sources programmatically

### Dependencies for Hosting Steel Browser
- **Docker:** Steel Browser runs as a containerized application
- **Chrome/Chromium:** Headless browser engine (included in the Docker image)
- **Node.js Runtime:** Required for the Steel Browser service

### Deployment Dependencies
- [Steel Browser GitHub Repository](https://github.com/steel-dev/steel-browser)
- [Steel Browser Documentation](https://docs.steel.dev/)
- [Chrome DevTools Protocol Documentation](https://chromedevtools.github.io/devtools-protocol/)

### Implementation Details

**Steel Browser Health Check Endpoint:**

Verify your Steel Browser instance is running:

```bash
curl https://your-domain.railway.app/v1/health
```

**Connecting to Steel Browser:**
After deployment, create a session and connect to your Steel Browser instance on the public domain using Playwright:

```typescript
import { chromium } from "playwright";
import Steel from "steel-sdk";
const client = new Steel({
  baseUrl: `https://${process.env.RAILWAY_PUBLIC_DOMAIN}`,
});
session = await client.sessions.create();
browser = await chromium.connectOverCDP(session.websocketUrl);
// The rest of your automation
```

### Why Deploy Steel Browser on Railway?

Railway is a singular platform to deploy your infrastructure stack. Railway will host your infrastructure so you don't have to deal with configuration, while allowing you to vertically and horizontally scale it.

By deploying Steel Browser on Railway, you are one step closer to supporting a complete full-stack application with minimal burden. Host your servers, databases, AI agents, and more on Railway.

**Benefits of Steel Browser on Railway:**
- Automatic HTTPS/SSL configuration
- Built-in health monitoring
- Easy scaling as your browser automation needs grow
- Simple environment variable management
- Seamless integration with other Railway services

### After Deploying Steel Browser

After deploying this template, users should:

1. **Access Your Steel Instance:** Navigate to the Railway-provided public domain
2. **Verify Health:** Check the `/v1/health` endpoint returns a successful response
3. **Configure API Access:** Use the public domain URL in their application code
4. **Monitor Usage:** Check Railway's metrics dashboard for resource usage

### Securing Your Steel Browser Instance
- Consider adding authentication if exposing publicly
- Monitor for unusual traffic patterns
- Set up rate limiting if needed for production use


# Steel Local vs Steel Cloud
URL: https://docs.steel.dev/overview/self-hosting/steel-local-vs-steel-cloud

# Overview

| Feature          | Steel Local                               | Steel Cloud                                                  |
|------------------|-------------------------------------------|--------------------------------------------------------------|
| Concurrency      | 1                                         | 100+                                                         |
| Stealth          | Limited                                   | Advanced Stealth (docs)                                      |
| Captcha Solving  | None                                      | Supported with the Captchas API                              |
| Proxies          | Bring your own                            | Bring your own + Steel Managed Proxies                       |
| Multi-Region     | Host it yourself                          | Supported with region flag during session creation           |
| Credentials      | Not supported                             | Supported with the Credentials API                           |
| Extensions       | Supported by loading in `api/extensions/` | Supported by using the Extensions API                        |
| Files            | Not supported                             | Supported by the Files API                                   |

The defining factor between running Steel locally and using Steel Cloud is concurrency.

For the Extensions API, if you put the extensions you would like to build/load in the `api/src/extensions/` folder then Steel Local will build these and inject them into the session. Credentials are not supported in Steel Local.

### Need help running locally?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### Can I use proxies with self-hosted Steel?

Yes — Steel Local supports bringing your own proxies. Steel Cloud additionally offers Steel-managed proxies on top of BYOP.

### Can I load browser extensions in Steel Local?

Yes — put the extensions you want in the `api/src/extensions/` folder and Steel Local will build and inject them into the session. On Steel Cloud, extensions are loaded via the Extensions API instead.


# WebRTC
URL: https://docs.steel.dev/overview/self-hosting/webrtc



# Configure a Browser Session
URL: https://docs.steel.dev/overview/sessions-api/configuration

Use this guide to select stable, non-experimental session settings for a browser task. Start with no
optional settings. Add only the settings that the task needs.

## Start with the minimum

```typescript
import Steel from 'steel-sdk';

const client = new Steel({
  steelAPIKey: process.env.STEEL_API_KEY,
});

const session = await client.sessions.create();
```

Steel creates a desktop, headful session with a default `timeout` of 5 minutes. A headful session has
a visible browser window. By default, Steel does not use a proxy or solve CAPTCHAs.

## Choose a configuration

### Recipes

Select the recipe that is closest to your task. Remove any setting that the task does not need.

| Task | Setting | Go to |
|---|---|---|
| Run standard browser automation | No optional setting | [Start with the minimum](#start-with-the-minimum) |
| Release an idle session before `timeout` | `timeout`, `inactivityTimeout` | [Task with an inactivity timeout](#task-with-an-inactivity-timeout) |
| Run a longer headless task and release it when idle | Headless and session life settings | [Long-running headless worker](#long-running-headless-worker) |
| Extract text from a protected site | Proxy, CAPTCHA, and resource settings | [Text extraction on a protected site](#text-extraction-on-a-protected-site) |
| Detect a CAPTCHA and let a person respond | CAPTCHA and interactive viewer settings | [CAPTCHA detection with human control](#captcha-detection-with-human-control) |
| Use stored account state without saving changes | `profileId` | [Account task without saved changes](#account-task-without-saved-changes) |
| Repeat an account task with one identity | Profile and dedicated IP settings | [Account task with stored state](#account-task-with-stored-state) |
| Open a mobile site from a selected country | Device and proxy settings | [Mobile task in a selected country](#mobile-task-in-a-selected-country) |

### Find a setting

If you know the browser behavior that the task needs, use this table.

| Requirement | Setting | Go to |
|---|---|---|
| Use a residential IP | `useProxy: true` | [Network identity](#network-identity) |
| Select the IP location | `useProxy.geolocation` | [Network identity](#network-identity) |
| Use the same dedicated IP | `useProxy: { type: "fixed", id: "fixed:…" }` | [Network identity](#network-identity) |
| Run the browser in a selected region | `region` | [Network identity](#network-identity) |
| Load saved browser state | `profileId` | [Stored state](#stored-state) |
| Save profile changes after release | `persistProfile: true` | [Stored state](#stored-state) |
| Add cookies or web storage | `sessionContext` | [Stored state](#stored-state) |
| Use credentials that Steel stores | `credentials` | [Stored state](#stored-state) |
| Detect or solve CAPTCHAs | `solveCaptcha` | [Page support](#page-support) |
| Let a person control the browser | `debugConfig.interactive: true` | [Viewer and connection](#viewer-and-connection) |
| Make the viewer read-only | `debugConfig.interactive: false` | [Viewer and connection](#viewer-and-connection) |
| Connect with Selenium | `isSelenium: true` | [Viewer and connection](#viewer-and-connection) |
| Block page resources | `optimizeBandwidth` | [Resource control](#resource-control) |
| Load browser extensions | `extensionIds` | [Resource control](#resource-control) |
| Set the browser window size | `dimensions` | [Browser interface](#browser-interface) |
| Use fullscreen mode | `fullscreen: true` | [Browser interface](#browser-interface) |
| Select the project context | `projectId`, `namespace` | [Project context](#project-context) |

`useProxy` accepts `true`, `false`, or a configuration object. Properties such as `geolocation` are
part of the `useProxy` object.

## Common configurations

Each recipe below is a JSON request body with only the settings for its task. Save a recipe as
`session.json`. Send the file to `POST /v1/sessions` with curl.

```bash
curl https://api.steel.dev/v1/sessions \
  -H "steel-api-key: $STEEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d @session.json
```

`@session.json` tells curl to read the request body from that file. For supported TypeScript SDK
settings, pass the same object to `client.sessions.create(...)`. If the SDK type does not include a
setting, use the REST API for that recipe.

### Task with an inactivity timeout

If Steel must release an idle session before `timeout`, use this recipe. Steel releases the session
after 1 minute without a Chrome DevTools Protocol (CDP) command or remote input.

```json
{
  "timeout": 600000,
  "inactivityTimeout": 60000
}
```

This request sets `timeout` to 10 minutes. Activity resets the 1-minute inactivity timer.

### Long-running headless worker

If an unattended task can run for several minutes, use this recipe. Steel releases the session after
2 minutes without a CDP command or remote input.

```json
{
  "headless": true,
  "timeout": 900000,
  "inactivityTimeout": 120000
}
```

The task can run for up to 15 minutes. Activity resets the 2-minute inactivity timer.

### Text extraction on a protected site

If the task needs a residential IP and automatic CAPTCHA solving, use these settings. Your account
must have access to these features.

```json
{
  "useProxy": true,
  "solveCaptcha": true,
  "optimizeBandwidth": {
    "blockImages": true,
    "blockMedia": true,
    "blockStylesheets": false
  }
}
```

This recipe keeps stylesheets because they can affect page behavior. Remove each setting that the
task does not need.

### CAPTCHA detection with human control

If Steel must detect a supported CAPTCHA and a person must respond in the live viewer, use this
recipe. Your account must have access to CAPTCHA solving.

```json
{
  "solveCaptcha": true,
  "stealthConfig": {
    "autoCaptchaSolving": false
  },
  "debugConfig": {
    "interactive": true
  }
}
```

Steel detects the CAPTCHA without solving it automatically. The live viewer accepts input because
the session is interactive. Protect the debug URL before you share it.

### Account task without saved changes

If the task needs profile state without saving changes, use this recipe. Before you use the recipe,
confirm that the profile status is `READY`. Replace the example UUID with a profile UUID from your
workspace.

```json
{
  "profileId": "123e4567-e89b-12d3-a456-426614174000"
}
```

Steel restores the profile state for the session. Because the request omits `persistProfile`, Steel
does not save the session's profile changes after release.

### Account task with stored state

Before you use these settings, confirm that the profile status is `READY`. Replace the example
profile UUID with a profile UUID from your workspace. Replace the example dedicated IP ID with an ID
from your workspace.

```json
{
  "profileId": "123e4567-e89b-12d3-a456-426614174000",
  "persistProfile": true,
  "useProxy": {
    "type": "fixed",
    "id": "fixed:8SdfYs77me"
  }
}
```

This recipe restores stored browser state. Steel saves the new browser state after you release the
session. The recipe also uses one dedicated IP.

### Mobile task in a selected country

If the site must show its mobile version for a selected country, use these settings. Your account
must have access to Steel-provided proxies.

```json
{
  "deviceConfig": {
    "device": "mobile"
  },
  "useProxy": {
    "geolocation": {
      "country": "DE"
    }
  }
}
```

The mobile setting controls the browser fingerprint and input behavior. The proxy setting controls
the public IP location.

## Setting reference

The following sections explain stable session settings by topic. Use
[Find a setting](#find-a-setting) to open a section.

### Session life

Use these settings to control the total and idle session life.

| Setting | Guidance |
|---|---|
| `timeout` | Sets the requested maximum session life. The default is 300000 ms (5 minutes). Set the value to 15000 ms or more. Your account limits set the maximum permitted value. |
| `inactivityTimeout` | Releases an idle session before `timeout`. Set the value below `timeout`. If your SDK type does not include this field, use the REST API. |

CDP commands and remote input reset the inactivity timer. After the task is complete, release the
session. See [Session Lifecycle](/overview/sessions-api/session-lifecycle) for session states and
release methods.

### Network identity

Use the smallest location scope that the task needs.

| Requirement | Configuration |
|---|---|
| Use the browser machine IP | Set `useProxy` to `false`. Omit `proxyUrl`. |
| Use a residential IP | Set `useProxy` to `true`. |
| Select a country, state, or city | Set `useProxy.geolocation`. Include a country. |
| Use one dedicated IP | Set `useProxy.type` to `fixed` and add the dedicated IP `id`. |
| Use any active dedicated IP | Set `useProxy.type` to `fixed` and omit `id`. |
| Use your proxy | Set `useProxy.server` or `proxyUrl`. |
| Select the browser region | Set `region`. |

- `region` selects the location of the browser process. Proxy geolocation selects the public IP
  location that the site receives.
- If you load a profile, `useProxy: false` overrides the proxy setting in that profile.
- `proxyUrl` overrides `useProxy`. Use only one custom proxy method in a session request.

See [Multi-Region Sessions](/overview/sessions-api/multi-region),
[Proxies](/overview/stealth/proxies), and
[Dedicated IPs](/overview/sessions-api/dedicated-ips).

### Stored state

Select the state methods that the task needs.

| Available state | Method |
|---|---|
| A specific set of cookies or web storage | Use `sessionContext`. |
| Browser state that must change across sessions | Use a profile. |
| Credentials that Steel stores | Add a `credentials` object. |

You can use more than one state method in a session. A request-level `sessionContext` replaces the
profile session context. A request-level `credentials` object replaces the profile credentials.

#### Save changes in a profile

To create a profile from a session, set `persistProfile` to `true`. Before you use the new profile,
complete these steps:

1. Copy `profileId` from the first session response.
2. Complete the browser task.
3. Release the first session.
4. Wait until the profile status is `READY`.
5. Create the next session with `profileId`.
6. If the next session must save its changes, set `persistProfile` to `true`.

Profile and credential rules:

- The API returns `409` if the profile status is `UPLOADING`.
- A profile UUID must identify a profile in the same project.
- Stored credentials must use the same project and namespace as the session.
- The `credentials` object can submit stored values automatically, blur credential fields, and
  require an exact origin match.

See [Profiles](/overview/profiles-api/overview),
[Reusing Auth Context](/overview/sessions-api/reusing-auth-context), and
[Credentials](/overview/credentials-api/overview).

### Browser interface

Use these settings to select the window size, mobile identity, or display mode.

| Requirement | Configuration |
|---|---|
| Set the browser window size | Set `dimensions`. |
| Use a mobile browser identity | Set `deviceConfig.device` to `"mobile"`. If the request and profile omit `dimensions`, Steel uses 508×1074. |
| Fill the browser screen | Set `fullscreen` to `true`. Fullscreen mode uses 1920×1080 and ignores `dimensions`. |
| Set a specific user agent | Set `userAgent`. |

Steel rejects custom mobile dimensions below 508×1074 (width × height). Use mobile mode for a
complete mobile browser identity. See [Mobile Mode](/overview/sessions-api/mobile-mode) and
[Fullscreen Mode](/overview/sessions-api/fullscreen-mode).

### Page support

Set `solveCaptcha` to `true` to detect and solve supported CAPTCHA types. Use this recipe to detect a
CAPTCHA without automatic solving:

```json
{
  "solveCaptcha": true,
  "stealthConfig": {
    "autoCaptchaSolving": false
  }
}
```

- Set `stealthConfig.humanizeInteractions` to `true` to simulate human pointer movements and
  keystrokes.
- If the task controls the browser fingerprint, set `stealthConfig.skipFingerprintInjection` to
  `true`.
- For all other tasks, omit `stealthConfig.skipFingerprintInjection`.

See [CAPTCHA Solving](/overview/captchas-api/overview) for automatic and manual solve methods.

### Resource control

Steel does not block ads by default. Use resource settings only when the task needs different
behavior.

| Requirement | Configuration |
|---|---|
| Block ads | Set `blockAds` to `true`. |
| Block images, media, and stylesheets | Set `optimizeBandwidth` to `true`. |
| Select resource types, hosts, or URL patterns | Set `optimizeBandwidth` to an object. |
| Load uploaded extensions | Set `extensionIds` to the uploaded extension IDs. See [Extensions](/overview/extensions-api/overview) for extension management. |

Block only the resources that the task does not use. If the task uses screenshots or visual
analysis, do not block images. If layout or visibility is important, do not block stylesheets.

### Viewer and connection

Use these settings to control the viewer and browser connection.

| Requirement | Configuration |
|---|---|
| Allow input from the live viewer | Set `debugConfig.interactive` to `true`. This is the default. |
| Make the live viewer read-only | Set `debugConfig.interactive` to `false`, or add `interactive=false` to the viewer URL. |
| Show the system cursor in a headful stream | Set `debugConfig.systemCursor` to `true`. This is the default and does not control viewer input. |
| Use a headless browser | Set `headless` to `true`. |
| Connect with Selenium | Set `isSelenium` to `true`. This forces `headless` to `true`, even if the request sets `headless` to `false`. |

The viewer URL cannot make a read-only session interactive.

### Protect the debug URL
Anyone with the debug URL can view the session and control an interactive session. Before you share
the debug URL, add application access controls.

See [Live Sessions](/overview/sessions-api/embed-sessions/live-sessions),
[Human-in-the-Loop Controls](/overview/sessions-api/human-in-the-loop), and
[Selenium](/integrations/selenium).

### Project context

Use `projectId` and `namespace` only to select a different project context. Stored credentials must
use the same project and namespace as the session. For exact request types and validation rules, see
the [API Reference](/api-reference#tag/sessions).


# Dedicated IPs
URL: https://docs.steel.dev/overview/sessions-api/dedicated-ips

Steel sessions give you a fresh cloud browser on demand. Dedicated IPs let those sessions
look consistent where the network matters.

Without a dedicated IP, each session may come from a different network path. With a
dedicated IP, a site can see requests from a familiar IP instead of a new location on
every run. This is useful for login reliability, long-running automations, and workflows
where repeated "new network" signals cause extra checks. This is often called the
impossible traveler problem: auth and anti-bot systems see the same account appear from
unrelated networks or locations too quickly, then invalidate the saved auth state or add
extra verification.

Profiles take this further. A dedicated IP gives the session stable network identity, but
it does not preserve the browser itself. Without a profile, each session still starts like
a new browser: no cookies, local storage, IndexedDB, login state, permissions, or prior
browser history.

With [Steel Profiles](/overview/profiles-api/overview), the browser identity persists too.
You can sign in once, release the session, and later start another cloud browser with the
same profile and the same saved browser state.

Together:

- Sessions give you scalable, on-demand cloud browsers.
- Dedicated IPs give those sessions stable network identity.
- Profiles give those sessions stable browser identity.
- Using both helps automations resume from a trusted, logged-in state instead of starting
  cold every time.

## Lease Dedicated IPs

Dedicated IPs are leased from **Settings > Network** in the Steel dashboard. They are
available on paid plans; Hobby workspaces need to upgrade before leasing dedicated IPs.

Each dedicated IP is billed as a monthly subscription at **$5 per dedicated IP / month**.
You can lease up to 25 dedicated IPs self-serve from the Network page. If you need more,
contact support.

1. Open [Settings > Network](https://app.steel.dev/settings/network).
2. Click **Lease Dedicated IPs**.
3. Choose a location and quantity.
4. Complete checkout.

After checkout completes, the Network page lists each leased dedicated IP. You will see
the public IP address, such as `203.0.113.42`, and a Steel dedicated IP identifier, such
as `fixed:8SdfYs77me`.

Use the `fixed:<id>` identifier in API requests. Copy the Dedicated IP ID from the
Network page when you want to pin a session or profile to a specific IP.

## Use Any Dedicated IP

Pass `useProxy: { type: "fixed" }` when creating a session. Steel will randomly select
one active dedicated IP from your workspace.

```typescript
const session = await client.sessions.create({
  useProxy: { type: "fixed" },
});
```

```python
session = client.sessions.create(
    use_proxy={"type": "fixed"}
)
```

This default is useful when you want stable, dedicated egress without caring which
dedicated IP is used for a specific run.

## Pin A Specific Dedicated IP

If you want a specific IP, pass its fixed IP identifier with `id`.

```typescript
const session = await client.sessions.create({
  useProxy: { type: "fixed", id: "fixed:aaaa" },
});
```

```python
session = client.sessions.create(
    use_proxy={"type": "fixed", "id": "fixed:aaaa"}
)
```

Use this when a specific account, profile, customer, or workflow should always use the
same network identity.

## Persist A Dedicated IP With A Profile

When you create a persisted profile with an unspecified dedicated IP, Steel first selects
one of your dedicated IPs, then stores that selected IP on the profile after the session
starts successfully.

```typescript
const firstSession = await client.sessions.create({
  persistProfile: true,
  useProxy: { type: "fixed" },
});

const profileId = firstSession.profileId;

const secondSession = await client.sessions.create({
  profileId,
});
```

```python
first_session = client.sessions.create(
    persist_profile=True,
    use_proxy={"type": "fixed"}
)

profile_id = first_session.profile_id

second_session = client.sessions.create(
    profile_id=profile_id
)
```

The second session loads the saved profile, including the fixed IP selection. That means
future sessions using the profile can keep both the same browser state and the same
network identity.

You can also explicitly pin the IP at profile creation time:

```typescript
const session = await client.sessions.create({
  persistProfile: true,
  useProxy: { type: "fixed", id: "fixed:aaaa" },
});
```

```python
session = client.sessions.create(
    persist_profile=True,
    use_proxy={"type": "fixed", "id": "fixed:aaaa"}
)
```

## Why Lease Multiple IPs?

Multiple dedicated IPs give you more control over how work is distributed:

- Keep one stable IP per account, profile, customer, or workflow.
- Run parallel sessions without every workflow sharing the same IP.
- Reduce repeated traffic from one network identity.
- Keep a fallback IP available if a site challenges or blocks one IP.
- Let Steel randomly distribute sessions across your dedicated IPs when you do not pass
  an `id`.

For account-based automations, a common pattern is one profile plus one dedicated IP per
account. The profile keeps browser state. The dedicated IP keeps network identity.

## Billing And Metering

Dedicated IP traffic is metered as normal proxy bandwidth. It appears in the same proxy
bandwidth usage model as your other Steel proxy traffic.

Browser session time is still metered separately as normal session usage.

### Need help choosing an IP strategy?
Reach out to us on the **#help** channel on
[Discord](https://discord.gg/steel-dev) under the ⭐ community section.

## FAQ

### What happens if I do not pass a fixed IP `id`?

Steel randomly selects one active dedicated IP from your workspace. If you use a persisted
profile, Steel saves that selected IP to the profile after the session starts.

### Should I use dedicated IPs with profiles?

Yes, if the workflow needs continuity. Dedicated IPs keep the network identity stable.
Profiles keep cookies, storage, login state, permissions, and browser history stable.

### Does dedicated IP traffic have separate metering?

No. Dedicated IP traffic counts as normal proxy bandwidth. Browser session time is still
metered separately.


# Fullscreen Mode
URL: https://docs.steel.dev/overview/sessions-api/fullscreen-mode

### Overview

Fullscreen mode launches the browser covering the full screen with no Chrome UI—no address bar, no tabs, no toolbars. Pass `fullscreen: true` when creating a session and the page gets every pixel of the 1920×1080 screen.

By default, sessions run windowed: Steel reserves space for browser UI the same way a real desktop browser does, so the page sees a viewport slightly smaller than the screen. Fullscreen removes that reservation entirely. The result is cleaner screenshots and recordings with no browser chrome in the frame, and a viewport that exactly matches the screen.

### How It Works

```typescript
import Steel from 'steel-sdk';
import { chromium } from 'playwright';

const client = new Steel({ steelAPIKey: process.env.STEEL_API_KEY });

// Create a session that launches fullscreen
const session = await client.sessions.create({
  fullscreen: true
});

// Connect to the fullscreen session
const browser = await chromium.connectOverCDP(
  `wss://connect.steel.dev?apiKey=${process.env.STEEL_API_KEY}&sessionId=${session.id}`
);

const page = await browser.contexts()[0].pages()[0];
await page.goto('https://example.com');
```

```python
from steel import Steel
from playwright.async_api import async_playwright
import os

client = Steel(steel_api_key=os.environ.get("STEEL_API_KEY"))

# Create a session that launches fullscreen
session = client.sessions.create(
    fullscreen=True
)

# Connect to the fullscreen session
async with async_playwright() as p:
    browser = await p.chromium.connect_over_cdp(
        f"wss://connect.steel.dev?apiKey={os.environ.get('STEEL_API_KEY')}&sessionId={session.id}"
    )

    page = browser.contexts[0].pages[0]
    await page.goto('https://example.com')
```

The flag is set at session creation and can't be changed on a running session. It defaults to `false`, so existing integrations are unaffected, and it echoes back on the session object so you can confirm what you got.

The difference fullscreen makes is in the viewport. A windowed session subtracts a chrome allowance from the screen—5px of width and 91px of height—so a default 1920×1080 session hands the page a 1915×989 viewport. Fullscreen skips the subtraction:

| | Windowed (default) | Fullscreen |
|---|---|---|
| Browser UI | Reserves space at the top | None |
| Screen size | Your `dimensions`, capped at 1920×1080 | Fixed at 1920×1080 |
| Viewport | Screen minus chrome allowance (e.g. 1915×989) | Full 1920×1080 |
| `innerHeight` vs `screen.height` | Smaller | Identical |

### Why This Matters

**Clean Screenshots and Recordings**

No browser UI means no strip of dead pixels at the top of every capture. Screenshots and session recordings show only page content, with no cropping step in your pipeline.

**Kiosk-Style Displays**

Dashboards, signage, and embedded views render edge-to-edge, the way they would on a dedicated full-screen display. Pages built against the full viewport get the canvas they expect.

**Watch-Only Views for Your Users**

Pair fullscreen with a read-only embed and you can surface a live session inside your own product as a clean, edge-to-edge view your users can watch but not control. Embed the session's `debugUrl` with `interactive=false` to lock interaction to your backend, and fullscreen removes the browser chrome so all they see is the page. See [Live Sessions](/overview/sessions-api/embed-sessions/live-sessions) for the embed parameters.

**Viewport Matches Screen**

Sites that compare `innerHeight` to `screen.height` see exactly what a maximized, fullscreened real browser reports: the two are identical. Windowed sessions report the gap a real toolbar would create, so both modes present a consistent fingerprint—fullscreen just presents the edge-to-edge one.

### Things to Know

- **Fullscreen overrides `dimensions`.** The session is fixed at 1920×1080; any `width`/`height` you pass is ignored. Use `dimensions` for a specific size, `fullscreen` for the whole screen—not both.
- **Click coordinates shift.** Moving from a windowed session to fullscreen changes the viewport from 1915×989 to 1920×1080, so saved coordinates from windowed runs won't line up.
- **It's not the viewer's fullscreen button.** That button expands the live viewer on your screen; `fullscreen: true` changes how the cloud browser itself launches.

### Need help with fullscreen mode?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) or [@steeldotdev](https://twitter.com/steeldotdev).


# Implement Human-in-the-Loop Controls
URL: https://docs.steel.dev/overview/sessions-api/human-in-the-loop

Steel's debug URL feature allows you to implement human-in-the-loop workflows where users can directly interact with and control browser sessions. This is particularly useful when you need users to take temporary control of automated browser sessions.

### Prerequisites

*   Basic familiarity with [Steel sessions](https://docs.steel.dev/overview/sessions-api/overview)

*   Understanding of [debug URLs](https://docs.steel.dev/overview/sessions-api/embed-sessions/live-sessions)

*   A Steel API key

### Making Sessions Interactive

To enable human interaction with a session, you'll need to configure two key parameters when embedding the session viewer:

*   `interactive=true`: Enables users to interact with the page through clicks, scrolling, and form inputs

*   `showControls=true`: Shows the navigation bar where users can enter URLs and use forward/back controls
```typescript
<iframe
  src={`${session.debugUrl}?interactive=true&showControls=true`}
  style="width: 100%; height: 600px; border: none;"
></iframe>
```

When both parameters are enabled, users can:

*   Click and interact with elements on the page

*   Scroll the page

*   Enter new URLs in the navigation bar

*   Use browser-style forward/back navigation

*   Fill out forms and input fields

*   Navigate through websites naturally

If you’re building user facing agents, this is particularly useful when you need users to:

*   Take control of an automated session that needs assistance

*   Enter sensitive information like login credentials

*   Solve CAPTCHAs

*   Verify or correct automated actions

*   Demonstrate actions that will be automated

### Implementation Examples

#### React Implementation

Here's how to embed an interactive session viewer into a React Application:
```typescript
// SessionViewer.tsx
import React from 'react';

type SessionViewerProps = {
    debugURL: string;
};

const SessionViewer: React.FC<SessionViewerProps> = ({ debugURL }) => {
    return (
        <div className="session-container">
            <div
                className="status-banner"
                style={{
                    background: '#f0f0f0',
                    padding: '10px',
                    marginBottom: '10px',
                    textAlign: 'center',
                }}
            >
                Automated session - Click inside to take control
            </div>

            <iframe
                src={`${debugURL}?interactive=true&showControls=true`}
                style={{
                    width: '100%',
                    height: '600px',
                    border: 'none',
                }}
                title="Browser Session"
            />
        </div>
    );
};

export default SessionViewer;

// Usage in App.tsx
import React from 'react';
import SessionViewer from './SessionViewer';

const App: React.FC = () => {
    return (
        <div className="App">
            <h1>Browser Automation Dashboard</h1>
            <SessionViewer debugURL="YOUR_debug_URL" />
        </div>
    );
};

export default App;
```

### Best Practices

*   Ensure your iframe container is large enough for comfortable interaction (recommended minimum height: 600px)

*   Make it clear to users when they can interact with the session

*   Remember that any actions taken in an interactive session affect the actual browser session & state

### What's Next

Learn about session timeouts for managing interactive sessions:

Session Lifecycle

Learn how to start and release browser sessions programmatically.

### FAQ

### How do I let a user take control of an automated browser session?

Embed the session's debug URL in an iframe with `interactive=true` (enables clicks, scrolling, and form input) and `showControls=true` (shows the navigation bar with URL entry and back/forward controls).

### What can users do in an interactive session?

With both parameters enabled, users can click page elements, scroll, fill out forms and inputs, enter new URLs in the navigation bar, and use browser-style forward/back navigation.

### When is human-in-the-loop useful for browser agents?

It is most useful when users need to take over a session that needs assistance, enter sensitive information like login credentials, solve CAPTCHAs, verify or correct automated actions, or demonstrate actions that will be automated.

### Do actions taken in the embedded viewer affect the real session?

Yes. Any actions a user takes in an interactive session affect the actual browser session and its state, so make it clear to users when they are in control. A minimum iframe height of 600px is recommended for comfortable interaction.


# Mobile Mode
URL: https://docs.steel.dev/overview/sessions-api/mobile-mode

### Overview

Mobile mode allows Steel sessions to appear as mobile devices. Pass `deviceConfig: { device: "mobile" }` when creating a session and the browser presents itself with mobile user agent, viewport, touch capabilities, and browser characteristics—everything aligned to look like a phone instead of desktop.

Most websites serve fundamentally different experiences to mobile devices. Desktop sites have nested navigation, hover menus, and complex interactions. Mobile sites strip these away into linear flows and touch-optimized interfaces. For AI agents, this simplification can directly improve task completion.

### How It Works

```typescript
import Steel from 'steel-sdk';
import { chromium } from 'playwright';

const client = new Steel({ steelAPIKey: process.env.STEEL_API_KEY });

// Create a session with mobile device configuration
const session = await client.sessions.create({
  deviceConfig: { device: "mobile" }
});

// Connect to the mobile session
const browser = await chromium.connectOverCDP(
  `wss://connect.steel.dev?apiKey=${process.env.STEEL_API_KEY}&sessionId=${session.id}`
);

const page = await browser.contexts()[0].pages()[0];
await page.goto('https://example.com');
```

```python
from steel import Steel
from playwright.async_api import async_playwright
import os

client = Steel(steel_api_key=os.environ.get("STEEL_API_KEY"))

# Create a session with mobile device configuration
session = client.sessions.create(
    device_config={"device": "mobile"}
)

# Connect to the mobile session
async with async_playwright() as p:
    browser = await p.chromium.connect_over_cdp(
        f"wss://connect.steel.dev?apiKey={os.environ.get('STEEL_API_KEY')}&sessionId={session.id}"
    )
    
    page = browser.contexts[0].pages[0]
    await page.goto('https://example.com')
```

The session automatically configures mobile viewport dimensions, touch events, and a full mobile device fingerprint. Sites see a consistent mobile device visiting from a browser app, not a desktop browser with a spoofed user agent. Before this, you could override the user agent string, but the rest of the fingerprint wouldn't match—sites would detect the inconsistency.

Mobile mode works with all existing features including proxies, CAPTCHA solving, and session persistence.

### Why This Matters

**Simplified Navigation**

Mobile sites present content sequentially rather than using nested menus or hover states. An e-commerce checkout that requires navigating dropdown menus on desktop becomes a vertical list on mobile. Fewer interactive elements means clearer action spaces and less chance of mistakes.

**Performance and Cost Benefits**

Mobile sites load faster with fewer widgets and less aggressive lazy-loading. They also have simpler DOM structures. Less HTML for your model to process means lower token costs. If you're using vision, it means fewer image tokens too.

**Consistent Fingerprints**

Without mobile mode, your sessions use desktop fingerprints by default. Mobile mode provides a complete, consistent mobile device fingerprint that websites trust.

### Need help with mobile mode?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) or [@steeldotdev](https://twitter.com/steeldotdev).

Part of Steel's launch week. More at [steel.dev/launch-week](https://steel.dev/launch-week).


# Multi-region
URL: https://docs.steel.dev/overview/sessions-api/multi-region

### Overview

By default, Steel automatically selects the data center closest to the client’s request location when creating a new browser session. This ensures optimal performance and minimal latency for your browser automation tasks. However, you can also manually specify which region you want your browser session to run in using the `region` parameter.

This region selection determines the physical location of the browser instance itself, which can help reduce latency for applications targeting specific geographic areas or comply with data residency requirements.

### Automatic Region Selection

When you create a session without specifying a region, Steel automatically determines the closest data center based on your request location:

```typescript
import Steel from 'steel-sdk';

const client = new Steel();

// Automatically uses the closest region
const session = await client.sessions.create();
```

```python
from steel import Steel

client = Steel()

# Automatically uses the closest region
session = client.sessions.create()
```

### Manual Region Selection

To specify a particular region for your browser session, use the `region` parameter when creating a session:

```typescript
import Steel from 'steel-sdk';

const client = new Steel();

// Create session in Los Angeles data center
const session = await client.sessions.create({
    region: "lax"
});
```

```python
from steel import Steel

client = Steel()

# Create session in Los Angeles data center
session = client.sessions.create(
    region="lax"
)
```

### Available Regions

Steel is available in the following regions:

| Region         | Code | Data Center Location      |
|----------------|------|---------------------------|
| Los Angeles    | LAX  | Los Angeles, USA          |
| Washington DC  | IAD  | Washington DC, USA        |

### Region vs Proxy Selection

Region selection determines where your browser session runs, which is different from proxy selection. The region parameter controls the physical location of the browser instance, while the useProxy and proxyUrl parameters control the network routing and IP address used by the browser for web requests.

You can combine region selection with proxy settings:

```typescript
// Browser runs in Frankfurt, but uses a US proxy for requests
const session = await client.sessions.create({
    region: "lax",
    useProxy: true
});

```

```python
# Browser runs in Frankfurt, but uses a US proxy for requests
session = client.sessions.create(
    region="lax",
    use_proxy=True
)
```

We'll be launching new features soon to allow you to control regions for proxies as well. Right now, all are US based.

### Need help building with multi-region?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### How does Steel choose which region my session runs in?

By default Steel automatically selects the data center closest to the client's request location for minimal latency. You can override this with the `region` parameter when creating a session.


# Overview
URL: https://docs.steel.dev/overview/sessions-api/overview

[Go to Quickstart Example](/overview/sessions-api/quickstart)

### What is a Session?

Sessions are the atomic unit of our Sessions API. Think of sessions as giving your AI agents their own dedicated browser windows. Just like you might open an incognito window to start a fresh browsing session, the Sessions API lets your agents spin up isolated browser instances on demand. Each session maintains its own state, cookies, and storage - perfect for AI agents that need to navigate the web, interact with sites, and maintain context across multiple steps.

### Get started

[Getting Started](/overview/sessions-api/quickstart)

### Connect with your preferred tools

[Connect with Puppeteer](/cookbook/puppeteer)

[Connect with Playwright](/cookbook/playwright)

[Connect with Playwright (Python)](/cookbook/playwright-python)

[Connect with Selenium](/cookbook/selenium)

[Python SDK Reference](/steel-python-sdk)

[Node SDK Reference](/steel-js-sdk)

### Understanding sessions

[Session Lifecycle](/overview/sessions-api/session-lifecycle)

### Need help building with the Sessions API?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev/) under the community ⭐ section.

### FAQ

### What is a Steel session?

A session is an isolated cloud browser instance your agent spins up on demand, like opening a fresh incognito window but running in Steel's cloud and controlled through code. Sessions are the atomic unit of the Sessions API.

### Do Steel sessions keep state between steps?

Yes. Each session maintains its own state, cookies, and storage, which is designed for AI agents that need to navigate the web, interact with sites, and keep context across multiple steps.

### Can I control a Steel session with Puppeteer, Playwright, or Selenium?

Yes. Steel has connection guides for Puppeteer, Playwright (Node and Python), and Selenium, plus Python and Node SDK references for managing sessions themselves.


# Quickstart
URL: https://docs.steel.dev/overview/sessions-api/quickstart

### Overview

This guide will walk you through setting up your Steel account, creating your first browser session in the cloud, and driving it using Typescript/Playwright. In just a few minutes, you'll be up and programmatically controlling a Steel browser Session.

### Initial Setup

#### 1\. Create a Steel Account

1.  Sign up for a free account at steel.dev

2.  The free plan includes 100 browser hours to get you started

3.  No credit card required

#### 2\. Get Your API Key

1.  After signing up, navigate to Settings > API Keys

2.  Create an API key and save it somewhere safe. You will not be able to generate the same key again.

#### 3\. Set Up Environment Variables

1.  Create a `.env` file in your project root (if you don't have one)

2.  Add your Steel API key:

Make sure to add `.env` to your `.gitignore` file to keep your key secure

### Installing Dependencies

Install the Steel SDK and Playwright:

```bash
npm install steel-sdk playwright
```

Using Python? `pip install steel-sdk playwright`, then `playwright install chromium`. The PyPI package is `steel-sdk`, but the import is `from steel import Steel`.

### Create Your First Session

Let's create a simple script that launches and then releases a Steel session:

```typescript
import Steel from 'steel-sdk';
import dotenv from 'dotenv';

dotenv.config();

const client = new Steel({
  steelAPIKey: process.env.STEEL_API_KEY,
});

async function main() {
  // Create a session
  const session = await client.sessions.create();
  console.log('Session created:', session.id);
  console.log(`View live session at: ${session.sessionViewerUrl}`);

  // Your session is now ready to use!
  // When done, release the session
  await client.sessions.release(session.id);
  console.log('Session released');
}

main().catch(console.error);
```

### Connecting to Your Session

Now that you have a session, you can connect to it using your preferred automation tool.

```typescript
import puppeteer from 'puppeteer';

const browser = await puppeteer.connect({
    browserWSEndpoint: `wss://connect.steel.dev?apiKey=${process.env.STEEL_API_KEY}&sessionId=${session.id}`,
});

const page = await browser.newPage();
await page.goto('https://example.com');
```

### Session Features

Want to do more with your session? Here are some common options you can add when creating:

```typescript
const session = await client.sessions.create({
    useProxy: true,           // Use Steel's residential proxy network
    solveCaptcha: true,       // Enable automatic CAPTCHA solving
    timeout: 1800000,      // Set 30-minute timeout (default is 5 minutes)
    inactivityTimeout: 300000, // Release after 5 minutes of inactivity
    userAgent: 'custom-ua'    // Set a custom user agent
});
```

You've now created your first Steel session and learned the basics of session management. With these fundamentals, you can start building more complex automations using Steel's cloud browser infrastructure.

### Need help building with the Sessions API?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### How long does a Steel session last by default?

The default session timeout is 5 minutes. You can extend it with the `timeout` option on session create (e.g. `timeout: 1800000` for 30 minutes) and set `inactivityTimeout` to release after a period of inactivity.

### Can I enable proxies and CAPTCHA solving on a session?

Yes. Pass `useProxy: true` to route through Steel's residential proxy network and `solveCaptcha: true` to enable automatic CAPTCHA solving when creating the session. You can also set a custom user agent with `userAgent`.


# Reusing Context & Auth
URL: https://docs.steel.dev/overview/sessions-api/reusing-auth-context

The Steel Sessions API provides a `contexts` endpoint that allows you to capture and transfer browser state (including cookies and local storage) between sessions. This is particularly useful for maintaining authenticated states across multiple sessions, helping your AI agents access protected resources efficiently without repeatedly handling login processes or exposing credentials at all.

In this guide, you'll learn how to use the Steel Sessions API to reuse authentication between browser sessions.

For an easier way to reuse authentication, context, cookies, extensions etc. consider using Steel's new [Profiles API](/overview/profiles-api/overview). It utilizes auth context alongside a complete browser profile to automatically reuse all your auth, not just context or cookies.

If reused auth still triggers extra verification, check whether the target site is reacting
to a new network on each run. Pairing profiles or reused context with
[Dedicated IPs](/overview/sessions-api/dedicated-ips) keeps both browser state and network
identity stable.

For additional practical examples and recipes, check out the [Steel Cookbook](https://github.com/steel-dev/steel-cookbook).

### Prerequisites

*   Steel API Key

*   [Steel SDK](https://github.com/steel-dev/steel-python) installed.
```bash
npm install steel-sdk
```

*   Familiarity with [Steel sessions](https://docs.steel.dev/overview/sessions-api/overview)

### Overview of the Process

Reusing authentication across sessions involves a straightforward workflow:

*   **Create and authenticate an initial session.**
    Create a Steel session, navigate to target websites, and authenticate (log-in, etc).

*   **Capture the session context.**
    Extract browser state data through the `GET /v1/sessions/{id}/context` endpoint. This endpoint returns a context object containing browser state information such as cookies and local storage.
    **Example:**

    ```typescript
    const initialSessionContext = await client.sessions.context(initialSession.id);
    ```

*   **Reuse session context in new sessions.**
    Create new sessions using the captured context object by passing it directly to the `sessionContext` parameter.
    **Example:**

    ```typescript
    const session = await client.sessions.create({ sessionContext: initialSessionContext });
    ```

    Now your new session will begin with the same authenticated state as your previous session without having to manually authenticate again.

### Complete Example (Playwright, Node.js)

**Note**: While this example uses TypeScript, Node.js, and Playwright, the same logic applies regardless of your programming language or automation framework. The Steel API handles the context management - you just need to capture and reuse it using your preferred tools.

The following script demonstrates the entire authentication reuse process. It:

*   Creates an initial session and authenticates with a website by logging in

*   Captures the authenticated session context

*   Creates a new session using the captured context

*   Verifies the authentication was successfully transferred to the new session

```typescript
import { chromium, Page } from "playwright";
import Steel from "steel-sdk";
import dotenv from "dotenv";

dotenv.config();

const client = new Steel({
  steelAPIKey: process.env.STEEL_API_KEY,
});

// Helper function to perform login
async function login(page: Page) {
  await page.goto("https://practice.expandtesting.com/login");
  await page.fill('input[name="username"]', "practice");
  await page.fill('input[name="password"]', "SuperSecretPassword!");
  await page.click('button[type="submit"]');
}

// Helper function to verify authentication
async function verifyAuth(page: Page): Promise<boolean> {
  await page.goto("https://practice.expandtesting.com/secure");
  const welcomeText = await page.textContent("#username");
  return welcomeText?.includes("Hi, practice!") ?? false;
}

async function main() {
  let session;
  let browser;

  try {
    // Step 1: Create and authenticate initial session
    console.log("Creating initial Steel session...");
    session = await client.sessions.create();
    console.log(
      `\x1b[1;93mSteel Session #1 created!\x1b[0m\n` +
        `View session at \x1b[1;37m${session.sessionViewerUrl}\x1b[0m`
    );

    // Connect Playwright to the session
    browser = await chromium.connectOverCDP(
      `wss://connect.steel.dev?apiKey=${process.env.STEEL_API_KEY}&sessionId=${session.id}`
    );

    const page = await browser.contexts()[0].pages()[0];
    await login(page);

    if (await verifyAuth(page)) {
      console.log("✓ Authentication successful");
    }

    // Step 2: Capture and transfer authentication
    const sessionContext = await client.sessions.context(session.id);

    // Clean up first session
    await browser.close();
    await client.sessions.release(session.id);
    console.log("Session #1 released");

    // Step 3: Create new authenticated session

    session = await client.sessions.create({ sessionContext });
    console.log(
      `\x1b[1;93mSteel Session #2 created!\x1b[0m\n` +
        `View session at \x1b[1;37m${session.sessionViewerUrl}\x1b[0m`
    );

    // Connect to new session
    browser = await chromium.connectOverCDP(
      `wss://connect.steel.dev?apiKey=${process.env.STEEL_API_KEY}&sessionId=${session.id}`
    );

    // Verify authentication transfer
    const newPage = await browser.contexts()[0].pages()[0];
    if (await verifyAuth(newPage)) {
      console.log("\x1b[32m✓ Authentication successfully transferred!\x1b[0m");
    }
  } catch (error) {
    console.error("Error:", error);
  } finally {
    // Cleanup
    await browser?.close();
    if (session) {
      await client.sessions.release(session.id);
      console.log("Session #2 released");
    }
  }
}

main().catch(console.error);
```

Check out the full example

### Important Considerations

*   **Cookie and JWT Based Authentication Only:**
    This method works exclusively with websites that utilize cookie-based or JWT-based authentication (saved onto Local Storage).

*   **Enhancing Continuity:**
    A useful practice is to save the URL of the last visited page along with the session context. This allows you to restore the browsing context, providing continuity for users.

*   **Session Security:**
    Treat captured contexts as sensitive data. Ensure proper security and regularly refresh your sessions to maintain account integrity.

*   **Available for Live Sessions:**
    Context can only be captured from live sessions. So if you wish to re-use a context, make sure to grab the object _before_ releasing the session.

### FAQ

### How do I reuse a login across Steel sessions?

Authenticate in an initial session, capture its browser state with the `GET /v1/sessions/{id}/context` endpoint (`client.sessions.context(id)` in the SDK), then pass that object to the `sessionContext` parameter when creating a new session. The new session starts already authenticated, with no second login.

### Should I use the Profiles API or session context reuse?

For an easier path, use the Profiles API: it reuses a complete browser profile (auth, context, cookies, extensions) automatically, not just context or cookies. Context reuse is the lower-level option when you only need to transfer cookies and local storage.


# Session Lifecycle
URL: https://docs.steel.dev/overview/sessions-api/session-lifecycle

### Overview
Sessions are the foundation of browser automation in Steel. Each session represents an isolated browser instance that persists until it's either explicitly released or times out.

Each session can be in one of three states:

*   **Live**: The session is active and ready to accept commands/connections. This is the state right after creation and during normal operation.

*   **Released**: The session has been intentionally shut down, either through explicit release or timeout. Resources have been cleaned up. Can no longer accept commands/connections.

*   **Failed**: Something went wrong during the session's lifetime (like a crash or connection loss). These sessions are automatically cleaned up.

Browser sessions are billed and metered by the minute. A session can last up to 24 hours depending on your plan.

Understanding how sessions live and die helps you manage resources effectively and build more reliable applications.

### Reserving a Session ID

Steel generates a session ID for you, but `create` also accepts one. Pass your own UUID when the ID has to exist before the browser does: a job row you write before starting the session, or a live view link you hand to a user while the browser is still booting.

```typescript
import { randomUUID } from 'node:crypto';
import Steel from 'steel-sdk';

const client = new Steel();

// Reserve the ID first, then create the session with it.
const sessionId = randomUUID();
await jobs.insert({ sessionId, status: 'starting' });

const session = await client.sessions.create({ sessionId });
console.log(session.id === sessionId); // true
```

```python
import uuid
from steel import Steel

client = Steel()

# Reserve the ID first, then create the session with it.
session_id = str(uuid.uuid4())
jobs.insert(session_id=session_id, status="starting")

session = client.sessions.create(session_id=session_id)
print(session.id == session_id)  # True
```

The value must be a valid UUID such as `123e4567-e89b-12d3-a456-426614174000`. Omit `sessionId` and Steel generates one instead. Either way, the ID goes to the same places:

*   `client.sessions.release(sessionId)` to end the session

*   `client.sessions.files.list(sessionId)` and the rest of the Files API

*   `wss://connect.steel.dev?apiKey=<key>&sessionId=<id>` to attach Playwright or Puppeteer

### Session Lifetime and Timeout

When you start a session, it stays alive for 5 minutes by default but you can change it by passing the timeout parameter. After the time passes, the session will be automatically released.

```typescript
import Steel from 'steel-sdk';

const client = new Steel();

// Create session and keep it running for 10 minutes.
const session = await client.sessions.create({
  timeout: 600000 // 10 minutes (NOTE: Units are in milliseconds)
});
```

```python
import os
from steel import Steel

client = Steel()

# Create session and keep it running for 10 minutes.
session = client.sessions.create(
    api_timeout=600000 # 10 minutes (NOTE: Units are in milliseconds)
)
```

**Note:** Currently, Steel doesn’t support editing the timeout duration of a live session.

### Inactivity Timeout

By default a session runs until its `timeout` elapses, even when nothing is driving the browser. Set `inactivityTimeout` to release the session early once it stops seeing activity—any CDP command or remote input—so you don’t keep paying for an idle browser while waiting on an external step.

```typescript
import Steel from 'steel-sdk';

const client = new Steel();

// Release the session after 1 minute with no CDP or input activity,
// capped at a 10-minute hard limit.
const session = await client.sessions.create({
  timeout: 600000,          // 10 minutes (hard cap)
  inactivityTimeout: 60000, // release after 1 minute of inactivity
});
```

```python
import os
from steel import Steel

client = Steel()

# Release the session after 1 minute with no CDP or input activity,
# capped at a 10-minute hard limit.
session = client.sessions.create(
    api_timeout=600000,       # 10 minutes (hard cap)
    inactivity_timeout=60000, # release after 1 minute of inactivity
)
```

**Note:** `timeout` is always the hard cap on a session’s lifetime. If `inactivityTimeout` is greater than or equal to `timeout` it has no effect—`timeout` elapses first. Omit `inactivityTimeout` to disable inactivity-based release (the default).

### **Releasing a Session**

When you're done with a session, it's best practice to release it explicitly rather than waiting for the timeout. You can release a session any time before the timeout is up by calling the `release` method.

```typescript
// Release a single session
const response = await client.sessions.release(session.id);
```

```python
# Release a single session
response = client.sessions.release(session.id)
```

#### Bulk Session Release

Sometimes you need to clean up all active sessions at once. Steel provides a convenient way to do this:

```typescript
// Release all active sessions
const response = await client.sessions.releaseAll();
console.log(response.message); // "All sessions released successfully"
```

```python
# Release all active sessions
response = client.sessions.release_all()
print(response.message) # "All sessions released successfully"
```

### Need help building with the Sessions API?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### How long does a Steel browser session stay alive by default?

5 minutes — after that the session is automatically released. You can change this by passing the `timeout` parameter (in milliseconds) when creating the session, e.g. `timeout: 600000` for 10 minutes.

### What is the maximum length of a Steel session?

Up to 24 hours, depending on your plan. Sessions are billed and metered by the minute.

### Can I extend the timeout of a running Steel session?

No — Steel currently doesn't support editing the timeout duration of a live session, so set the `timeout` you need at creation time.

### How do I avoid paying for an idle Steel session?

Set `inactivityTimeout` so the session releases itself if your side goes quiet — for example when your agent crashes, hangs, or simply stops sending commands. Without it (the default), a stalled client keeps the session alive and billed until the hard `timeout` cap; with it, any CDP command or remote input resets the timer, so normal automation is unaffected while you're protected from paying for a browser nobody is driving.

### How do I end a Steel session before it times out?

Call `client.sessions.release(session.id)` — releasing explicitly is best practice rather than waiting for the timeout. To clean up everything at once, `client.sessions.releaseAll()` releases all active sessions.


# Getting Started
URL: https://docs.steel.dev/overview/skills

Steel Skills help coding agents use Steel cloud browsers correctly. Install the skill that matches what you want the agent to do: operate a browser now, write Steel code, diagnose a failed session, improve reliability, or turn a repeated browser task into a reusable workflow.

Most users should start with `steel-browser` and `steel-developer`. Add the debugging and reliability skills when you are running repeated workflows or investigating failed sessions.

## Quick install

Use `npx skills` when you want a direct install command that works across supported coding agents.

```bash
npx skills add steel-dev/skills --list
npx skills add steel-dev/skills --skill steel-browser
npx skills add steel-dev/skills --skill steel-developer
```

Or use the Steel CLI helper:

```bash
steel skills install steel-browser
steel skills install steel-developer
steel skills doctor
```

## Claude Code plugin marketplace

Use the plugin marketplace if you are working inside Claude Code and prefer Claude's plugin manager. Run these slash commands inside Claude Code.

Add the Steel Skills marketplace once, then install individual skills from it:

```bash
/plugin marketplace add steel-dev/skills
/plugin install steel-browser@steel-skills
```

Install another skill by replacing `steel-browser` with any catalog skill:

```bash
/plugin install steel-developer@steel-skills
/plugin install steel-session-debugging@steel-skills
/plugin install steel-reliability@steel-skills
/plugin install steel-skill-creator@steel-skills
```

The marketplace name is `steel-skills`.

## Agent targets

Pass the target agent when you know where the skill should be installed:

```bash
npx skills add steel-dev/skills --skill steel-browser -a claude-code -g
npx skills add steel-dev/skills --skill steel-browser -a cursor -g
npx skills add steel-dev/skills --skill steel-browser -a codex -g
npx skills add steel-dev/skills --skill steel-browser -a opencode -g
```

Supported agent targets are Claude Code, Cursor, Codex, OpenCode, and Pi.

## Which skill should I use?

- Use `steel-browser` for live web work the agent should perform now.
- Use `steel-developer` for code, scripts, docs, examples, SDKs, and app integrations.
- Use `steel-session-debugging` when a session failed and you need evidence-backed diagnosis.
- Use `steel-reliability` for bot detection, CAPTCHA, proxy, identity, login reliability, pacing, and retries.
- Use `steel-skill-creator` to turn recurring browser tasks into reusable workflows.

## Verify setup

```bash
steel skills doctor
steel doctor --preflight
steel scrape https://example.com
```

Restart your agent client after installing new skills so it can discover them.

### FAQ

### How do I install Steel Skills?

Use `npx skills add steel-dev/skills --skill steel-browser` or the Steel CLI helper `steel skills install steel-browser`. In Claude Code you can also use the plugin marketplace: `/plugin marketplace add steel-dev/skills` then `/plugin install steel-browser@steel-skills`.

### Which coding agents do Steel Skills support?

Supported agent targets are Claude Code, Cursor, Codex, OpenCode, and Pi — pass the target with the `-a` flag, e.g. `npx skills add steel-dev/skills --skill steel-browser -a cursor -g`. Beyond these built-in targets, Steel Skills generally work with any agent that supports the agent skills format.


# Troubleshooting Skills
URL: https://docs.steel.dev/overview/skills/troubleshooting

Start here:

```bash
steel skills doctor
steel doctor --preflight
```

## Common Issues

### `npx skills` is missing

Install Node.js/npm, then retry:

```bash
npx skills add steel-dev/skills --skill steel-browser
```

### The agent does not see the skill

Restart the agent client after installation. Then run:

```bash
steel skills paths
```

### Claude Code does not show the marketplace

Make sure the marketplace was added from inside Claude Code:

```bash
/plugin marketplace add steel-dev/skills
```

Then install the skill by name:

```bash
/plugin install steel-browser@steel-skills
```

### The wrong skill triggers

Use the boundary map:

- Live browser work: `steel-browser`
- Code generation: `steel-developer`
- Failed-session diagnosis: `steel-session-debugging`
- Bot/proxy/CAPTCHA/login reliability: `steel-reliability`
- New reusable browser task skill: `steel-skill-creator`

### A live task failed

Use `steel-session-debugging` with the session ID. If evidence points to bot detection, CAPTCHA, proxy, identity, or pacing, continue with `steel-reliability`.


# What Is a CAPTCHA Solver? Automatic CAPTCHA Solving API
URL: https://docs.steel.dev/overview/stealth/captcha-solving

### What Is a CAPTCHA Solver?

A CAPTCHA solver is a service that notices a CAPTCHA challenge on a page, produces a valid answer for it, and submits that answer back to the page so an automated session can continue. In Steel the solver runs inside the browser session, so you turn it on with a single session flag instead of wiring up a separate solving provider.

```typescript
import Steel from 'steel-sdk';

const client = new Steel();

// One flag covers detection, solving, and verification for the whole session
const session = await client.sessions.create({
  solveCaptcha: true
});
```

```python
from steel import Steel

client = Steel()

# One flag covers detection, solving, and verification for the whole session
session = client.sessions.create(
    solve_captcha=True
)
```

Any solver, Steel's included, has to do three separate jobs, and a failure in any one of them looks the same from your automation's point of view:

*   **Detect** the challenge, including invisible ones that never render a visible widget

*   **Solve** it, using models, third-party solving services, or by reproducing the interaction a person would perform

*   **Submit and verify** the resulting token or input so the target page actually accepts it

### How Automated CAPTCHA Solving Works

When CAPTCHA solving is enabled on a session, every page runs through the same pipeline and each stage is reported as a task status you can read from the API:

1.  **Detection**: The system continuously monitors the page for CAPTCHA elements using multiple detection methods:

    *   DOM structure analysis

    *   Known CAPTCHA iframe patterns

    *   Common CAPTCHA API endpoints

    *   Visual element detection

2.  **State Management**: CAPTCHA states are tracked per page with real-time updates

3.  **Classification**: Once detected, the system identifies the specific type of CAPTCHA and routes it to the appropriate solver.

4.  **Solving**: CAPTCHAs are then solved by us using various methods:

    *   Machine learning models

    *   Third-party solving services

    *   Browser automation techniques

    *   Token manipulation (when applicable)

5.  **Verification**: The system verifies that the CAPTCHA was successfully solved before allowing the session to continue.

```typescript
const pages = await client.sessions.captchas.status(session.id);

for (const state of pages) {
  for (const task of state.tasks as Array<{ type: string; status: string }>) {
    // detected -> solving -> validating -> solved
    console.log(task.type, task.status);
  }
}
```

```python
pages = client.sessions.captchas.status(session.id)

for state in pages:
    for task in state.tasks:
        # detected -> solving -> validating -> solved
        print(task["type"], task["status"])
```

### CAPTCHA Types Steel Detects and Solves

Each detected challenge is classified, and its type string is what you see on the task in the [CAPTCHA status response](/overview/captchas-api/overview#getting-captcha-status). The types below cover what you will run into most often, and other widely used challenge types are auto-solved as well:

| Type string     | What the challenge looks like                            | Auto-solved |
| --------------- | -------------------------------------------------------- | ----------- |
| `recaptchaV2`   | "I'm not a robot" checkbox plus image challenges         | Yes         |
| `recaptchaV3`   | Invisible background scoring, no widget to click         | Yes         |
| `turnstile`     | Cloudflare's minimal-interaction widget                  | Yes         |
| `image_to_text` | Distorted characters in an image, solved with OCR        | Yes         |
| `slider`        | Drag a handle into the correct position                  | Yes         |
| `unknown`       | Challenge detected but not classified                    | No          |

### Detected but not auto-solved
DataDome, Imperva, Amazon WAF, and FunCAPTCHA challenges are detected and logged for diagnostics, but they are not auto-solved. Custom in-house and enterprise-specific CAPTCHA implementations are not supported either, so treat those sites as prevention problems rather than solving problems.

Read the types off the status response when you want to branch on what the page threw at you:

```typescript
const pages = await client.sessions.captchas.status(session.id);
const tasks = pages.flatMap((state) => state.tasks as Array<{ type: string }>);

// e.g. ['turnstile', 'image_to_text']
console.log(tasks.map((task) => task.type));
```

```python
pages = client.sessions.captchas.status(session.id)
types = [task["type"] for state in pages for task in state.tasks]

# e.g. ['turnstile', 'image_to_text']
print(types)
```

### How Steel Handles CAPTCHAs

Steel takes a two-pronged approach to dealing with CAPTCHAs:

1.  **Prevention First**: Our sophisticated browser fingerprinting and anti-detection systems often prevent CAPTCHAs from appearing in the first place. We maintain realistic browser profiles that make your automated sessions appear more human-like, reducing the likelihood of triggering CAPTCHA challenges.

2.  **Automatic Solving**: When CAPTCHAs do appear, our automatic solving system kicks in to handle them transparently, allowing your automation to continue without interruption.

Fingerprinting is applied to every session, so the practical decision is whether to pair it with residential IPs and solving on the sites that challenge you most:

```typescript
const session = await client.sessions.create({
  useProxy: true,      // residential IP, fewer challenges triggered
  solveCaptcha: true   // fallback for the ones that still appear
});
```

```python
session = client.sessions.create(
    use_proxy=True,      # residential IP, fewer challenges triggered
    solve_captcha=True,  # fallback for the ones that still appear
)
```

### Automatic vs Manual Solving

Automatic solving fires as soon as a challenge is detected and needs no further calls from you. Manual solving keeps detection on but waits for you to trigger the solve, which is useful when you want to decide whether a given challenge is worth solving at all.

| | Automatic solving | Manual solving |
| --- | --- | --- |
| Configuration | `solveCaptcha: true` | `solveCaptcha: true` plus `autoCaptchaSolving: false` |
| Trigger | Detection of the challenge | Your call to the solve endpoint |
| Extra API calls | None | One solve call per challenge or page |
| Best for | Unattended scraping and agent runs | Flows where you gate solving on the page, the URL, or your own logic |

#### Session Configuration

To enable autosolving, simply set `solveCaptcha: true` when creating a session.

```typescript
import Steel from 'steel-sdk';

const client = new Steel();

const session = await client.sessions.create({
  solveCaptcha: true
});
```

```python
from steel import Steel

client = Steel()
session = client.sessions.create(
    solve_captcha=True
)
```

To detect CAPTCHAs without automatically solving them, disable `autoCaptchaSolving` in the stealth config:

```typescript
const session = await client.sessions.create({
  solveCaptcha: true,
  stealthConfig: {
    autoCaptchaSolving: false
  }
});
```

```python
session = client.sessions.create(
    solve_captcha=True,
    stealth_config={
        "autoCaptchaSolving": False
    }
)
```

#### Manual Solving

If auto-solving is disabled, use the solve endpoint to trigger solving. You can solve all detected CAPTCHAs, or target a specific one with a value read from the [CAPTCHA status response](/overview/captchas-api/overview#getting-captcha-status):

*   `taskId`: the value of the task's `id` field

*   `url`: the URL the challenge was detected on

*   `pageId`: the page the challenge belongs to

```typescript
// Solve all detected CAPTCHAs
await client.sessions.captchas.solve('sessionId');

// Solve specific task
await client.sessions.captchas.solve('sessionId', { taskId: 'task_123' });

// Solve by URL
await client.sessions.captchas.solve('sessionId', { url: 'https://example.com' });

// Solve by Page ID
await client.sessions.captchas.solve('sessionId', { pageId: 'page_123' });
```

```python
# Solve all detected CAPTCHAs
client.sessions.captchas.solve("sessionId")

# Solve specific task
client.sessions.captchas.solve("sessionId", task_id="task_123")

# Solve by URL
client.sessions.captchas.solve("sessionId", url="https://example.com")

# Solve by Page ID
client.sessions.captchas.solve("sessionId", page_id="page_123")
```

### Response Times

Solving adds wall-clock time to a run, and how much depends on the challenge type and the target site, so Steel does not quote a fixed figure. Every task carries its own timings instead, which means you can measure the real numbers for the sites you actually automate:

```typescript
type CaptchaTiming = { type: string; status: string; totalDuration?: number };

const pages = await client.sessions.captchas.status(session.id);

for (const state of pages) {
  for (const task of state.tasks as CaptchaTiming[]) {
    // Milliseconds from detection to solved or failed
    console.log(task.type, task.status, task.totalDuration);
  }
}
```

```python
pages = client.sessions.captchas.status(session.id)

for state in pages:
    for task in state.tasks:
        # Milliseconds from detection to solved or failed
        print(task["type"], task["status"], task.get("totalDuration"))
```

Three fields on each task give you the full picture:

*   `detectionTime`: when the challenge was spotted on the page

*   `solveTime`: when solving finished

*   `totalDuration`: milliseconds from detection to the terminal status

### Budget for solving in your timeouts
Log `totalDuration` across a representative run, then set your session timeout and your own waits above the values you observe rather than around a guessed number.

### Best Practices for Implementation

#### 1\. Implement Proper Waiting

When navigating to pages that might contain CAPTCHAs, it's important to implement proper waiting strategies:

```typescript
// Typescript example using Puppeteer
await page.waitForNetworkIdle();  // Wait for network activity to settle
await page.waitForTimeout(2000);  // Additional safety buffer
```

```python
# Python example using Playwright
await page.wait_for_load_state('networkidle')  # Wait for network activity to settle
await page.wait_for_timeout(2000)  # Additional safety buffer
```

#### 2. Detecting CAPTCHA Presence

You can detect CAPTCHA presence using these selectors:

```typescript
// Common CAPTCHA selectors
const captchaSelectors = [
    'iframe[src*="recaptcha"]',
    '#captcha-box',
    '[class*="captcha"]'
];
```

### Important Considerations

1.  **Plan Availability**: CAPTCHA solving is only available on Developer, Startup, and Enterprise plans. It is not included in the free tier.

2.  **Success Rates**: While our system has high success rates, CAPTCHA solving is not guaranteed to work 100% of the time. Always implement proper error handling.

3.  **Timing**: CAPTCHA solving can add latency to your automation. Account for this in your timeouts and waiting strategies.

4.  **Rate Limits**: Even with successful CAPTCHA solving, respect the target site's rate limits and terms of service.

### Common Issues and Solutions

1.  **Timeout Issues**

    *   Increase your session timeout when working with CAPTCHA-heavy sites

    *   Implement exponential backoff for retries

2.  **Detection Issues**

    *   Use Steel's built-in stealth profiles

    *   Implement natural delays between actions

    *   Rotate IP addresses using Steel's proxy features

3.  **Solving Failures**

    *   Implement proper error handling

    *   Have fallback strategies ready

    *   Consider implementing manual solving as a last resort

### Best Practices for Avoiding CAPTCHAs

1.  **Use Steel's Fingerprinting**: Our automatic fingerprinting often helps bypass avoidable CAPTCHAs entirely by making your sessions appear more human-like.

2.  **Session Management**:

    *   Reuse successful sessions when possible

    *   Maintain cookies and session data

    *   Use Steel's session persistence features

3.  **Request Patterns**:

    *   Implement natural delays between actions

    *   Vary your request patterns

    *   Avoid rapid, repetitive actions

### Looking Forward

Steel is continuously improving its CAPTCHA handling capabilities. We regularly update our solving mechanisms to handle new CAPTCHA variants and improve success rates for existing ones, so check back here for the latest information about supported CAPTCHA types and best practices.

### Need help building with captcha solving?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### What is a CAPTCHA solver?

A CAPTCHA solver detects a challenge on a page, produces a valid answer for it, and submits that answer back so an automated session can continue. Steel's solver runs inside the browser session, so setting `solveCaptcha: true` covers detection, solving, and verification without a separate solving provider.

### What is a CAPTCHA solving API?

It is an API that exposes solving as part of your automation instead of as a manual step: you enable it on a session, then read CAPTCHA state and trigger solves over HTTP or the SDKs. In Steel that means the `solveCaptcha` session flag plus the status and solve endpoints of the [CAPTCHAs API](/overview/captchas-api/overview).

### How does Steel handle CAPTCHAs?

With a two-pronged approach: browser fingerprinting and anti-detection systems prevent many CAPTCHAs from appearing in the first place, and when one does appear, automatic solving handles it transparently. Enable it by setting `solveCaptcha: true` when creating a session.

### What CAPTCHA types does Steel support?

Steel's auto-solver currently handles reCAPTCHA v2 and v3, Cloudflare Turnstile, image-to-text, slider, and other widely used challenge types. Systems like DataDome, Imperva, Amazon WAF, and FunCAPTCHA are detected and logged but not auto-solved, and custom or enterprise-specific implementations aren't supported.

### Should I use automatic or manual solving?

Automatic solving is the default and the right choice for unattended runs, since it fires on detection with no extra calls. Set `autoCaptchaSolving: false` when you want detection only and prefer to decide per challenge, then trigger the solve endpoint yourself.

### How long does CAPTCHA solving take?

It depends on the challenge type and the target site, so Steel does not publish a fixed number. Each task in the status response carries `detectionTime`, `solveTime`, and `totalDuration`, so measure solving latency on your own target sites and size your timeouts from those values.

### Does Steel's CAPTCHA solver work 100% of the time?

No, the system has high success rates but solving is not guaranteed to work 100% of the time, so you should implement proper error handling. Solving can also add latency, so account for it in your timeouts and waiting strategies.

### How can I avoid triggering CAPTCHAs in the first place?

Use Steel's automatic fingerprinting (which often bypasses avoidable CAPTCHAs by making sessions appear more human-like), reuse successful sessions while maintaining cookies, and add natural delays while avoiding rapid, repetitive actions.


# Proxies & Proxy Rotation for Browser Automation
URL: https://docs.steel.dev/overview/stealth/proxies

## Overview
Steel offers two powerful ways to use proxies: our built-in **Managed Residential Proxies** or connecting to your own proxy provider with our **Bring Your Own Proxy (BYOP)** feature.

If your workflow needs a stable network identity instead of automatic rotation, use
[Dedicated IPs](/overview/sessions-api/dedicated-ips). Dedicated IPs lease a specific
Steel-managed IP into your workspace so sessions can reuse the same egress IP, especially
when paired with [Profiles](/overview/profiles-api/overview) for persistent cookies and
browser state.

### Which Proxy Approach Should you choose?

Use this table to pick the right option for your project.

| Feature     | Steel-Managed Proxies                                                                 | Default Behavior (No proxies)                                 | Bring Your Own Proxies (BYOP)                                         |
|-------------|---------------------------------------------------------------------------------------|---------------------------------------------------------------|------------------------------------------------------------------------|
| Best For    | Quickly accessing high-quality residential IPs from specific countries without setup. | General web access, testing, or sites that don't block datacenter IPs. | Full control over your proxy infrastructure, using specialized providers. |
| IP Type     | High-quality residential IPs                                                          | Datacenter                                                    | Any (Datacenter, Residential, Mobile)                                |
| Control     | Managed by Steel (automatic rotation)                                                 | Static datacenter IP assigned by Steel                        | Full control over IPs and rotation logic                             |
| Cost        | Billed per GB of usage by Steel                                                       | Free (included in all plans)                                  | No charge from Steel; you pay your own proxy provider                |
| Availability| Developer, Pro, & Enterprise plans                                                    | All plans, including Hobby (free)                             | All plans, including Hobby (free)                                    |

### Steel-Managed Proxies

⭐ **_This is the best option for most use-cases._**

Steel maintains a high-quality pool of residential IP addresses that make your browser sessions appear to come from real user connections. Our residential proxy network includes:

*   **Hundreds of millions of IP addresses** sourced from legitimate residential connections

*   **United States locations by default** with options for global geographic targeting

*   **Continuous quality monitoring** through our internal testing and validation systems

*   **Automatic IP rotation** to ensure fresh addresses for each session

These proxies are ideal for accessing sites that block datacenter IPs or when you need to appear as a genuine residential user.

### Default Behavior (No Proxies)

When you create a Steel session without enabling proxies, your requests originate from the datacenter/machine’s IP addresses where Steel's browser infrastructure is hosted. This option is free, available on all plans, and incurs no charges on proxy bandwidth. This approach works well for:

*   Interacting with websites that aren’t blocking default these datacenter IPs

*   General web scraping that doesn't require specific geographic locations

*   Internal applications or APIs that don't have geo-restrictions

*   Testing and development where IP location isn't critical

### Bring Your Own Proxies (BYOP)

If you have existing proxy infrastructure or specific proxy requirements, you can route Steel sessions through your own proxy servers. This approach gives you:

*   **Complete control** over your proxy infrastructure and IP sources

*   **No additional costs** from Steel - you only pay for your own proxy services

*   **Flexibility** to use specialized proxy providers or custom configurations

*   **Compatibility** with both Steel Cloud and the open-source Steel browser

By default, proxies are disabled (`useProxy: false` is the implicit setting). This means your traffic originates from Steel's own datacenter IPs.

### Using Steel-Managed Residential Proxies

To enable it, simply set `useProxy: true` when creating a session. By default, your traffic will be routed through a new US-based IP address each session:

```typescript
// Typescript SDK
const session = await client.sessions.create({
    useProxy: true
});
```

```python
# Python SDK
session = client.sessions.create(
    use_proxy=True
)
```

### Geographic Targeting

You can easily target countries, states (US only), or cities:

**Quality vs. Specificity**

The more specific your targeting, the smaller the IP pool. For the best performance and highest quality IPs, use the broadest targeting that meets your needs (e.g., prefer country-level over city-level). Generally, we’ve seen US and GB proxies have the highest quality.

```typescript
// Target specific state
const session = await client.sessions.create({
  useProxy: {
    geolocation: { country: "US", state: "NY" },
  },
});

// Target specific city
const session = await client.sessions.create({
  useProxy: {
    geolocation: { city: "LOS_ANGELES" },
  },
});
```

```python
# Target specific state
session = client.sessions.create(
    use_proxy={
        "geolocation": { "country": "US", "state": "NY" }
    }
)

# Target specific city
session = client.sessions.create(
    use_proxy={
        "geolocation": { "city": "LOS_ANGELES" }
    }
)
```

**Available targeting options:**

*   **Countries**: We support over 200 countries via their two-letter Alpha-2 codes

*   **States**: Supported for the US only

*   **Cities**: Available for major global cities

### Bring Your Own Proxies (BYOP)

If you already have a proxy provider or need highly specialized configurations, you can route Steel sessions through your own proxy server. This gives you complete control and avoids any additional proxy fees from Steel.

```typescript
// Typescript SDK
const session = await client.sessions.create({
  useProxy: {
    server: "http://username:password@proxy.example.com:8080",
  },
});
```

```python
# Python SDK
session = client.sessions.create(
    use_proxy={
        "server": "http://username:password@proxy.example.com:8080"
    }
)
```

**Supported proxy formats:**

*   `http://username:password@hostname:port`

*   `https://username:password@hostname:port`

*   `socks5://username:password@hostname:port`

Your proxy credentials are handled securely and never logged or stored by Steel beyond the duration of your session.

#### Proxy Connection Errors

You may occasionally encounter proxy connection errors like `ERR_TUNNEL_CONNECTION_FAILED`, `ERR_PROXY_CONNECTION_FAILED`, or `ERR_CONN_REFUSED`. This error indicates a connectivity issue between Steel's infrastructure and the proxy server.

**This is normal behavior** and can happen for several reasons:

*   Temporary proxy server unavailability

*   Network connectivity issues between Steel and the proxy

*   The target website blocking the specific proxy IP

**When this happens:**

1.  **Retry your request.** These errors are usually transient.

2.  If using **Steel-Managed proxies,** we automatically rotate to a new IP on retry.

3.  If using **BYOP**, ensure your proxy server is online and accessible.

If the error persists across multiple retries, it may point to a more systemic issue.

#### **Website Blocking**

To maintain a high-quality and compliant network, Steel and its partners may restrict access to certain websites. We do this to ensure the long-term health and reputation of our IP pool. Blocklists are typically maintained for:

*   Gambling and betting websites

*   Government and restricted institutional sites

*   Ticketing websites

*   Other categories flagged for compliance reasons

**If you're experiencing unexpected or persistent blocking:**

1.  **Change the geographic region.** A different IP block might solve the problem.

2.  **Use BYOP.** If you need access to specific restricted content, using your own proxy provider gives you full control.

3.  **Contact Support.** If you believe a legitimate site is being blocked, please let us know. If retries and changing regions consistently fail, it might indicate the domain is on a compliance blocklist. Escalating to our team helps us investigate.

Most blocking issues can be resolved through configuration adjustments or by working with our team to whitelist specific domains.

Follow these guidelines to get the most out of your proxies and build more resilient automations.

1.  **Establish a Baseline Without Proxies**
    Before assuming you need a proxy for anti-bot measures, try accessing the target website without one. If Steel's default datacenter IPs work, you can save on costs. Use proxies as the next step if you encounter blocks.

2.  **Start with Broad Targeting**
    For the best performance, always start with country-level targeting. The larger IP pool provides higher quality and better success rates. Only use state or city-level targeting when it is a strict requirement for your use case.

3.  **Build Fallback Logic in Your Code**
    Proxy connections can sometimes fail (e.g., `ERR_TUNNEL_CONNECTION_FAILED`). This is normal. Your code should anticipate this by including retry logic. For critical tasks, consider having a fallback plan, such as retrying the request without a proxy or with a different proxy configuration.

4.  **Monitor Success Rates with Narrow Targeting**
    If you must use city-level targeting, closely monitor your job success rates. A high rate of failure could mean the local IP pool is too small or contains IPs that have been blocked or are of lower quality.

5.  **Test Different Regions for Blocked Content**
    If you're consistently blocked when targeting a specific country, try your request again from a different region. The target website may have different rules or restrictions for different geographic locations.

### Need help building with proxies?
Reach out to us on the **#help** channel on [Discord](https://discord.gg/steel-dev) under the ⭐ community section.

### FAQ

### How much do Steel proxies cost?

Steel-managed residential proxies are billed per GB of usage, while the default behavior (no proxy, Steel's datacenter IPs) is free and included in all plans. Bring Your Own Proxy carries no charge from Steel — you only pay your own proxy provider.

### Do Steel sessions use a proxy by default?

No — proxies are disabled by default (`useProxy: false` is the implicit setting), so traffic originates from Steel's own datacenter IPs at no proxy-bandwidth cost. Set `useProxy: true` to route through a Steel-managed residential IP (US-based by default).

### Can I use my own proxies with Steel?

Yes — Bring Your Own Proxy (BYOP) lets you pass a `server` URL in `http://`, `https://`, or `socks5://` format on any plan, including the free Hobby plan. Your proxy credentials are handled securely and never logged or stored by Steel beyond the duration of your session.

### Can Steel proxies target a specific country or city?

Yes — Steel-managed proxies support geographic targeting across 200+ countries (two-letter Alpha-2 codes), US states, and major global cities via the `geolocation` option. Steel recommends the broadest targeting that meets your needs, since narrower targeting means a smaller IP pool.

### Which Steel plans include managed residential proxies?

Developer, Pro, and Enterprise plans. The proxy pool offers hundreds of millions of residential IPs with automatic rotation, while the no-proxy default and BYOP are available on all plans including the free Hobby tier.


# Embed Sessions
URL: https://docs.steel.dev/overview/sessions-api/embed-sessions

You can embed Steel sessions directly into your applications or dashboards to watch live browser activity or replay recorded sessions.

Steel supports two types of embeds:

- [Live Sessions](/overview/sessions-api/embed-sessions/live-sessions): Stream an active session in real time using WebRTC (headful by default).
- [Past Sessions](/overview/sessions-api/embed-sessions/past-sessions): Replay completed sessions as MP4/HLS video (or rrweb for legacy headless).

# Live Sessions
URL: https://docs.steel.dev/overview/sessions-api/embed-sessions/live-sessions

Steel sessions can be viewed live directly from your app or dashboard.  
With the new **headful experience**, live views now stream real-time video using WebRTC — low-latency, high-fidelity, and OS-accurate.

Legacy **headless sessions** continue to use the same debug URL but display content using Chrome’s screencasting for backward compatibility.

## Embed Headful Live Sessions (Recommended)

### What Changed
Steel’s live view now uses **WebRTC-based video streaming at 25 fps (H.264)**, replacing Chrome’s screencasting and screenshot-based method.

- Real-time OS-level capture  
- Stable 25 fps playback  
- Low-latency streaming with full visual fidelity  

> **Tip:** Headful sessions are now **default** for all new sessions.  
> You’ll use the same `debugUrl`, and Steel automatically chooses the proper playback technology.

---

### Getting the Debug URL

When creating a session with the API, the response includes a `debugUrl`.  
You can open this URL directly in your browser or embed it inside an application.

```typescript
import { Steel } from "steel-sdk";

const client = new Steel({ apiKey: process.env.STEEL_API_KEY });
const session = await client.sessions.create();

console.log("Debug URL:", session.debugUrl);
```

```python
from steel import Steel

client = Steel()
session = client.sessions.create()
print("Debug URL:", session.debug_url)
```

---

### Embedding in Your Application

Embed the session directly in your UI using an iframe:

```html
<iframe
  src="{session.debugUrl}?interactive=true"
  style="width: 100%; height: 600px; border: none;"
></iframe>
```

- Streams real-time browser output using **WebRTC + H.264**  
- Works in all major browsers with baseline H.264 support  
- `interactive=true` allows remote mouse/keyboard input for collaborative debugging or human-in-the-loop workflows  

> **Note:** For security reasons, debug URLs are **unauthenticated**.  
> Anyone with the debug URL can view or interact with that session.  
> Use your own-access controls if embedding in a user-facing product.

---

### Supported Parameters (Headful)

| Parameter | Type | Default | Description |
|------------|------|----------|-------------|
| `interactive` | boolean | `true` | Enables or disables remote control of the live session. |

Example:

```html
<iframe
  src={`${session.debugUrl}?interactive=false`}
  style="width: 100%; height: 600px; border: none;"
></iframe>
```

Disabling interactivity makes the view read-only, ideal for watch-only monitoring scenarios.

---

## Headless (Legacy)

> Headless live sessions remain supported for existing workflows.  
> They use Chrome’s screencasting instead of WebRTC and expose additional configuration options.

### Configuration Options (Headless Only)

| Parameter | Type | Default | Description |
|------------|------|----------|-------------|
| `theme` | string | `"dark"` | UI theme (`dark` or `light`) |
| `interactive` | boolean | `true` | Enable or disable interaction |
| `showControls` | boolean | `true` | Show or hide navigation UI |
| `pageId` | string | (empty) | Focus the view on a specific page/tab |
| `pageIndex` | string | (empty) | Display a specific tab by index |

Example:

```html
<iframe
  src={`${session.debugUrl}?theme=light&interactive=true&showControls=true&pageIndex=0`}
  style="width: 100%; height: 600px; border: none;"
></iframe>
```

---

### Common Use Cases

**Read-only viewer**

```html
<iframe
  src={`${session.debugUrl}?interactive=false`}
  style="width: 100%; height: 600px; border: none;"
></iframe>
```

**Human-in-the-loop control**

Allow humans to take over automation tasks or debug live workflows interactively using `interactive=true`.

---

### Troubleshooting

If the embedded view appears blank or unresponsive:
- Ensure the session is active (default timeout: 5 min).  
- Confirm your browser supports **H.264 baseline** playback.  
- Check your container has fixed dimensions (`width` and `height`).  
- Verify the correct session and valid API key were used.

---

### Summary

All new sessions now run **headful by default**, streaming real-time video with WebRTC.  
Use the same `debugUrl` to embed or view — Steel automatically determines the correct playback mode.  

Headless live streams remain available for legacy sessions but will be phased out over time.

### FAQ

### Can I embed a live Steel browser session in my app?

Yes. Every session created via the API returns a `debugUrl` that you can open directly in a browser or embed in your UI with an iframe, streaming the live browser in real time.

### Can users interact with an embedded live session, or is it view-only?

Both. The `interactive` parameter (default `true`) enables remote mouse and keyboard input for human-in-the-loop workflows; set `interactive=false` on the debug URL for a read-only, watch-only view.

### Is the debug URL authenticated?

No. Debug URLs are unauthenticated by design, so anyone with the URL can view or interact with that session. Add your own access controls if you embed live sessions in a user-facing product.

### How does Steel's live view streaming work?

New sessions run headful by default and stream real-time video over WebRTC at 25 fps using H.264, with OS-level capture and low latency. Legacy headless sessions use the same `debugUrl` but display via Chrome's screencasting, and Steel automatically picks the right playback mode.


# Past Sessions
URL: https://docs.steel.dev/overview/sessions-api/embed-sessions/past-sessions

Steel automatically records every session so you can replay it later.  
With the new headful session recordings, you can now embed real MP4 playback — no event reconstruction, no missing UI elements.

For older implementations, we still support headless playback via rrweb.

## Embed Headful Session Recordings (Recommended)

### What Changed
Steel has moved from slow, unreliable screencasting and event-based playback to full OS-level streaming and MP4 recordings.

- 25fps WebRTC-based video streaming  
- MP4 recordings showing the exact screen output  
- No discrepancies between actual sessions and replays  

> **Tip:** Headful sessions are now **default** for all Steel sessions.  
> No changes are needed to your integration — this gives you direct control over embedding playback.

### Retrieving the Recording Playlist

```typescript
const playlist = await fetch("https://api.steel.dev/v1/sessions/{session_id}/hls", {
  headers: {
    "steel-api-key": "YOUR_API_KEY"
  }
});
```

```python
import requests

url = "https://api.steel.dev/v1/sessions/{session_id}/hls"
headers = {
    "steel-api-key": "YOUR_API_KEY"
}
response = requests.get(url, headers=headers)
playlist = response.text
```

This returns an HLS playlist that can be used in any compatible video player.

### Embedding in a Web Page

```html
<!doctype html>
<html>
  <body>
    <video id="player" controls playsinline style="width:100%;max-width:900px;"></video>

    <script type="module">
      import Hls from "https://cdn.jsdelivr.net/npm/hls.js@^1.5.0/dist/hls.mjs";
      const sessionId = "e4d682bb-a7f2-432c-ad13-8b116695d59e";
      const API_KEY = "YOUR_API_KEY";
      const manifestUrl = `https://api.steel.dev/v1/sessions/${sessionId}/hls`;
      const video = document.getElementById("player");

      if (Hls.isSupported()) {
        const hls = new Hls({
          xhrSetup: (xhr) => {
            xhr.setRequestHeader("steel-api-key", API_KEY);
          }
        });
        hls.loadSource(manifestUrl);
        hls.attachMedia(video);
      } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
        video.src = manifestUrl;
      } else {
        video.outerHTML = "<p>Your browser does not support HLS.</p>";
      }
    </script>
  </body>
</html>
```

**Notes:**
- Works with any HLS-compatible player (e.g., Safari, HLS.js, JW Player, Video.js).  
- Recordings are durable MP4 streams for accurate, 1:1 playback.

## Headless

> Headless playback is supported for legacy sessions.  
> New sessions use headful replays for full visual fidelity — we recommend migrating when possible.

### Overview
Every Steel browser session records page events.  
You can fetch those events from the `/v1/sessions/:id/events` endpoint and replay them using `rrweb-player`.

### Retrieve the Recorded Events

**SDK Example**

```ts
const events = await client.sessions.events(session.id);
```

or

```python
events = client.sessions.events(session_id=session.id)
```

**Direct API**

```text
GET /v1/sessions/:id/events
```

### Replay with rrweb-player

**Install**
```bash
npm install rrweb-player
```

**Usage**
```ts
import rrwebPlayer from "rrweb-player";
import "rrweb-player/dist/style.css";

const events = await client.sessions.events(session.id);
const playerElement = document.getElementById("player-container");

new rrwebPlayer({
  target: playerElement,
  props: {
    events: events,
    width: 800,
    height: 600,
    autoPlay: true,
    skipInactive: true
  }
});
```

**HTML**
```html
<div id="player-container"></div>
```

---

### Summary
All new sessions now run **headful by default**.  
Headless event-based playback remains available for legacy recordings but will be deprecated in the future.  
Use headful recordings for the most accurate, reliable replays.


# Available Skills
URL: https://docs.steel.dev/overview/skills/available-skills


## Install commands

```bash
npx skills add steel-dev/skills --skill steel-browser
npx skills add steel-dev/skills --skill steel-developer
npx skills add steel-dev/skills --skill steel-session-debugging
npx skills add steel-dev/skills --skill steel-reliability
npx skills add steel-dev/skills --skill steel-skill-creator
```

For Claude Code plugin installs, add the marketplace once and install by skill name:

```bash
/plugin marketplace add steel-dev/skills
/plugin install steel-browser@steel-skills
```

## Choosing a skill

| Intent                                                                 | Primary Skill             |
| ---------------------------------------------------------------------- | ------------------------- |
| Browse, click, scrape, screenshot, or create a PDF now                 | `steel-browser`           |
| Write reusable Steel code                                              | `steel-developer`         |
| Diagnose a failed session                                              | `steel-session-debugging` |
| Mitigate bot detection, CAPTCHA, proxy, identity, or login reliability | `steel-reliability`       |
| Turn a recurring browser task into a reusable workflow                 | `steel-skill-creator`     |


# Steel Browser Skill
URL: https://docs.steel.dev/overview/skills/available-skills/steel-browser

## Install

```bash
npx skills add steel-dev/skills --skill steel-browser
```

After adding the Steel Skills marketplace in Claude Code:

```bash
/plugin install steel-browser@steel-skills
```

## Use When

- The agent should visit a page now.
- WebFetch or curl is insufficient.
- The task needs JavaScript, forms, login, screenshots, PDFs, or multi-step navigation.

## Example Prompts

- "Use Steel to open example.com and summarize the page."
- "Fill out this form in a Steel browser and show me the result."
- "Take a full-page screenshot of this dashboard."

## Related Skills

- Use `steel-developer` for reusable Steel code.
- Use `steel-session-debugging` if a live session fails.
- Use `steel-reliability` if evidence points to bot detection, CAPTCHA, proxy, or login reliability.


# Steel Developer Skill
URL: https://docs.steel.dev/overview/skills/available-skills/steel-developer

## Install

```bash
npx skills add steel-dev/skills --skill steel-developer
```

After adding the Steel Skills marketplace in Claude Code:

```bash
/plugin install steel-developer@steel-skills
```

## Use When

- The user wants SDK, REST API, Playwright, Puppeteer, Stagehand, or Browser Use code.
- The user is building an app on Steel.
- The task involves credentials, profiles, files, extensions, live embeds, traces, or computer-use integrations.

## Example Prompts

- "Write a TypeScript Playwright script that runs on Steel."
- "Show how to upload a file into a Steel session and submit it."
- "Build a UI that embeds a live Steel session viewer."

## Related Skills

- Use `steel-browser` for live web work now.
- Use `steel-session-debugging` for failed-session diagnosis.
- Use `steel-reliability` for bot/proxy/CAPTCHA/login mitigation.


# Steel Reliability Skill
URL: https://docs.steel.dev/overview/skills/available-skills/steel-reliability

## Install

```bash
npx skills add steel-dev/skills --skill steel-reliability
```

After adding the Steel Skills marketplace in Claude Code:

```bash
/plugin install steel-reliability@steel-skills
```

## Use When

- The site returns 403, access denied, verify-human pages, or redirect loops.
- CAPTCHA solving fails or loops.
- Steel-managed proxy or BYOP proxy behavior needs diagnosis.
- Login state, profiles, credentials, identity, pacing, or retry policy affects success.

## Example Prompts

- "Plan the least invasive mitigation for this 403."
- "Show me the safe BYOP pattern on Hobby."
- "Should this login workflow use profiles, credentials, or both?"

## Related Skills

Use `steel-session-debugging` first when the failure class is unknown or you need logs, traces, replay links, or screenshots.


# Steel Session Debugging Skill
URL: https://docs.steel.dev/overview/skills/available-skills/steel-session-debugging

## Install

```bash
npx skills add steel-dev/skills --skill steel-session-debugging
```

After adding the Steel Skills marketplace in Claude Code:

```bash
/plugin install steel-session-debugging@steel-skills
```

## Use When

- A Steel session failed, timed out, stalled, or produced unexpected output.
- The user provides a session ID, replay, screenshot, log snippet, or failure report.
- The agent needs evidence before recommending a fix.

## Example Prompts

- "Debug why Steel session `sess_123` failed after login."
- "Use the traces and logs to explain what happened in this run."
- "Classify this failure and tell me the next verification step."

## Related Skills

Use `steel-reliability` after evidence collection when the fix is bot detection, CAPTCHA, proxy, profile identity, login persistence, pacing, or retry strategy.


# Steel Skill Creator
URL: https://docs.steel.dev/overview/skills/available-skills/steel-skill-creator

## Install

```bash
npx skills add steel-dev/skills --skill steel-skill-creator
```

After adding the Steel Skills marketplace in Claude Code:

```bash
/plugin install steel-skill-creator@steel-skills
```

## Use When

- A user repeats the same browser task.
- The task has clear inputs and success criteria.
- The agent can safely record the task twice in Steel.
- You want a repeatable workflow the agent can run again later.

## Example Prompts

- "Turn this weekly report download into a reusable workflow."
- "Capture this search workflow twice and make it parameterized."
- "Package this repeated browser task so my agent can run it again next week."

## Related Skills

Use `steel-browser` to drive the recording sessions, `steel-session-debugging` for failed recordings, and `steel-reliability` for bot/proxy/CAPTCHA reliability issues.
