From 0dff61ab34ef4556e15d53213dae1dc2cd077122 Mon Sep 17 00:00:00 2001 From: junhsss Date: Wed, 24 Jun 2026 01:17:44 +0900 Subject: [PATCH 01/11] chore: bump cookbook version --- content/docs/cookbook/agentkit.mdx | 10 +- content/docs/cookbook/agno.mdx | 10 +- content/docs/cookbook/auth-context.mdx | 6 +- content/docs/cookbook/authors/junhsss.mdx | 12 +- content/docs/cookbook/authors/meta.json | 10 +- .../cookbook/browser-use-captcha-auto.mdx | 6 +- .../cookbook/browser-use-captcha-manual.mdx | 6 +- content/docs/cookbook/browser-use.mdx | 6 +- content/docs/cookbook/chromedp.mdx | 90 +++++ content/docs/cookbook/chromiumoxide.mdx | 98 +++++ content/docs/cookbook/claude-agent-sdk.mdx | 12 +- .../cookbook/claude-computer-use-mobile.mdx | 4 +- content/docs/cookbook/claude-computer-use.mdx | 247 +++++++++++- .../docs/cookbook/convex-chat-with-page.mdx | 10 +- content/docs/cookbook/convex-price-watch.mdx | 6 +- content/docs/cookbook/credentials.mdx | 4 +- content/docs/cookbook/crewai.mdx | 10 +- content/docs/cookbook/deep-research.mdx | 12 +- content/docs/cookbook/eino.mdx | 110 ++++++ content/docs/cookbook/extensions.mdx | 6 +- content/docs/cookbook/files.mdx | 6 +- content/docs/cookbook/gemini-computer-use.mdx | 6 +- content/docs/cookbook/genkit.mdx | 117 ++++++ content/docs/cookbook/google-adk.mdx | 373 ++++++++++++++++++ content/docs/cookbook/index.mdx | 91 +++++ content/docs/cookbook/langchaingo.mdx | 86 ++++ content/docs/cookbook/langgraph.mdx | 6 +- content/docs/cookbook/magnitude.mdx | 10 +- content/docs/cookbook/mastra.mdx | 6 +- .../cookbook/microsoft-agent-framework.mdx | 10 +- content/docs/cookbook/notte.mdx | 10 +- content/docs/cookbook/openai-agents.mdx | 8 +- content/docs/cookbook/openai-computer-use.mdx | 224 ++++++++++- content/docs/cookbook/playwright.mdx | 12 +- content/docs/cookbook/profiles.mdx | 6 +- content/docs/cookbook/puppeteer.mdx | 10 +- content/docs/cookbook/pydantic-ai.mdx | 6 +- content/docs/cookbook/rig.mdx | 106 +++++ content/docs/cookbook/rod.mdx | 122 ++++++ content/docs/cookbook/scrape.mdx | 347 ++++++++++++++++ content/docs/cookbook/selenium.mdx | 10 +- content/docs/cookbook/stagehand.mdx | 12 +- content/docs/cookbook/swiftide.mdx | 108 +++++ content/docs/cookbook/topics/agents.mdx | 6 + .../cookbook/topics/browser-automation.mdx | 3 + content/docs/cookbook/topics/steel-apis.mdx | 1 + content/docs/cookbook/topics/typed-output.mdx | 1 + .../docs/cookbook/vercel-ai-sdk-nextjs.mdx | 6 +- content/docs/cookbook/vercel-ai-sdk.mdx | 6 +- content/docs/cookbook/you-com-search.mdx | 10 +- cookbook.lock.json | 2 +- lib/remark-format-code.ts | 3 + scripts/sync-cookbook.ts | 2 +- 53 files changed, 2271 insertions(+), 136 deletions(-) create mode 100644 content/docs/cookbook/chromedp.mdx create mode 100644 content/docs/cookbook/chromiumoxide.mdx create mode 100644 content/docs/cookbook/eino.mdx create mode 100644 content/docs/cookbook/genkit.mdx create mode 100644 content/docs/cookbook/google-adk.mdx create mode 100644 content/docs/cookbook/langchaingo.mdx create mode 100644 content/docs/cookbook/rig.mdx create mode 100644 content/docs/cookbook/rod.mdx create mode 100644 content/docs/cookbook/scrape.mdx create mode 100644 content/docs/cookbook/swiftide.mdx diff --git a/content/docs/cookbook/agentkit.mdx b/content/docs/cookbook/agentkit.mdx index 453d67af..4cf83ec9 100644 --- a/content/docs/cookbook/agentkit.mdx +++ b/content/docs/cookbook/agentkit.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Inngest AgentKit description: "Integrate Steel with Inngest's AgentKit framework." --- - + - + @@ -116,7 +116,7 @@ A run lands in the ~20-40 second range. ## Related recipes - - - + + + diff --git a/content/docs/cookbook/agno.mdx b/content/docs/cookbook/agno.mdx index fe0b2db3..5ec04b7c 100644 --- a/content/docs/cookbook/agno.mdx +++ b/content/docs/cookbook/agno.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Agno description: Integrate Steel with the Agno agent framework. --- - + - + @@ -77,7 +77,7 @@ The `finally` block in `main()` calls `tools.close_session()`, which releases th ## Related recipes - - - + + + diff --git a/content/docs/cookbook/auth-context.mdx b/content/docs/cookbook/auth-context.mdx index 8ab79e9f..4b59c95a 100644 --- a/content/docs/cookbook/auth-context.mdx +++ b/content/docs/cookbook/auth-context.mdx @@ -3,9 +3,9 @@ title: Reuse authenticated sessions across browsers description: Maintain authenticated sessions across Steel browser instances by capturing and reusing cookies and local storage. --- - + - + @@ -92,5 +92,5 @@ If you want Steel to store credentials and handle the login itself, see [credent - + diff --git a/content/docs/cookbook/authors/junhsss.mdx b/content/docs/cookbook/authors/junhsss.mdx index ca06e82a..9382050f 100644 --- a/content/docs/cookbook/authors/junhsss.mdx +++ b/content/docs/cookbook/authors/junhsss.mdx @@ -1,11 +1,21 @@ --- title: Jun Ryu -description: 27 recipes contributed to the Steel Cookbook by Jun Ryu. +description: 37 recipes contributed to the Steel Cookbook by Jun Ryu. --- + + + + + + + + + + diff --git a/content/docs/cookbook/authors/meta.json b/content/docs/cookbook/authors/meta.json index 31b53725..9b9460bb 100644 --- a/content/docs/cookbook/authors/meta.json +++ b/content/docs/cookbook/authors/meta.json @@ -1,4 +1,12 @@ { "title": "Authors", - "pages": ["aspectrr", "bsparker", "danew", "hussufo", "jagadeshjai", "junhsss", "nibzard"] + "pages": [ + "aspectrr", + "bsparker", + "danew", + "hussufo", + "jagadeshjai", + "junhsss", + "nibzard" + ] } diff --git a/content/docs/cookbook/browser-use-captcha-auto.mdx b/content/docs/cookbook/browser-use-captcha-auto.mdx index 81027e1c..c430191e 100644 --- a/content/docs/cookbook/browser-use-captcha-auto.mdx +++ b/content/docs/cookbook/browser-use-captcha-auto.mdx @@ -3,9 +3,9 @@ title: Solve CAPTCHAs automatically in a Browser Use agent description: Build an AI agent with browser-use and Steel that solves CAPTCHAs automatically. --- - + - + @@ -101,5 +101,5 @@ A run usually finishes in under a minute: a few cents of Steel session time plus - + diff --git a/content/docs/cookbook/browser-use-captcha-manual.mdx b/content/docs/cookbook/browser-use-captcha-manual.mdx index acb1b00a..336b52b1 100644 --- a/content/docs/cookbook/browser-use-captcha-manual.mdx +++ b/content/docs/cookbook/browser-use-captcha-manual.mdx @@ -3,9 +3,9 @@ title: Solve reCAPTCHA v2 manually with Browser Use description: "Manually solve reCAPTCHA v2 using Steel's CAPTCHA API with the browser-use framework." --- - + - + @@ -139,5 +139,5 @@ A run takes ~60 seconds and costs Steel session time plus OpenAI tokens for each - + diff --git a/content/docs/cookbook/browser-use.mdx b/content/docs/cookbook/browser-use.mdx index 4a0a4f66..258ce3c5 100644 --- a/content/docs/cookbook/browser-use.mdx +++ b/content/docs/cookbook/browser-use.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Browser Use description: Integrate Steel with the browser-use framework for AI-driven web automation. --- - + - + @@ -76,5 +76,5 @@ A run costs a few cents of Steel session time plus OpenAI tokens for each step t - + diff --git a/content/docs/cookbook/chromedp.mdx b/content/docs/cookbook/chromedp.mdx new file mode 100644 index 00000000..d04398d6 --- /dev/null +++ b/content/docs/cookbook/chromedp.mdx @@ -0,0 +1,90 @@ +--- +title: Automate a cloud browser with chromedp +description: Use Steel with chromedp to connect over CDP, navigate to Hacker News, extract the top stories, and capture a screenshot. +--- + + + + + + + +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. + +```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. [go-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 + + + + + + diff --git a/content/docs/cookbook/chromiumoxide.mdx b/content/docs/cookbook/chromiumoxide.mdx new file mode 100644 index 00000000..a8f25fc1 --- /dev/null +++ b/content/docs/cookbook/chromiumoxide.mdx @@ -0,0 +1,98 @@ +--- +title: Automate a cloud browser with chromiumoxide +description: Use Steel with chromiumoxide to connect over CDP, drive the handler task, extract page content, and capture a screenshot. +--- + + + + + + + +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. + +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`, so the extraction stays typed rather than a pile of per-element awaits: + +```rust +let stories: Vec = page.evaluate(EXTRACT_STORIES).await?.into_value()?; +``` + +The screenshot uses `page.screenshot`, which returns the PNG as `Vec` 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` 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. +- [thirtyfour-rs](../thirtyfour-rs) drives Steel over WebDriver instead of CDP, the Rust counterpart to the Selenium recipe. +- [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 + + + + + + diff --git a/content/docs/cookbook/claude-agent-sdk.mdx b/content/docs/cookbook/claude-agent-sdk.mdx index 1cfbe370..dfcf246b 100644 --- a/content/docs/cookbook/claude-agent-sdk.mdx +++ b/content/docs/cookbook/claude-agent-sdk.mdx @@ -3,13 +3,13 @@ title: Build a browser agent with the Claude Agent SDK description: "Use Steel with the Claude Agent SDK (TypeScript) to build a tool-using browser agent on Anthropic's first-party agent loop." --- - + - + @@ -120,7 +120,7 @@ A run takes ~25 to 45 seconds and 3 to 6 turns. Cost is Steel session-minutes pl - + @@ -232,7 +232,7 @@ A run takes ~30 to 50 seconds and 3 to 6 turns. Cost is Steel session-minutes pl ## Related recipes - - - + + + diff --git a/content/docs/cookbook/claude-computer-use-mobile.mdx b/content/docs/cookbook/claude-computer-use-mobile.mdx index 70ea5253..6af76417 100644 --- a/content/docs/cookbook/claude-computer-use-mobile.mdx +++ b/content/docs/cookbook/claude-computer-use-mobile.mdx @@ -3,9 +3,9 @@ title: Drive a mobile browser with Claude Computer Use description: Claude Computer Use with Steel for autonomous task execution in mobile browser environments. --- - + - + diff --git a/content/docs/cookbook/claude-computer-use.mdx b/content/docs/cookbook/claude-computer-use.mdx index e898bdb3..dd02c257 100644 --- a/content/docs/cookbook/claude-computer-use.mdx +++ b/content/docs/cookbook/claude-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with Claude Computer Use description: Connect Claude to a Steel browser session for autonomous web interactions. --- - + - + - + @@ -118,7 +118,7 @@ Expect ~60-120 seconds and 15-40 iterations for a simple browsing task. - + @@ -234,6 +234,245 @@ A run typically takes 60-180 seconds and 10-30 loop iterations. + + + + + + +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) + + + + + + + + + +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 { + action: ComputerActionRequestVariant1Action::ClickMouse, + 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) + + + ## Related recipes diff --git a/content/docs/cookbook/convex-chat-with-page.mdx b/content/docs/cookbook/convex-chat-with-page.mdx index d82640cf..049d2b65 100644 --- a/content/docs/cookbook/convex-chat-with-page.mdx +++ b/content/docs/cookbook/convex-chat-with-page.mdx @@ -3,9 +3,9 @@ title: Chat with any webpage on Convex description: "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." --- - + - + @@ -111,7 +111,7 @@ The result is then chunked at paragraph boundaries into ~25k-character pieces (` ## Related recipes - - - + + + diff --git a/content/docs/cookbook/convex-price-watch.mdx b/content/docs/cookbook/convex-price-watch.mdx index 3af110bc..3abfa8dc 100644 --- a/content/docs/cookbook/convex-price-watch.mdx +++ b/content/docs/cookbook/convex-price-watch.mdx @@ -3,9 +3,9 @@ title: Watch Claude pricing for divergent A/B variants description: 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. --- - + - + @@ -103,7 +103,7 @@ const result = await steel.steel.scrape( ## Related recipes + - diff --git a/content/docs/cookbook/credentials.mdx b/content/docs/cookbook/credentials.mdx index 3aa8b9dc..615c09a2 100644 --- a/content/docs/cookbook/credentials.mdx +++ b/content/docs/cookbook/credentials.mdx @@ -3,9 +3,9 @@ title: Automate logins with the Credentials API description: Use the Steel Credentials API with Playwright to automate flows with stored credentials. --- - + - + diff --git a/content/docs/cookbook/crewai.mdx b/content/docs/cookbook/crewai.mdx index fccdb841..4f695ed3 100644 --- a/content/docs/cookbook/crewai.mdx +++ b/content/docs/cookbook/crewai.mdx @@ -3,9 +3,9 @@ title: Build a multi-agent browser workflow with CrewAI description: Integrate Steel with the CrewAI multi-agent framework. --- - + - + @@ -89,7 +89,7 @@ A run takes ~60-90 seconds. `report.md` is overwritten each run. ## Related recipes - - - + + + diff --git a/content/docs/cookbook/deep-research.mdx b/content/docs/cookbook/deep-research.mdx index cb211d61..77db3fa4 100644 --- a/content/docs/cookbook/deep-research.mdx +++ b/content/docs/cookbook/deep-research.mdx @@ -3,13 +3,13 @@ title: Deep research with Claude Agent SDK subagents description: Lead orchestrator dispatches parallel researcher subagents, each driving its own Steel browser, and synthesizes findings into a cited Markdown report. --- - + - + @@ -177,7 +177,7 @@ A run takes ~4 to 6 minutes wall-clock with 3 Steel sessions in parallel. Cost i - + @@ -350,7 +350,7 @@ A run takes ~4 to 6 minutes wall-clock with 3 Steel sessions running in parallel ## Related recipes - - - + + + diff --git a/content/docs/cookbook/eino.mdx b/content/docs/cookbook/eino.mdx new file mode 100644 index 00000000..fbe25d15 --- /dev/null +++ b/content/docs/cookbook/eino.mdx @@ -0,0 +1,110 @@ +--- +title: Build a browser agent with Eino +description: "Use Steel with the ByteDance Eino framework to build a ReAct agent that calls Steel's scrape API as a tool to research and answer a web question." +--- + + + + + + + +[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: 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 + + + + + + diff --git a/content/docs/cookbook/extensions.mdx b/content/docs/cookbook/extensions.mdx index 02489f4f..52d0bb6f 100644 --- a/content/docs/cookbook/extensions.mdx +++ b/content/docs/cookbook/extensions.mdx @@ -3,9 +3,9 @@ title: Upload and run browser extensions description: Use the Steel Extensions API with Playwright to upload and run browser extensions. --- - + - + @@ -96,5 +96,5 @@ A run takes ~20 seconds and costs a few cents of session time. First run uploads - + diff --git a/content/docs/cookbook/files.mdx b/content/docs/cookbook/files.mdx index 9cf8323a..2c9ab234 100644 --- a/content/docs/cookbook/files.mdx +++ b/content/docs/cookbook/files.mdx @@ -3,9 +3,9 @@ title: Move files between your machine and a cloud browser description: Use the Steel Files API with Playwright to automate file uploads and downloads in the cloud. --- - + - + @@ -102,5 +102,5 @@ There's also `client.files` (without `.sessions`), an organization-scoped store - + diff --git a/content/docs/cookbook/gemini-computer-use.mdx b/content/docs/cookbook/gemini-computer-use.mdx index 220eff6e..10f00ee4 100644 --- a/content/docs/cookbook/gemini-computer-use.mdx +++ b/content/docs/cookbook/gemini-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with Gemini Computer Use description: "Connect Google's Gemini Computer Use to a Steel browser session for autonomous web interactions." --- - + - + @@ -114,7 +114,7 @@ Expect roughly 60-120 seconds and 15-40 turns for a simple browsing task. - + diff --git a/content/docs/cookbook/genkit.mdx b/content/docs/cookbook/genkit.mdx new file mode 100644 index 00000000..209d50b0 --- /dev/null +++ b/content/docs/cookbook/genkit.mdx @@ -0,0 +1,117 @@ +--- +title: Build a browser agent with Genkit +description: Use Steel with Genkit Go to build a tool-calling agent that navigates and extracts from a chromedp-backed browser and completes a web task. +--- + + + + + + + +[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 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 + + + + + + diff --git a/content/docs/cookbook/google-adk.mdx b/content/docs/cookbook/google-adk.mdx new file mode 100644 index 00000000..f5bb1565 --- /dev/null +++ b/content/docs/cookbook/google-adk.mdx @@ -0,0 +1,373 @@ +--- +title: Build a browser agent with Google ADK +description: "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." +--- + + + + + + + + + + + +[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 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/) + + + + + + + + + +[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/) + + + + + + + + + +[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 + + + + + + diff --git a/content/docs/cookbook/index.mdx b/content/docs/cookbook/index.mdx index 0d6c6fda..ef15314b 100644 --- a/content/docs/cookbook/index.mdx +++ b/content/docs/cookbook/index.mdx @@ -5,6 +5,97 @@ description: Runnable recipes for using Steel with your favorite libraries and f --- + + + + + +[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 agent 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://tmc.github.io/langchaingo/docs/) cover chains, memory, and the agent types. + +## Related recipes + + + + + + diff --git a/content/docs/cookbook/langgraph.mdx b/content/docs/cookbook/langgraph.mdx index 16e24312..ff6431a0 100644 --- a/content/docs/cookbook/langgraph.mdx +++ b/content/docs/cookbook/langgraph.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with LangGraph description: Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node. --- - + - + @@ -96,7 +96,7 @@ A run takes ~20 to 40 seconds and a few cents of Steel session time plus Anthrop ## Related recipes + - diff --git a/content/docs/cookbook/magnitude.mdx b/content/docs/cookbook/magnitude.mdx index 1fe1b547..5b25b117 100644 --- a/content/docs/cookbook/magnitude.mdx +++ b/content/docs/cookbook/magnitude.mdx @@ -3,9 +3,9 @@ title: Build an AI browser agent with Magnitude description: Use Steel with Magnitude for AI-powered browser automation. --- - + - + @@ -120,7 +120,7 @@ A full run takes ~45 seconds. The `finally` block stops the agent first, then re ## Related recipes - - - + + + diff --git a/content/docs/cookbook/mastra.mdx b/content/docs/cookbook/mastra.mdx index ae3a7fc7..5792fb44 100644 --- a/content/docs/cookbook/mastra.mdx +++ b/content/docs/cookbook/mastra.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with Mastra description: Use Steel with Mastra to build a typed browser agent with the Mastra Model Router and Studio playground. --- - + - + @@ -108,7 +108,7 @@ It serves at `http://localhost:4111` and reads the `mastra` registry exported fr ## Related recipes + - diff --git a/content/docs/cookbook/microsoft-agent-framework.mdx b/content/docs/cookbook/microsoft-agent-framework.mdx index 70a19969..cc9b0ca4 100644 --- a/content/docs/cookbook/microsoft-agent-framework.mdx +++ b/content/docs/cookbook/microsoft-agent-framework.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Microsoft Agent Framework description: Use Steel with Microsoft Agent Framework 1.0 (the successor to AutoGen and Semantic Kernel) to build a tool-using browser agent. --- - + - + @@ -98,7 +98,7 @@ A run takes ~20 to 40 seconds and 5 to 10 agent turns on GitHub Trending. Cost i ## Related recipes - - - + + + diff --git a/content/docs/cookbook/notte.mdx b/content/docs/cookbook/notte.mdx index e4514b9d..58d47b62 100644 --- a/content/docs/cookbook/notte.mdx +++ b/content/docs/cookbook/notte.mdx @@ -3,9 +3,9 @@ title: "Control a browser with Notte's reasoning engine" description: "Control browsers with AI using Steel's infrastructure and Notte's reasoning engine." --- - + - + @@ -81,7 +81,7 @@ A default run takes ~25 seconds. The `finally` block calls `client.sessions.rele ## Related recipes - - - + + + diff --git a/content/docs/cookbook/openai-agents.mdx b/content/docs/cookbook/openai-agents.mdx index 10bf11ba..a56be739 100644 --- a/content/docs/cookbook/openai-agents.mdx +++ b/content/docs/cookbook/openai-agents.mdx @@ -3,13 +3,13 @@ title: Build a typed browser agent with the OpenAI Agents SDK description: Use Steel with the OpenAI Agents SDK for TypeScript to build typed, tool-using browser agents. --- - + - + @@ -96,7 +96,7 @@ A full run is ~20-40 seconds. Cost is a few cents of Steel session time plus Ope - + @@ -186,7 +186,7 @@ The Agents SDK ships [tracing](https://openai.github.io/openai-agents-python/tra ## Related recipes + - diff --git a/content/docs/cookbook/openai-computer-use.mdx b/content/docs/cookbook/openai-computer-use.mdx index 9f61d9e0..151a34bf 100644 --- a/content/docs/cookbook/openai-computer-use.mdx +++ b/content/docs/cookbook/openai-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with OpenAI Computer Use description: "Connect OpenAI's Computer Use Assistant to a Steel browser session for autonomous web interactions." --- - + - + - + @@ -139,7 +139,7 @@ Expect roughly 60-120 seconds and 15-40 turns for a simple browsing task. - + @@ -263,6 +263,222 @@ A run typically takes 60-180 seconds and 10-30 iterations. Screenshots are cache + + + + + + +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.ComputerActionRequestVariant1ActionClickMouse, + 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) + + + + + + + + + +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 = 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. + + + ## Related recipes diff --git a/content/docs/cookbook/playwright.mdx b/content/docs/cookbook/playwright.mdx index 245def15..76b99cd5 100644 --- a/content/docs/cookbook/playwright.mdx +++ b/content/docs/cookbook/playwright.mdx @@ -3,13 +3,13 @@ title: Automate a cloud browser with Playwright description: Use Steel with Playwright in TypeScript for cloud browser automation. --- - + - + @@ -81,7 +81,7 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the - + @@ -159,7 +159,7 @@ One run costs a few cents of session time. Steel bills per session-minute, which ## Related recipes - - - + + + diff --git a/content/docs/cookbook/profiles.mdx b/content/docs/cookbook/profiles.mdx index b020bb04..ff735dd0 100644 --- a/content/docs/cookbook/profiles.mdx +++ b/content/docs/cookbook/profiles.mdx @@ -3,9 +3,9 @@ title: Persist authenticated sessions with Profiles description: Maintain authenticated sessions across Steel browser instances using profiles. --- - + - + @@ -122,5 +122,5 @@ Three recipes handle "start the browser already signed in." Pick by lifetime: - + diff --git a/content/docs/cookbook/puppeteer.mdx b/content/docs/cookbook/puppeteer.mdx index cd04678f..ca88138e 100644 --- a/content/docs/cookbook/puppeteer.mdx +++ b/content/docs/cookbook/puppeteer.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with Puppeteer description: Use Steel with Puppeteer in TypeScript for cloud browser automation. --- - + - + @@ -76,7 +76,7 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the ## Related recipes - - - + + + diff --git a/content/docs/cookbook/pydantic-ai.mdx b/content/docs/cookbook/pydantic-ai.mdx index 84aeb77f..a1e57aee 100644 --- a/content/docs/cookbook/pydantic-ai.mdx +++ b/content/docs/cookbook/pydantic-ai.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with Pydantic AI description: Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection. --- - + - + @@ -104,7 +104,7 @@ A run takes ~20 to 40 seconds and 5 to 10 agent turns on GitHub Trending. Cost i ## Related recipes + - diff --git a/content/docs/cookbook/rig.mdx b/content/docs/cookbook/rig.mdx new file mode 100644 index 00000000..58623f65 --- /dev/null +++ b/content/docs/cookbook/rig.mdx @@ -0,0 +1,106 @@ +--- +title: Build a browser agent with rig +description: 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. +--- + + + + + + + +[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 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.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 + + + + + + diff --git a/content/docs/cookbook/rod.mdx b/content/docs/cookbook/rod.mdx new file mode 100644 index 00000000..5ffac790 --- /dev/null +++ b/content/docs/cookbook/rod.mdx @@ -0,0 +1,122 @@ +--- +title: Automate a cloud browser with go-rod +description: "Use Steel with go-rod's fluent, chainable API to connect over CDP and scrape quotes.toscrape.com from a cloud browser." +--- + + + + + + + +go-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 go-rod the session's websocket URL with your key appended, and the rest of your code is ordinary go-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. + +```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. go-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 go-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 go-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: go-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 go-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 [go-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 + + + + + + diff --git a/content/docs/cookbook/scrape.mdx b/content/docs/cookbook/scrape.mdx new file mode 100644 index 00000000..9e569fd3 --- /dev/null +++ b/content/docs/cookbook/scrape.mdx @@ -0,0 +1,347 @@ +--- +title: Scrape a page to Markdown, screenshot, and PDF +description: "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." +--- + + + + + + + + + + + +`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. 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 `
` 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). + + + + + + + + + +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). + + + + + + + + + +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. + + + + + + + + + +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 +scraped.content.markdown; // Option +``` + +`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). + + + + + +## Related recipes + + + + + + diff --git a/content/docs/cookbook/selenium.mdx b/content/docs/cookbook/selenium.mdx index b2fba364..d89a78fe 100644 --- a/content/docs/cookbook/selenium.mdx +++ b/content/docs/cookbook/selenium.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with Selenium description: Use Steel with Selenium in Python for cloud browser automation. --- - + - + @@ -99,7 +99,7 @@ A run costs a few cents of session time. Steel bills per session-minute, so `mai ## Related recipes - - - + + + diff --git a/content/docs/cookbook/stagehand.mdx b/content/docs/cookbook/stagehand.mdx index 32287ea1..a928eaf9 100644 --- a/content/docs/cookbook/stagehand.mdx +++ b/content/docs/cookbook/stagehand.mdx @@ -3,13 +3,13 @@ title: Automate browsing with natural-language instructions using Stagehand description: Use Steel with Stagehand for natural-language-driven AI browser automation. --- - + - + @@ -105,7 +105,7 @@ A full run takes ~30 seconds and costs a few cents of Steel session time plus Op - + @@ -250,7 +250,7 @@ A full run takes ~30 seconds. The `finally` block in `main()` calls `stagehand.s ## Related recipes - - - + + + diff --git a/content/docs/cookbook/swiftide.mdx b/content/docs/cookbook/swiftide.mdx new file mode 100644 index 00000000..eabc8154 --- /dev/null +++ b/content/docs/cookbook/swiftide.mdx @@ -0,0 +1,108 @@ +--- +title: Build a research agent with Swiftide +description: "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." +--- + + + + + + + +[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 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, +} + +impl ReadPage { + async fn read_page(&self, _ctx: &dyn AgentContext, url: &str) -> Result { + 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`; 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 + + + + + + diff --git a/content/docs/cookbook/topics/agents.mdx b/content/docs/cookbook/topics/agents.mdx index 58f04cf8..fee1c702 100644 --- a/content/docs/cookbook/topics/agents.mdx +++ b/content/docs/cookbook/topics/agents.mdx @@ -4,6 +4,12 @@ description: Agent frameworks that run a perception-plan-act loop against a Stee --- + + + + + + diff --git a/content/docs/cookbook/topics/browser-automation.mdx b/content/docs/cookbook/topics/browser-automation.mdx index cca555d4..b212b3ce 100644 --- a/content/docs/cookbook/topics/browser-automation.mdx +++ b/content/docs/cookbook/topics/browser-automation.mdx @@ -4,6 +4,9 @@ description: "Drive a cloud browser with familiar automation libraries: Playwrig --- + + + diff --git a/content/docs/cookbook/topics/steel-apis.mdx b/content/docs/cookbook/topics/steel-apis.mdx index 17c0c503..1b4b8f43 100644 --- a/content/docs/cookbook/topics/steel-apis.mdx +++ b/content/docs/cookbook/topics/steel-apis.mdx @@ -4,6 +4,7 @@ description: "Recipes for Steel's first-party APIs: credentials, auth contexts, --- + diff --git a/content/docs/cookbook/topics/typed-output.mdx b/content/docs/cookbook/topics/typed-output.mdx index bf9172ca..c2843578 100644 --- a/content/docs/cookbook/topics/typed-output.mdx +++ b/content/docs/cookbook/topics/typed-output.mdx @@ -4,6 +4,7 @@ description: Agents that return structured, schema-validated results instead of --- + diff --git a/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx b/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx index 2a9391e3..0f98c8d6 100644 --- a/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx +++ b/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx @@ -3,9 +3,9 @@ title: Stream a browser agent into a Next.js chat app description: A Next.js App Router chat app where a Vercel AI SDK agent drives a Steel cloud browser with embedded Live View. --- - + - + @@ -90,7 +90,7 @@ The `/api/chat` route already declares `maxDuration = 120` and `runtime = "nodej ## Related recipes + - diff --git a/content/docs/cookbook/vercel-ai-sdk.mdx b/content/docs/cookbook/vercel-ai-sdk.mdx index 8a77e98d..b793dee5 100644 --- a/content/docs/cookbook/vercel-ai-sdk.mdx +++ b/content/docs/cookbook/vercel-ai-sdk.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with the Vercel AI SDK description: Use Steel with the Vercel AI SDK v6 ToolLoopAgent for typed, tool-using browser agents. --- - + - + @@ -83,7 +83,7 @@ A full run takes ~20 seconds and costs a few cents of Steel session time plus a ## Related recipes + - diff --git a/content/docs/cookbook/you-com-search.mdx b/content/docs/cookbook/you-com-search.mdx index 28e64476..a61302dc 100644 --- a/content/docs/cookbook/you-com-search.mdx +++ b/content/docs/cookbook/you-com-search.mdx @@ -3,9 +3,9 @@ title: Combine You.com search with Steel browser actions description: 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. --- - + - + @@ -133,7 +133,7 @@ If the agent answers from search and contents alone, the `open_session`, `naviga ## Related recipes - - - + + + diff --git a/cookbook.lock.json b/cookbook.lock.json index e0a65a9d..be354c20 100644 --- a/cookbook.lock.json +++ b/cookbook.lock.json @@ -1,5 +1,5 @@ { "repo": "steel-dev/steel-cookbook", "ref": "main", - "sha": "92f29742253e2b6c6801d109e18232768e5291a0" + "sha": "c506b2f6f7f97fa5bd47a616eac5315c9b9b1d25" } diff --git a/lib/remark-format-code.ts b/lib/remark-format-code.ts index 976e7ef3..106f3ada 100644 --- a/lib/remark-format-code.ts +++ b/lib/remark-format-code.ts @@ -52,6 +52,9 @@ function shouldFormatLanguage(lang: string): boolean { 'python', 'py', 'json', + 'go', + 'rust', + 'rs', // Add more languages as needed ]; diff --git a/scripts/sync-cookbook.ts b/scripts/sync-cookbook.ts index bc9780ea..085bbf04 100644 --- a/scripts/sync-cookbook.ts +++ b/scripts/sync-cookbook.ts @@ -53,7 +53,7 @@ const TOPIC_DESCRIPTIONS: Record = { // Display order for language tabs in merged concept pages. Entries not // listed fall back to the end in insertion order. -const LANGUAGE_ORDER: string[] = ['TypeScript', 'Python', 'Next.js']; +const LANGUAGE_ORDER: string[] = ['TypeScript', 'Python', 'Next.js', 'Go', 'Rust']; interface CookbookLock { repo: string; // "owner/name" on GitHub From fdd0cb0f9dffc8e416fd1c58ff47e16b247f4290 Mon Sep 17 00:00:00 2001 From: junhsss Date: Wed, 24 Jun 2026 02:40:05 +0900 Subject: [PATCH 02/11] chore: bump cookbook version --- content/docs/cookbook/agentkit.mdx | 4 +- content/docs/cookbook/agno.mdx | 4 +- content/docs/cookbook/auth-context.mdx | 209 +++++++++++++- content/docs/cookbook/authors/junhsss.mdx | 3 +- .../cookbook/browser-use-captcha-auto.mdx | 4 +- .../cookbook/browser-use-captcha-manual.mdx | 4 +- content/docs/cookbook/browser-use.mdx | 4 +- content/docs/cookbook/chromedp.mdx | 4 +- content/docs/cookbook/chromiumoxide.mdx | 4 +- content/docs/cookbook/claude-agent-sdk.mdx | 6 +- .../cookbook/claude-computer-use-mobile.mdx | 4 +- content/docs/cookbook/claude-computer-use.mdx | 10 +- .../docs/cookbook/convex-chat-with-page.mdx | 4 +- content/docs/cookbook/convex-price-watch.mdx | 4 +- content/docs/cookbook/credentials.mdx | 238 +++++++++++++++- content/docs/cookbook/crewai.mdx | 4 +- content/docs/cookbook/deep-research.mdx | 6 +- content/docs/cookbook/eino.mdx | 4 +- content/docs/cookbook/extensions.mdx | 234 +++++++++++++++- content/docs/cookbook/files.mdx | 264 +++++++++++++++++- content/docs/cookbook/gemini-computer-use.mdx | 188 ++++++++++++- content/docs/cookbook/genkit.mdx | 4 +- content/docs/cookbook/google-adk.mdx | 8 +- content/docs/cookbook/langchaingo.mdx | 4 +- content/docs/cookbook/langgraph.mdx | 4 +- content/docs/cookbook/magnitude.mdx | 4 +- content/docs/cookbook/mastra.mdx | 4 +- .../cookbook/microsoft-agent-framework.mdx | 4 +- content/docs/cookbook/notte.mdx | 4 +- content/docs/cookbook/openai-agents.mdx | 6 +- content/docs/cookbook/openai-computer-use.mdx | 10 +- content/docs/cookbook/playwright.mdx | 6 +- content/docs/cookbook/profiles.mdx | 255 ++++++++++++++++- content/docs/cookbook/puppeteer.mdx | 4 +- content/docs/cookbook/pydantic-ai.mdx | 4 +- content/docs/cookbook/rig.mdx | 4 +- content/docs/cookbook/rod.mdx | 4 +- content/docs/cookbook/scrape.mdx | 10 +- content/docs/cookbook/selenium.mdx | 4 +- content/docs/cookbook/stagehand.mdx | 6 +- content/docs/cookbook/swiftide.mdx | 4 +- .../docs/cookbook/vercel-ai-sdk-nextjs.mdx | 4 +- content/docs/cookbook/vercel-ai-sdk.mdx | 4 +- content/docs/cookbook/you-com-search.mdx | 4 +- cookbook.lock.json | 2 +- 45 files changed, 1457 insertions(+), 116 deletions(-) diff --git a/content/docs/cookbook/agentkit.mdx b/content/docs/cookbook/agentkit.mdx index 4cf83ec9..8a3d7731 100644 --- a/content/docs/cookbook/agentkit.mdx +++ b/content/docs/cookbook/agentkit.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Inngest AgentKit description: "Integrate Steel with Inngest's AgentKit framework." --- - + - + diff --git a/content/docs/cookbook/agno.mdx b/content/docs/cookbook/agno.mdx index 5ec04b7c..10697865 100644 --- a/content/docs/cookbook/agno.mdx +++ b/content/docs/cookbook/agno.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Agno description: Integrate Steel with the Agno agent framework. --- - + - + diff --git a/content/docs/cookbook/auth-context.mdx b/content/docs/cookbook/auth-context.mdx index 4b59c95a..121186cd 100644 --- a/content/docs/cookbook/auth-context.mdx +++ b/content/docs/cookbook/auth-context.mdx @@ -3,11 +3,15 @@ title: Reuse authenticated sessions across browsers description: Maintain authenticated sessions across Steel browser instances by capturing and reusing cookies and local storage. --- - + - + - + + + + + 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: @@ -34,7 +38,7 @@ The second session is a brand new browser on Steel's fleet. It has the auth stat ## Run it ```bash -cd examples/auth-context +cd examples/auth-context-ts cp .env.example .env # set STEEL_API_KEY npm install npm start @@ -87,6 +91,203 @@ If you want Steel to store credentials and handle the login itself, see [credent [credentials](/cookbook/credentials) · [Playwright docs](https://playwright.dev) + + + + + + + + +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/). + + + + + + + + + +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) + + + + + + + + + +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`), but `SessionCreateParams::session_context` wants a `SessionCreateParamsSessionContext` (cookies are `Vec`). 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) + + + + + ## Related recipes diff --git a/content/docs/cookbook/authors/junhsss.mdx b/content/docs/cookbook/authors/junhsss.mdx index 9382050f..351be22f 100644 --- a/content/docs/cookbook/authors/junhsss.mdx +++ b/content/docs/cookbook/authors/junhsss.mdx @@ -1,6 +1,6 @@ --- title: Jun Ryu -description: 37 recipes contributed to the Steel Cookbook by Jun Ryu. +description: 38 recipes contributed to the Steel Cookbook by Jun Ryu. --- @@ -27,6 +27,7 @@ description: 37 recipes contributed to the Steel Cookbook by Jun Ryu. + diff --git a/content/docs/cookbook/browser-use-captcha-auto.mdx b/content/docs/cookbook/browser-use-captcha-auto.mdx index c430191e..667d0078 100644 --- a/content/docs/cookbook/browser-use-captcha-auto.mdx +++ b/content/docs/cookbook/browser-use-captcha-auto.mdx @@ -3,9 +3,9 @@ title: Solve CAPTCHAs automatically in a Browser Use agent description: Build an AI agent with browser-use and Steel that solves CAPTCHAs automatically. --- - + - + diff --git a/content/docs/cookbook/browser-use-captcha-manual.mdx b/content/docs/cookbook/browser-use-captcha-manual.mdx index 336b52b1..503ed71a 100644 --- a/content/docs/cookbook/browser-use-captcha-manual.mdx +++ b/content/docs/cookbook/browser-use-captcha-manual.mdx @@ -3,9 +3,9 @@ title: Solve reCAPTCHA v2 manually with Browser Use description: "Manually solve reCAPTCHA v2 using Steel's CAPTCHA API with the browser-use framework." --- - + - + diff --git a/content/docs/cookbook/browser-use.mdx b/content/docs/cookbook/browser-use.mdx index 258ce3c5..ebf2f6b4 100644 --- a/content/docs/cookbook/browser-use.mdx +++ b/content/docs/cookbook/browser-use.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Browser Use description: Integrate Steel with the browser-use framework for AI-driven web automation. --- - + - + diff --git a/content/docs/cookbook/chromedp.mdx b/content/docs/cookbook/chromedp.mdx index d04398d6..bd3f46e2 100644 --- a/content/docs/cookbook/chromedp.mdx +++ b/content/docs/cookbook/chromedp.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with chromedp description: Use Steel with chromedp to connect over CDP, navigate to Hacker News, extract the top stories, and capture a screenshot. --- - + - + diff --git a/content/docs/cookbook/chromiumoxide.mdx b/content/docs/cookbook/chromiumoxide.mdx index a8f25fc1..df465b74 100644 --- a/content/docs/cookbook/chromiumoxide.mdx +++ b/content/docs/cookbook/chromiumoxide.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with chromiumoxide description: Use Steel with chromiumoxide to connect over CDP, drive the handler task, extract page content, and capture a screenshot. --- - + - + diff --git a/content/docs/cookbook/claude-agent-sdk.mdx b/content/docs/cookbook/claude-agent-sdk.mdx index dfcf246b..c8f0d381 100644 --- a/content/docs/cookbook/claude-agent-sdk.mdx +++ b/content/docs/cookbook/claude-agent-sdk.mdx @@ -3,13 +3,13 @@ title: Build a browser agent with the Claude Agent SDK description: "Use Steel with the Claude Agent SDK (TypeScript) to build a tool-using browser agent on Anthropic's first-party agent loop." --- - + - + @@ -120,7 +120,7 @@ A run takes ~25 to 45 seconds and 3 to 6 turns. Cost is Steel session-minutes pl - + diff --git a/content/docs/cookbook/claude-computer-use-mobile.mdx b/content/docs/cookbook/claude-computer-use-mobile.mdx index 6af76417..f0946144 100644 --- a/content/docs/cookbook/claude-computer-use-mobile.mdx +++ b/content/docs/cookbook/claude-computer-use-mobile.mdx @@ -3,9 +3,9 @@ title: Drive a mobile browser with Claude Computer Use description: Claude Computer Use with Steel for autonomous task execution in mobile browser environments. --- - + - + diff --git a/content/docs/cookbook/claude-computer-use.mdx b/content/docs/cookbook/claude-computer-use.mdx index dd02c257..af909bb9 100644 --- a/content/docs/cookbook/claude-computer-use.mdx +++ b/content/docs/cookbook/claude-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with Claude Computer Use description: Connect Claude to a Steel browser session for autonomous web interactions. --- - + - + @@ -118,7 +118,7 @@ Expect ~60-120 seconds and 15-40 iterations for a simple browsing task. - + @@ -236,7 +236,7 @@ A run typically takes 60-180 seconds and 10-30 loop iterations. - + @@ -355,7 +355,7 @@ Expect roughly 60 to 180 seconds and 10 to 40 loop iterations for a simple brows - + diff --git a/content/docs/cookbook/convex-chat-with-page.mdx b/content/docs/cookbook/convex-chat-with-page.mdx index 049d2b65..32e776f2 100644 --- a/content/docs/cookbook/convex-chat-with-page.mdx +++ b/content/docs/cookbook/convex-chat-with-page.mdx @@ -3,9 +3,9 @@ title: Chat with any webpage on Convex description: "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." --- - + - + diff --git a/content/docs/cookbook/convex-price-watch.mdx b/content/docs/cookbook/convex-price-watch.mdx index 3abfa8dc..1ea38a82 100644 --- a/content/docs/cookbook/convex-price-watch.mdx +++ b/content/docs/cookbook/convex-price-watch.mdx @@ -3,9 +3,9 @@ title: Watch Claude pricing for divergent A/B variants description: 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. --- - + - + diff --git a/content/docs/cookbook/credentials.mdx b/content/docs/cookbook/credentials.mdx index 615c09a2..65677037 100644 --- a/content/docs/cookbook/credentials.mdx +++ b/content/docs/cookbook/credentials.mdx @@ -3,11 +3,15 @@ title: Automate logins with the Credentials API description: Use the Steel Credentials API with Playwright to automate flows with stored credentials. --- - + - + - + + + + + 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. @@ -50,7 +54,7 @@ Credentials are per-origin. Create one per site you automate. Re-calling `creden ## Run it ```bash -cd examples/credentials +cd examples/credentials-ts cp .env.example .env # set STEEL_API_KEY npm install npm start @@ -94,6 +98,232 @@ Reach for credentials when you want a stable, long-lived setup tied to an accoun [auth-context](/cookbook/auth-context) (cookie and localStorage replay) · [Playwright docs](https://playwright.dev) + + + + + + + + +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/) + + + + + + + + + +The automation in `main.go` never types a username or a password. It navigates to a site, clicks the login link, and reads the heading to confirm it is signed in. 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. + +## The two-second wait + +After `chromedp.Click("#AccountLink", ...)` the script does `chromedp.Sleep(2 * time.Second)` before reading the `h1`. That window lets Steel detect the form, fill it, and let the page settle on the post-login view. A fixed sleep keeps the demo short. In production, prefer a deterministic wait such as `chromedp.WaitVisible` on an element that only exists once you are signed in. + +Re-running `Credentials.Create` for an origin that already has a stored credential returns an error whose message contains `Credential 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... +Navigating to the demo site... +Success, you are logged in +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 `chromedp.Navigate` and the `#AccountLink` click at the new login trigger. 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) + + + + + + + + + +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("Credential 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: navigate to the Altoro Mutual test site, click `#AccountLink` to surface the login form, give Steel a couple of seconds to fill and submit, then read the `h1`. "Hello Admin User" means the fill worked. In production you would replace the fixed `sleep` with a wait on a post-login selector. + +## 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... +Connected over CDP, opening https://demo.testfire.net... +Success, you are logged in +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 `new_page` and the trigger click 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. + + + + + ## Related recipes diff --git a/content/docs/cookbook/crewai.mdx b/content/docs/cookbook/crewai.mdx index 4f695ed3..0dfbb9dc 100644 --- a/content/docs/cookbook/crewai.mdx +++ b/content/docs/cookbook/crewai.mdx @@ -3,9 +3,9 @@ title: Build a multi-agent browser workflow with CrewAI description: Integrate Steel with the CrewAI multi-agent framework. --- - + - + diff --git a/content/docs/cookbook/deep-research.mdx b/content/docs/cookbook/deep-research.mdx index 77db3fa4..98a4f3a3 100644 --- a/content/docs/cookbook/deep-research.mdx +++ b/content/docs/cookbook/deep-research.mdx @@ -3,13 +3,13 @@ title: Deep research with Claude Agent SDK subagents description: Lead orchestrator dispatches parallel researcher subagents, each driving its own Steel browser, and synthesizes findings into a cited Markdown report. --- - + - + @@ -177,7 +177,7 @@ A run takes ~4 to 6 minutes wall-clock with 3 Steel sessions in parallel. Cost i - + diff --git a/content/docs/cookbook/eino.mdx b/content/docs/cookbook/eino.mdx index fbe25d15..502b0313 100644 --- a/content/docs/cookbook/eino.mdx +++ b/content/docs/cookbook/eino.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Eino description: "Use Steel with the ByteDance Eino framework to build a ReAct agent that calls Steel's scrape API as a tool to research and answer a web question." --- - + - + diff --git a/content/docs/cookbook/extensions.mdx b/content/docs/cookbook/extensions.mdx index 52d0bb6f..d1587c51 100644 --- a/content/docs/cookbook/extensions.mdx +++ b/content/docs/cookbook/extensions.mdx @@ -3,11 +3,15 @@ title: Upload and run browser extensions description: Use the Steel Extensions API with Playwright to upload and run browser extensions. --- - + - + - + + + + + Steel sessions launch a clean Chrome with nothing installed. The Extensions API 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. @@ -32,7 +36,7 @@ The demo loads [GitHub Isometric Contributions](https://chromewebstore.google.co ## Run it ```bash -cd examples/extensions +cd examples/extensions-ts cp .env.example .env # set STEEL_API_KEY npm install npm start @@ -91,6 +95,228 @@ A run takes ~20 seconds and costs a few cents of session time. First run uploads [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) + + + + + + + + +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) + + + + + + + + + +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) + + + + + + + + + +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. + + + + + ## Related recipes diff --git a/content/docs/cookbook/files.mdx b/content/docs/cookbook/files.mdx index 2c9ab234..1f31a1aa 100644 --- a/content/docs/cookbook/files.mdx +++ b/content/docs/cookbook/files.mdx @@ -3,11 +3,15 @@ title: Move files between your machine and a cloud browser description: Use the Steel Files API with Playwright to automate file uploads and downloads in the cloud. --- - + - + - + + + + + 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 `` over CDP, and lets the browser render a chart against it. @@ -43,7 +47,7 @@ await cdpSession.send("DOM.setFileInputFiles", { ## Run it ```bash -cd examples/files-api +cd examples/files-ts cp .env.example .env # set STEEL_API_KEY npm install npm start @@ -97,6 +101,258 @@ There's also `client.files` (without `.sessions`), an organization-scoped store [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. + + + + + + + + +`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 `` 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/). + + + + + + + + + +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 `` 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. + + + + + + + + + +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 `` 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`. + + + + + ## Related recipes diff --git a/content/docs/cookbook/gemini-computer-use.mdx b/content/docs/cookbook/gemini-computer-use.mdx index 10f00ee4..b8efc954 100644 --- a/content/docs/cookbook/gemini-computer-use.mdx +++ b/content/docs/cookbook/gemini-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with Gemini Computer Use description: "Connect Google's Gemini Computer Use to a Steel browser session for autonomous web interactions." --- - + - + - + @@ -114,7 +114,7 @@ Expect roughly 60-120 seconds and 15-40 turns for a simple browsing task. - + @@ -235,6 +235,186 @@ A run typically takes 60-180 seconds and 10-30 iterations. Because `generate_con + + + + + + +`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) + + + + + + + + + +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) + + + ## Related recipes diff --git a/content/docs/cookbook/genkit.mdx b/content/docs/cookbook/genkit.mdx index 209d50b0..a84d3a51 100644 --- a/content/docs/cookbook/genkit.mdx +++ b/content/docs/cookbook/genkit.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Genkit description: Use Steel with Genkit Go to build a tool-calling agent that navigates and extracts from a chromedp-backed browser and completes a web task. --- - + - + diff --git a/content/docs/cookbook/google-adk.mdx b/content/docs/cookbook/google-adk.mdx index f5bb1565..4484ecd7 100644 --- a/content/docs/cookbook/google-adk.mdx +++ b/content/docs/cookbook/google-adk.mdx @@ -3,13 +3,13 @@ title: Build a browser agent with Google ADK description: "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." --- - + - + @@ -114,7 +114,7 @@ This agent has no `outputSchema`. ADK disables tool calls when an output schema - + @@ -250,7 +250,7 @@ A run takes ~20 to 40 seconds and a handful of agent turns on Hacker News. Cost - + diff --git a/content/docs/cookbook/langchaingo.mdx b/content/docs/cookbook/langchaingo.mdx index e6eb7593..3aca7b98 100644 --- a/content/docs/cookbook/langchaingo.mdx +++ b/content/docs/cookbook/langchaingo.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with LangChainGo description: "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." --- - + - + diff --git a/content/docs/cookbook/langgraph.mdx b/content/docs/cookbook/langgraph.mdx index ff6431a0..3ccd0e28 100644 --- a/content/docs/cookbook/langgraph.mdx +++ b/content/docs/cookbook/langgraph.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with LangGraph description: Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node. --- - + - + diff --git a/content/docs/cookbook/magnitude.mdx b/content/docs/cookbook/magnitude.mdx index 5b25b117..beaa0597 100644 --- a/content/docs/cookbook/magnitude.mdx +++ b/content/docs/cookbook/magnitude.mdx @@ -3,9 +3,9 @@ title: Build an AI browser agent with Magnitude description: Use Steel with Magnitude for AI-powered browser automation. --- - + - + diff --git a/content/docs/cookbook/mastra.mdx b/content/docs/cookbook/mastra.mdx index 5792fb44..cdd926bf 100644 --- a/content/docs/cookbook/mastra.mdx +++ b/content/docs/cookbook/mastra.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with Mastra description: Use Steel with Mastra to build a typed browser agent with the Mastra Model Router and Studio playground. --- - + - + diff --git a/content/docs/cookbook/microsoft-agent-framework.mdx b/content/docs/cookbook/microsoft-agent-framework.mdx index cc9b0ca4..a6e65071 100644 --- a/content/docs/cookbook/microsoft-agent-framework.mdx +++ b/content/docs/cookbook/microsoft-agent-framework.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Microsoft Agent Framework description: Use Steel with Microsoft Agent Framework 1.0 (the successor to AutoGen and Semantic Kernel) to build a tool-using browser agent. --- - + - + diff --git a/content/docs/cookbook/notte.mdx b/content/docs/cookbook/notte.mdx index 58d47b62..b2e84695 100644 --- a/content/docs/cookbook/notte.mdx +++ b/content/docs/cookbook/notte.mdx @@ -3,9 +3,9 @@ title: "Control a browser with Notte's reasoning engine" description: "Control browsers with AI using Steel's infrastructure and Notte's reasoning engine." --- - + - + diff --git a/content/docs/cookbook/openai-agents.mdx b/content/docs/cookbook/openai-agents.mdx index a56be739..efce7b1b 100644 --- a/content/docs/cookbook/openai-agents.mdx +++ b/content/docs/cookbook/openai-agents.mdx @@ -3,13 +3,13 @@ title: Build a typed browser agent with the OpenAI Agents SDK description: Use Steel with the OpenAI Agents SDK for TypeScript to build typed, tool-using browser agents. --- - + - + @@ -96,7 +96,7 @@ A full run is ~20-40 seconds. Cost is a few cents of Steel session time plus Ope - + diff --git a/content/docs/cookbook/openai-computer-use.mdx b/content/docs/cookbook/openai-computer-use.mdx index 151a34bf..2ac12712 100644 --- a/content/docs/cookbook/openai-computer-use.mdx +++ b/content/docs/cookbook/openai-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with OpenAI Computer Use description: "Connect OpenAI's Computer Use Assistant to a Steel browser session for autonomous web interactions." --- - + - + @@ -139,7 +139,7 @@ Expect roughly 60-120 seconds and 15-40 turns for a simple browsing task. - + @@ -265,7 +265,7 @@ A run typically takes 60-180 seconds and 10-30 iterations. Screenshots are cache - + @@ -386,7 +386,7 @@ The published OpenAI Python and TypeScript computer-use recipes target the `{"ty - + diff --git a/content/docs/cookbook/playwright.mdx b/content/docs/cookbook/playwright.mdx index 76b99cd5..b0315d17 100644 --- a/content/docs/cookbook/playwright.mdx +++ b/content/docs/cookbook/playwright.mdx @@ -3,13 +3,13 @@ title: Automate a cloud browser with Playwright description: Use Steel with Playwright in TypeScript for cloud browser automation. --- - + - + @@ -81,7 +81,7 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the - + diff --git a/content/docs/cookbook/profiles.mdx b/content/docs/cookbook/profiles.mdx index ff735dd0..9e746a31 100644 --- a/content/docs/cookbook/profiles.mdx +++ b/content/docs/cookbook/profiles.mdx @@ -3,11 +3,15 @@ title: Persist authenticated sessions with Profiles description: Maintain authenticated sessions across Steel browser instances using profiles. --- - + - + - + + + + + 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. @@ -46,7 +50,7 @@ Select the saved profile on a later run and the menu skips step 2 entirely. One ## Run it ```bash -cd examples/profiles +cd examples/profiles-ts cp .env.example .env # set STEEL_API_KEY npm install npm start @@ -117,6 +121,249 @@ Three recipes handle "start the browser already signed in." Pick by lifetime: [Playwright docs](https://playwright.dev) + + + + + + + + +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. + + + + + + + + + +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) + + + + + + + + + +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) + + + + + ## Related recipes diff --git a/content/docs/cookbook/puppeteer.mdx b/content/docs/cookbook/puppeteer.mdx index ca88138e..c3c593aa 100644 --- a/content/docs/cookbook/puppeteer.mdx +++ b/content/docs/cookbook/puppeteer.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with Puppeteer description: Use Steel with Puppeteer in TypeScript for cloud browser automation. --- - + - + diff --git a/content/docs/cookbook/pydantic-ai.mdx b/content/docs/cookbook/pydantic-ai.mdx index a1e57aee..44a68080 100644 --- a/content/docs/cookbook/pydantic-ai.mdx +++ b/content/docs/cookbook/pydantic-ai.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with Pydantic AI description: Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection. --- - + - + diff --git a/content/docs/cookbook/rig.mdx b/content/docs/cookbook/rig.mdx index 58623f65..56f27186 100644 --- a/content/docs/cookbook/rig.mdx +++ b/content/docs/cookbook/rig.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with rig description: 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. --- - + - + diff --git a/content/docs/cookbook/rod.mdx b/content/docs/cookbook/rod.mdx index 5ffac790..e7d60fb2 100644 --- a/content/docs/cookbook/rod.mdx +++ b/content/docs/cookbook/rod.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with go-rod description: "Use Steel with go-rod's fluent, chainable API to connect over CDP and scrape quotes.toscrape.com from a cloud browser." --- - + - + diff --git a/content/docs/cookbook/scrape.mdx b/content/docs/cookbook/scrape.mdx index 9e569fd3..9e1f42f3 100644 --- a/content/docs/cookbook/scrape.mdx +++ b/content/docs/cookbook/scrape.mdx @@ -3,13 +3,13 @@ title: Scrape a page to Markdown, screenshot, and PDF description: "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." --- - + - + @@ -116,7 +116,7 @@ Each of the three calls is one billed request against Steel, so a full run costs - + @@ -185,7 +185,7 @@ The other recipes in the cookbook connect a browser library (Playwright, Seleniu - + @@ -264,7 +264,7 @@ A scrape call costs a few cents of browser time. Steel starts and tears down the - + diff --git a/content/docs/cookbook/selenium.mdx b/content/docs/cookbook/selenium.mdx index d89a78fe..c4aae33c 100644 --- a/content/docs/cookbook/selenium.mdx +++ b/content/docs/cookbook/selenium.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with Selenium description: Use Steel with Selenium in Python for cloud browser automation. --- - + - + diff --git a/content/docs/cookbook/stagehand.mdx b/content/docs/cookbook/stagehand.mdx index a928eaf9..523d3e64 100644 --- a/content/docs/cookbook/stagehand.mdx +++ b/content/docs/cookbook/stagehand.mdx @@ -3,13 +3,13 @@ title: Automate browsing with natural-language instructions using Stagehand description: Use Steel with Stagehand for natural-language-driven AI browser automation. --- - + - + @@ -105,7 +105,7 @@ A full run takes ~30 seconds and costs a few cents of Steel session time plus Op - + diff --git a/content/docs/cookbook/swiftide.mdx b/content/docs/cookbook/swiftide.mdx index eabc8154..920798ff 100644 --- a/content/docs/cookbook/swiftide.mdx +++ b/content/docs/cookbook/swiftide.mdx @@ -3,9 +3,9 @@ title: Build a research agent with Swiftide description: "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." --- - + - + diff --git a/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx b/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx index 0f98c8d6..7b87a48c 100644 --- a/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx +++ b/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx @@ -3,9 +3,9 @@ title: Stream a browser agent into a Next.js chat app description: A Next.js App Router chat app where a Vercel AI SDK agent drives a Steel cloud browser with embedded Live View. --- - + - + diff --git a/content/docs/cookbook/vercel-ai-sdk.mdx b/content/docs/cookbook/vercel-ai-sdk.mdx index b793dee5..8bdc4688 100644 --- a/content/docs/cookbook/vercel-ai-sdk.mdx +++ b/content/docs/cookbook/vercel-ai-sdk.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with the Vercel AI SDK description: Use Steel with the Vercel AI SDK v6 ToolLoopAgent for typed, tool-using browser agents. --- - + - + diff --git a/content/docs/cookbook/you-com-search.mdx b/content/docs/cookbook/you-com-search.mdx index a61302dc..ea2e069e 100644 --- a/content/docs/cookbook/you-com-search.mdx +++ b/content/docs/cookbook/you-com-search.mdx @@ -3,9 +3,9 @@ title: Combine You.com search with Steel browser actions description: 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. --- - + - + diff --git a/cookbook.lock.json b/cookbook.lock.json index be354c20..d41d07bd 100644 --- a/cookbook.lock.json +++ b/cookbook.lock.json @@ -1,5 +1,5 @@ { "repo": "steel-dev/steel-cookbook", "ref": "main", - "sha": "c506b2f6f7f97fa5bd47a616eac5315c9b9b1d25" + "sha": "406a3f60ccd2d1a2b6c1f07a1977fb9e4baae32c" } From 5e486bd17bc1cab0f22484c606a3a8d0af72808b Mon Sep 17 00:00:00 2001 From: junhsss Date: Wed, 24 Jun 2026 02:54:56 +0900 Subject: [PATCH 03/11] feat: add new sdks to overview --- app/(home)/overview/_pages/page.en.tsx | 14 ++++++++++++++ components/ui/icon.tsx | 16 ++++++++++++++++ next.config.mjs | 10 ++++++++++ 3 files changed, 40 insertions(+) diff --git a/app/(home)/overview/_pages/page.en.tsx b/app/(home)/overview/_pages/page.en.tsx index b0b7208d..4525d93b 100644 --- a/app/(home)/overview/_pages/page.en.tsx +++ b/app/(home)/overview/_pages/page.en.tsx @@ -10,8 +10,10 @@ import { Cloud, Container, GeminiIcon, + GoIcon, OpenAIIcon, PythonIcon, + RustIcon, TSIcon, } from '@/components/ui/icon'; import SteelLogo from '@/public/images/logo.png'; @@ -160,6 +162,18 @@ export default function HomePage() { title="Steel Python SDK" description="Python SDK for building applications on Steel." /> + } + href="/steel-go-sdk" + title="Steel Go SDK" + description="Go SDK for building applications on Steel." + /> + } + href="/steel-rust-sdk" + title="Steel Rust SDK" + description="Rust SDK for building applications on Steel." + />
diff --git a/components/ui/icon.tsx b/components/ui/icon.tsx index b64c8a2e..92981c03 100644 --- a/components/ui/icon.tsx +++ b/components/ui/icon.tsx @@ -3642,3 +3642,19 @@ export function SeleniumIcon({ className }: IconProps) { ); } + +export function GoIcon(props: SVGProps) { + return ( + + + + ); +} + +export function RustIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/next.config.mjs b/next.config.mjs index 5ad3ab90..ca0eee76 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -39,6 +39,16 @@ const config = { destination: "https://pypi.org/project/steel-sdk/", permanent: true, }, + { + source: "/steel-go-sdk", + destination: "https://pkg.go.dev/github.com/steel-dev/steel-go", + permanent: true, + }, + { + source: "/steel-rust-sdk", + destination: "https://crates.io/crates/steel-rs", + permanent: true, + }, { source: "/api-reference", destination: "https://steel.apidocumentation.com/api-reference", From e5feeaf413de0c2218b7765e5bc81dbc375cea21 Mon Sep 17 00:00:00 2001 From: junhsss Date: Wed, 24 Jun 2026 03:15:37 +0900 Subject: [PATCH 04/11] chore: bump cookbook version --- components/mdx/index.tsx | 2 ++ components/mdx/lang-tabs.tsx | 56 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 components/mdx/lang-tabs.tsx diff --git a/components/mdx/index.tsx b/components/mdx/index.tsx index f271daa4..84334a3f 100644 --- a/components/mdx/index.tsx +++ b/components/mdx/index.tsx @@ -12,6 +12,7 @@ import { docskit } from '@/components/docskit/components'; import { FAQ, FAQItem } from '@/components/faq'; import { IntegrationGrid } from '@/components/integration-grid'; import { OrderedList, UnorderedList } from '@/components/lists'; +import { Tabs as LangTabs } from '@/components/mdx/lang-tabs'; import { RecipeCard, RecipeGrid } from '@/components/recipe-card'; import { RecipeJsonLd } from '@/components/recipe-jsonld'; import { RecipeMeta } from '@/components/recipe-meta'; @@ -47,6 +48,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { ...FilesComponents, ...StepsComponents, ...TabsComponents, + Tabs: LangTabs, IntegrationGrid, RecipeCard, RecipeGrid, diff --git a/components/mdx/lang-tabs.tsx b/components/mdx/lang-tabs.tsx new file mode 100644 index 00000000..8333133b --- /dev/null +++ b/components/mdx/lang-tabs.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Tabs as FumaTabs, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs'; +import type { ComponentProps, ReactElement, SVGProps } from 'react'; +import { Children, cloneElement, isValidElement } from 'react'; +import { GoIcon, PythonIcon, RustIcon, TSIcon } from '@/components/ui/icon'; + +type IconComponent = (props: SVGProps) => ReactElement; + +const LANG_ICONS: Record = { + typescript: { Icon: TSIcon, className: '!size-3.5' }, + python: { Icon: PythonIcon, className: '!size-4' }, + go: { Icon: GoIcon, className: '!size-5' }, + rust: { Icon: RustIcon, className: '!size-4' }, +}; + +function escapeValue(value: string) { + return value.toLowerCase().replace(/\s/, '-'); +} + +export function Tabs({ + items, + defaultIndex = 0, + children, + ...props +}: ComponentProps) { + if (!items) { + return {children}; + } + + const values = items.map(escapeValue); + const tabs = Children.toArray(children).filter(isValidElement) as ReactElement<{ + id?: string; + value?: string; + }>[]; + const withValues = tabs.map((child, i) => + cloneElement(child, { value: child.props.value ?? child.props.id ?? values[i] }), + ); + + return ( + + + {items.map((item, i) => { + const icon = LANG_ICONS[values[i]]; + return ( + + {icon ? : null} + {item} + + ); + })} + + {withValues} + + ); +} From fc73a6f5e01a176e455bfb8f4d81dfe964fc9933 Mon Sep 17 00:00:00 2001 From: junhsss Date: Wed, 24 Jun 2026 04:12:03 +0900 Subject: [PATCH 05/11] feat: adjust language selector size --- components/mdx/lang-tabs.tsx | 10 +- components/recipe-card.tsx | 63 +++++-- components/recipe-search.tsx | 9 +- components/ui/icon.tsx | 37 +--- content/docs/cookbook/agentkit.mdx | 6 +- content/docs/cookbook/agno.mdx | 6 +- content/docs/cookbook/auth-context.mdx | 6 +- content/docs/cookbook/authors/aspectrr.mdx | 6 +- content/docs/cookbook/authors/bsparker.mdx | 2 +- content/docs/cookbook/authors/danew.mdx | 2 +- content/docs/cookbook/authors/hussufo.mdx | 8 +- content/docs/cookbook/authors/jagadeshjai.mdx | 8 +- content/docs/cookbook/authors/junhsss.mdx | 76 ++++---- content/docs/cookbook/authors/nibzard.mdx | 6 +- .../cookbook/browser-use-captcha-auto.mdx | 6 +- .../cookbook/browser-use-captcha-manual.mdx | 6 +- content/docs/cookbook/browser-use.mdx | 6 +- content/docs/cookbook/chromedp.mdx | 6 +- content/docs/cookbook/chromiumoxide.mdx | 6 +- content/docs/cookbook/claude-agent-sdk.mdx | 6 +- .../cookbook/claude-computer-use-mobile.mdx | 6 +- content/docs/cookbook/claude-computer-use.mdx | 6 +- .../docs/cookbook/convex-chat-with-page.mdx | 6 +- content/docs/cookbook/convex-price-watch.mdx | 6 +- content/docs/cookbook/credentials.mdx | 6 +- content/docs/cookbook/crewai.mdx | 6 +- content/docs/cookbook/deep-research.mdx | 6 +- content/docs/cookbook/eino.mdx | 6 +- content/docs/cookbook/extensions.mdx | 6 +- content/docs/cookbook/files.mdx | 6 +- content/docs/cookbook/gemini-computer-use.mdx | 6 +- content/docs/cookbook/genkit.mdx | 6 +- content/docs/cookbook/google-adk.mdx | 6 +- content/docs/cookbook/index.mdx | 163 ++++++++++++++++++ content/docs/cookbook/langchaingo.mdx | 6 +- content/docs/cookbook/langgraph.mdx | 6 +- content/docs/cookbook/magnitude.mdx | 6 +- content/docs/cookbook/mastra.mdx | 6 +- .../cookbook/microsoft-agent-framework.mdx | 6 +- content/docs/cookbook/notte.mdx | 6 +- content/docs/cookbook/openai-agents.mdx | 6 +- content/docs/cookbook/openai-computer-use.mdx | 6 +- content/docs/cookbook/playwright.mdx | 6 +- content/docs/cookbook/profiles.mdx | 6 +- content/docs/cookbook/puppeteer.mdx | 6 +- content/docs/cookbook/pydantic-ai.mdx | 6 +- content/docs/cookbook/rig.mdx | 6 +- content/docs/cookbook/rod.mdx | 6 +- content/docs/cookbook/scrape.mdx | 6 +- content/docs/cookbook/selenium.mdx | 6 +- content/docs/cookbook/stagehand.mdx | 6 +- content/docs/cookbook/swiftide.mdx | 6 +- content/docs/cookbook/topics/agents.mdx | 50 +++--- .../docs/cookbook/topics/authentication.mdx | 6 +- .../cookbook/topics/browser-automation.mdx | 14 +- content/docs/cookbook/topics/browser-use.mdx | 6 +- content/docs/cookbook/topics/captchas.mdx | 4 +- content/docs/cookbook/topics/computer-use.mdx | 8 +- content/docs/cookbook/topics/convex.mdx | 4 +- content/docs/cookbook/topics/mobile.mdx | 2 +- content/docs/cookbook/topics/nextjs.mdx | 2 +- content/docs/cookbook/topics/playwright.mdx | 8 +- content/docs/cookbook/topics/search.mdx | 2 +- content/docs/cookbook/topics/steel-apis.mdx | 14 +- content/docs/cookbook/topics/subagents.mdx | 2 +- content/docs/cookbook/topics/typed-output.mdx | 14 +- .../docs/cookbook/vercel-ai-sdk-nextjs.mdx | 6 +- content/docs/cookbook/vercel-ai-sdk.mdx | 6 +- content/docs/cookbook/you-com-search.mdx | 6 +- scripts/sync-cookbook.ts | 21 ++- 70 files changed, 494 insertions(+), 311 deletions(-) diff --git a/components/mdx/lang-tabs.tsx b/components/mdx/lang-tabs.tsx index 8333133b..c47e86c4 100644 --- a/components/mdx/lang-tabs.tsx +++ b/components/mdx/lang-tabs.tsx @@ -8,10 +8,10 @@ import { GoIcon, PythonIcon, RustIcon, TSIcon } from '@/components/ui/icon'; type IconComponent = (props: SVGProps) => ReactElement; const LANG_ICONS: Record = { - typescript: { Icon: TSIcon, className: '!size-3.5' }, - python: { Icon: PythonIcon, className: '!size-4' }, - go: { Icon: GoIcon, className: '!size-5' }, - rust: { Icon: RustIcon, className: '!size-4' }, + typescript: { Icon: TSIcon, className: '!size-[13px]' }, + python: { Icon: PythonIcon, className: '!size-[15px]' }, + go: { Icon: GoIcon, className: '!size-[18px]' }, + rust: { Icon: RustIcon, className: '!size-[15px]' }, }; function escapeValue(value: string) { @@ -43,7 +43,7 @@ export function Tabs({ {items.map((item, i) => { const icon = LANG_ICONS[values[i]]; return ( - + {icon ? : null} {item} diff --git a/components/recipe-card.tsx b/components/recipe-card.tsx index 7986d9cd..048bd042 100644 --- a/components/recipe-card.tsx +++ b/components/recipe-card.tsx @@ -1,5 +1,6 @@ import Link from 'next/link'; -import type { ReactNode } from 'react'; +import type { ReactNode, SVGProps } from 'react'; +import { GoIcon, PythonIcon, RustIcon, TSIcon } from '@/components/ui/icon'; import { cn } from '@/lib/utils'; interface RecipeCardProps { @@ -7,9 +8,23 @@ interface RecipeCardProps { title: string; description: string; topics: string[]; + languages?: string[]; date?: string; } +// Per-language icon with optical-size tuning so the marks read as one +// consistent size inside the chip (TS fills its box; the Go mark is +// wide-and-short). +const LANG_ICONS: Record< + string, + { Icon: (p: SVGProps) => ReactNode; size: string } +> = { + TypeScript: { Icon: TSIcon, size: 'size-3' }, + Python: { Icon: PythonIcon, size: 'size-3.5' }, + Go: { Icon: GoIcon, size: 'size-4' }, + Rust: { Icon: RustIcon, size: 'size-3.5' }, +}; + // Render a registry date (YYYY-MM-DD) as the short English month-day-year // form ("Apr 23, 2026"). Falls back to the raw string if parsing fails. function formatDate(iso: string): string { @@ -34,7 +49,7 @@ function topicSlug(topic: string): string { .replace(/^-|-$/g, ''); } -export function RecipeCard({ slug, title, description, topics, date }: RecipeCardProps) { +export function RecipeCard({ slug, title, description, topics, languages, date }: RecipeCardProps) { // "Stretched link" pattern: an absolute overlay covers the whole // card, so clicking anywhere navigates to the recipe. Inner contents are // pointer-events-none by default; interactive children (title, topic @@ -59,21 +74,35 @@ export function RecipeCard({ slug, title, description, topics, date }: RecipeCar

{description}

- {topics.length > 0 ? ( -
- {topics.map((topic) => ( - - {topic} - - ))} -
- ) : ( - - )} +
+ {languages && languages.length > 0 && ( +
+ {languages.map((language) => { + const icon = LANG_ICONS[language]; + if (!icon) return null; + return ( + + + + ); + })} +
+ )} + {topics.map((topic) => ( + + {topic} + + ))} +
{date && (
diff --git a/components/recipe-card.tsx b/components/recipe-card.tsx index 048bd042..d17838c7 100644 --- a/components/recipe-card.tsx +++ b/components/recipe-card.tsx @@ -49,14 +49,26 @@ function topicSlug(topic: string): string { .replace(/^-|-$/g, ''); } -export function RecipeCard({ slug, title, description, topics, languages, date }: RecipeCardProps) { +export function RecipeCard({ + slug, + title, + description, + topics, + languages, + date, +}: RecipeCardProps) { // "Stretched link" pattern: an absolute overlay covers the whole // card, so clicking anywhere navigates to the recipe. Inner contents are // pointer-events-none by default; interactive children (title, topic // pills) re-enable pointer events and sit above the overlay so they stay // clickable as distinct links. return ( -
+
0; + return (
-
+
+ + {showFeatured && featured && ( +
+

+ Featured +

+ + {featured.map((r) => ( + + ))} + +
+ )} + + {isIdle && ( +

+ All +

+ )} {filtered.length > 0 ? ( {filtered.map((r) => ( diff --git a/content/docs/cookbook/auth-context.mdx b/content/docs/cookbook/auth-context.mdx index 150ebf3a..fee287cd 100644 --- a/content/docs/cookbook/auth-context.mdx +++ b/content/docs/cookbook/auth-context.mdx @@ -5,7 +5,7 @@ description: Maintain authenticated sessions across Steel browser instances by c - + @@ -163,126 +163,126 @@ A run takes about 20 seconds and costs a few cents of session time. Both session - - - + - + -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) +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. -// Restore them into a brand new session #2 -second, _ := client.Sessions.Create(ctx, steel.SessionCreateParams{ - SessionContext: restoreContext(captured), -}) -``` +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`), but `SessionCreateParams::session_context` wants a `SessionCreateParamsSessionContext` (cookies are `Vec`). 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. -`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. +## What the demo does -## The read type is not the write type +`main.rs` drives [practice.expandtesting.com](https://practice.expandtesting.com/login), a public login test site, over CDP with chromiumoxide: -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. +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. -`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. +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-go +cd examples/auth-context-rs cp .env.example .env # set STEEL_API_KEY -go mod tidy -go run . +cargo 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. +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... -Authenticated on session #1 +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... -Authenticated on session #2 +Session #2 released -Authentication successfully transferred. -Releasing session #2... +Authentication successfully transferred without logging in ``` -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. +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 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. +- **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-rs](/cookbook/auth-context) · [credentials-go](/cookbook/credentials) · [chromedp docs](https://github.com/chromedp/chromedp) +[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) - + - + - + -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. +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. -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`), but `SessionCreateParams::session_context` wants a `SessionCreateParamsSessionContext` (cookies are `Vec`). 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. +```go +// Capture the live cookies + storage off session #1 +captured, _ := client.Sessions.Context(ctx, first.ID) -## What the demo does +// Restore them into a brand new session #2 +second, _ := client.Sessions.Create(ctx, steel.SessionCreateParams{ + SessionContext: restoreContext(captured), +}) +``` -`main.rs` drives [practice.expandtesting.com](https://practice.expandtesting.com/login), a public login test site, over CDP with chromiumoxide: +`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. -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. +## The read type is not the write type -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. +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-rs +cd examples/auth-context-go cp .env.example .env # set STEEL_API_KEY -cargo run +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 both session viewer URLs. Open them to watch each browser. +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... -Logging in... -Initial authentication confirmed +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... -Session #2 released +Authenticated on session #2 -Authentication successfully transferred without logging in +Authentication successfully transferred. +Releasing session #2... ``` -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. +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 `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. +- **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-go](/cookbook/auth-context) · [credentials-rs](/cookbook/credentials) · [chromiumoxide](https://github.com/mattsse/chromiumoxide) +[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) @@ -291,7 +291,7 @@ A run takes ~20 seconds. Both sessions go through `client.sessions().release(... ## Related recipes - - - + + + diff --git a/content/docs/cookbook/authors/aspectrr.mdx b/content/docs/cookbook/authors/aspectrr.mdx index 49c84b4e..7876b1e5 100644 --- a/content/docs/cookbook/authors/aspectrr.mdx +++ b/content/docs/cookbook/authors/aspectrr.mdx @@ -6,7 +6,7 @@ description: 3 recipes contributed to the Steel Cookbook by Collin Pfeifer. - + - + diff --git a/content/docs/cookbook/authors/danew.mdx b/content/docs/cookbook/authors/danew.mdx index 7f95bb93..b7eaa043 100644 --- a/content/docs/cookbook/authors/danew.mdx +++ b/content/docs/cookbook/authors/danew.mdx @@ -6,5 +6,5 @@ description: 1 recipe contributed to the Steel Cookbook by Dane Wilson. - + diff --git a/content/docs/cookbook/authors/hussufo.mdx b/content/docs/cookbook/authors/hussufo.mdx index b34c18d0..569eefc0 100644 --- a/content/docs/cookbook/authors/hussufo.mdx +++ b/content/docs/cookbook/authors/hussufo.mdx @@ -6,7 +6,7 @@ description: 4 recipes contributed to the Steel Cookbook by Hussien Hussien. - + diff --git a/content/docs/cookbook/authors/jagadeshjai.mdx b/content/docs/cookbook/authors/jagadeshjai.mdx index 278f0cd5..c20b8bb7 100644 --- a/content/docs/cookbook/authors/jagadeshjai.mdx +++ b/content/docs/cookbook/authors/jagadeshjai.mdx @@ -8,6 +8,6 @@ description: 4 recipes contributed to the Steel Cookbook by Jagadesh Jai. - + diff --git a/content/docs/cookbook/authors/junhsss.mdx b/content/docs/cookbook/authors/junhsss.mdx index 9f035eb9..5c979e1b 100644 --- a/content/docs/cookbook/authors/junhsss.mdx +++ b/content/docs/cookbook/authors/junhsss.mdx @@ -10,7 +10,7 @@ description: 39 recipes contributed to the Steel Cookbook by Jun Ryu. - + @@ -26,23 +26,23 @@ description: 39 recipes contributed to the Steel Cookbook by Jun Ryu. - + - + - - - + + + - - - + + + diff --git a/content/docs/cookbook/authors/nibzard.mdx b/content/docs/cookbook/authors/nibzard.mdx index b8d37855..0e1f9639 100644 --- a/content/docs/cookbook/authors/nibzard.mdx +++ b/content/docs/cookbook/authors/nibzard.mdx @@ -8,5 +8,5 @@ description: 3 recipes contributed to the Steel Cookbook by Nikola Balic. - + diff --git a/content/docs/cookbook/claude-computer-use-mobile.mdx b/content/docs/cookbook/claude-computer-use-mobile.mdx index 99912d1b..b5726de3 100644 --- a/content/docs/cookbook/claude-computer-use-mobile.mdx +++ b/content/docs/cookbook/claude-computer-use-mobile.mdx @@ -126,7 +126,7 @@ Expect ~60-180 seconds and 15-40 iterations for a typical mobile browse. ## Related recipes - - - + + + diff --git a/content/docs/cookbook/claude-computer-use.mdx b/content/docs/cookbook/claude-computer-use.mdx index 1c9b4ddd..add1b856 100644 --- a/content/docs/cookbook/claude-computer-use.mdx +++ b/content/docs/cookbook/claude-computer-use.mdx @@ -5,7 +5,7 @@ description: Connect Claude to a Steel browser session for autonomous web intera - + @@ -234,85 +234,85 @@ A run typically takes 60-180 seconds and 10-30 loop iterations. - + - + - + -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. +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. -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. +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. -## Constructing a Steel action +## Two type boundaries -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: +This recipe straddles two APIs with opposite typing stories, and `main.rs` leans into both. -```go -req := &steel.ComputerActionRequestClickMouse{ - Action: "click_mouse", - Button: &button, - Coordinates: &coords, - Screenshot: ptr(true), +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, } -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 +`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. -The response side is the other union. `BetaMessage.Content` is a slice of `BetaContentBlockUnion`; `block.AsAny()` returns the concrete variant for a type switch: +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: -```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 - } +```rust +"left_click" | "right_click" | "middle_click" | "double_click" | "triple_click" => { + SessionComputerParams::ClickMouse(ComputerActionRequestClickMouse { + action: ComputerActionRequestVariant1Action::ClickMouse, + button: Some(button), + coordinates: Some(vec![coords.0, coords.1]), + num_clicks, + screenshot: Some(true), + .. + }) } ``` -`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. +`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. -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. +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 -`executeTask` seeds the history with the system prompt and the task, then on each turn calls the Beta Messages API and processes the response: +`Agent::execute_task` seeds the transcript with the system prompt and the task, then repeats: call Anthropic, run any actions, append results. -```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"}, -}) +```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 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). +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. -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. +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-go +cd examples/claude-computer-use-rs cp .env.example .env # set STEEL_API_KEY and ANTHROPIC_API_KEY -go run . +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` as `TASK`; override it per 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" go run . +TASK="Find the current weather in New York City" cargo run ``` Your output varies. Structure looks like this: @@ -326,112 +326,113 @@ 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":"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 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`. +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.** `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). +- **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 -[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) +[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) - - - + - + -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 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. -## Two type boundaries +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. -This recipe straddles two APIs with opposite typing stories, and `main.rs` leans into both. +## Constructing a Steel action -Claude's reply decodes into an internally tagged enum on the block's `type` field: +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: -```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, +```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 ``` -`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. +`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`. -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: +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. -```rust -"left_click" | "right_click" | "middle_click" | "double_click" | "triple_click" => { - SessionComputerParams::ClickMouse(ComputerActionRequestClickMouse { - action: ComputerActionRequestVariant1Action::ClickMouse, - button: Some(button), - coordinates: Some(vec![coords.0, coords.1]), - num_clicks, - screenshot: Some(true), - .. - }) +## 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 + } } ``` -`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. +`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. -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. +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 -`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?; +`executeTask` seeds the history with the system prompt and the task, then on each turn calls the Beta Messages API and processes the response: -if !has_actions { - println!("Task complete - no further actions requested"); - final_text = text; - break; -} +```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 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. +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). -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`. +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-rs +cd examples/claude-computer-use-go cp .env.example .env # set STEEL_API_KEY and ANTHROPIC_API_KEY -cargo run +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`; override it per 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" cargo run +TASK="Find the current weather in New York City" go run . ``` Your output varies. Structure looks like this: @@ -445,31 +446,30 @@ 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":"key","text":"Return"}) 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. +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.** `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). +- **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 -[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) +[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) @@ -478,7 +478,7 @@ Expect 60 to 180 seconds and 10 to 30 iterations for a simple browse, plus Anthr ## Related recipes - + - + diff --git a/content/docs/cookbook/convex-price-watch.mdx b/content/docs/cookbook/convex-price-watch.mdx index 8f10420c..4620e308 100644 --- a/content/docs/cookbook/convex-price-watch.mdx +++ b/content/docs/cookbook/convex-price-watch.mdx @@ -103,7 +103,7 @@ const result = await steel.steel.scrape( ## Related recipes - + - + diff --git a/content/docs/cookbook/credentials.mdx b/content/docs/cookbook/credentials.mdx index 955d7bc2..779752a0 100644 --- a/content/docs/cookbook/credentials.mdx +++ b/content/docs/cookbook/credentials.mdx @@ -5,7 +5,7 @@ description: Use the Steel Credentials API with Playwright to automate flows wit - + @@ -178,76 +178,6 @@ Both persist a login across runs, by different means. Credentials stores a usern - - - - - - -The automation in `main.go` never types a username or a password. It navigates to a site, clicks the login link, and reads the heading to confirm it is signed in. 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. - -## The two-second wait - -After `chromedp.Click("#AccountLink", ...)` the script does `chromedp.Sleep(2 * time.Second)` before reading the `h1`. That window lets Steel detect the form, fill it, and let the page settle on the post-login view. A fixed sleep keeps the demo short. In production, prefer a deterministic wait such as `chromedp.WaitVisible` on an element that only exists once you are signed in. - -Re-running `Credentials.Create` for an origin that already has a stored credential returns an error whose message contains `Credential 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... -Navigating to the demo site... -Success, you are logged in -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 `chromedp.Navigate` and the `#AccountLink` click at the new login trigger. 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) - - - @@ -322,12 +252,82 @@ On a second run the first lines read `Credential already exists, moving on`; the + + + + + + +The automation in `main.go` never types a username or a password. It navigates to a site, clicks the login link, and reads the heading to confirm it is signed in. 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. + +## The two-second wait + +After `chromedp.Click("#AccountLink", ...)` the script does `chromedp.Sleep(2 * time.Second)` before reading the `h1`. That window lets Steel detect the form, fill it, and let the page settle on the post-login view. A fixed sleep keeps the demo short. In production, prefer a deterministic wait such as `chromedp.WaitVisible` on an element that only exists once you are signed in. + +Re-running `Credentials.Create` for an origin that already has a stored credential returns an error whose message contains `Credential 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... +Navigating to the demo site... +Success, you are logged in +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 `chromedp.Navigate` and the `#AccountLink` click at the new login trigger. 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 - - - + + + diff --git a/content/docs/cookbook/extensions.mdx b/content/docs/cookbook/extensions.mdx index 7d815e7c..f62ace5f 100644 --- a/content/docs/cookbook/extensions.mdx +++ b/content/docs/cookbook/extensions.mdx @@ -5,7 +5,7 @@ description: Use the Steel Extensions API with Playwright to upload and run brow - + @@ -172,6 +172,77 @@ A run takes ~20 seconds and costs a few cents of session time. The first run upl + + + + + + +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. + + + @@ -244,83 +315,12 @@ Releasing session... - - - - - - -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. - - - ## Related recipes - - - + + + diff --git a/content/docs/cookbook/files.mdx b/content/docs/cookbook/files.mdx index 5f7e7e3f..1068fb3b 100644 --- a/content/docs/cookbook/files.mdx +++ b/content/docs/cookbook/files.mdx @@ -5,7 +5,7 @@ description: Use the Steel Files API with Playwright to automate file uploads an - + @@ -187,87 +187,6 @@ Done! - - - - - - -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 `` 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. - - - @@ -351,12 +270,93 @@ Session released + + + + + + +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 `` 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 - - - + + + diff --git a/content/docs/cookbook/gemini-computer-use.mdx b/content/docs/cookbook/gemini-computer-use.mdx index 0413928c..36b4ed53 100644 --- a/content/docs/cookbook/gemini-computer-use.mdx +++ b/content/docs/cookbook/gemini-computer-use.mdx @@ -5,7 +5,7 @@ description: "Connect Google's Gemini Computer Use to a Steel browser session fo - + @@ -235,58 +235,43 @@ A run typically takes 60-180 seconds and 10-30 iterations. Because `generate_con - - - - - - -`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). +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. -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. +The model is `gemini-3-flash-preview`, the viewport is 1440x900, and the agent caps out at 50 iterations. -```go -data, err := base64.StdEncoding.DecodeString(shots[i]) -parts = append(parts, &genai.Part{ - InlineData: &genai.Blob{MIMEType: "image/png", Data: data}, -}) -``` +## REST plumbing and coordinates -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`). +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. -## The loop +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`). -`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: +## Sending frames back -- 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. +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. -`main` defers `agent.cleanup`, which releases the Steel session. +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-go +cd examples/gemini-computer-use-rs cp .env.example .env # set STEEL_API_KEY and GEMINI_API_KEY -go mod tidy -go run . +cargo 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: +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" go run . +TASK="Find the current weather in New York City" cargo run ``` -Output varies. The shape is: +Output varies. The shape looks like this: ```text Steel + Gemini Computer Use Assistant @@ -295,80 +280,96 @@ 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 open steel.dev and scan the page for recent 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":512,"y":340}) -Task complete - model provided final response -Releasing Steel session... -Session completed. View replay at https://app.steel.dev/sessions/ab12cd34... +click_at({"x":520,"y":410}) +Steel's latest release adds ... ============================================================ TASK EXECUTION COMPLETED ============================================================ -Duration: 78.4 seconds +Duration: 78.2 seconds Task: Go to Steel.dev and find the latest news Result: -Steel's latest release notes mention ... +Steel's latest release adds ... ============================================================ +Releasing Steel session... +Session completed. View replay at https://app.steel.dev/sessions/ab12cd34... ``` -A run usually takes 60-180 seconds across 10-30 iterations. +Expect roughly 60 to 120 seconds and 15 to 40 turns for a simple browsing task. ## 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). +- **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 -[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) +[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) - + - + - + -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. +`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. -The model is `gemini-3-flash-preview`, the viewport is 1440x900, and the agent caps out at 50 iterations. +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. -## REST plumbing and coordinates +## Two typed surfaces, one bytes gotcha -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. +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 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`). +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. -## Sending frames back +```go +data, err := base64.StdEncoding.DecodeString(shots[i]) +parts = append(parts, &genai.Part{ + InlineData: &genai.Blob{MIMEType: "image/png", Data: data}, +}) +``` -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. +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 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. +## 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-rs +cd examples/gemini-computer-use-go cp .env.example .env # set STEEL_API_KEY and GEMINI_API_KEY -cargo run +go mod tidy +go 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: +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" cargo run +TASK="Find the current weather in New York City" go run . ``` -Output varies. The shape looks like this: +Output varies. The shape is: ```text Steel + Gemini Computer Use Assistant @@ -377,41 +378,40 @@ 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. +I'll open steel.dev and scan the page for recent news. navigate({"url":"https://steel.dev"}) scroll_document({"direction":"down"}) -click_at({"x":520,"y":410}) -Steel's latest release adds ... +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.2 seconds +Duration: 78.4 seconds Task: Go to Steel.dev and find the latest news Result: -Steel's latest release adds ... +Steel's latest release notes mention ... ============================================================ -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. +A run usually takes 60-180 seconds across 10-30 iterations. ## 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. +- 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 -[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) +[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) @@ -421,6 +421,6 @@ Expect roughly 60 to 120 seconds and 15 to 40 turns for a simple browsing task. - - + + diff --git a/content/docs/cookbook/index.mdx b/content/docs/cookbook/index.mdx index 2f4b4925..2e321a33 100644 --- a/content/docs/cookbook/index.mdx +++ b/content/docs/cookbook/index.mdx @@ -63,8 +63,8 @@ description: Runnable recipes for using Steel with your favorite libraries and f "languages": [ "TypeScript", "Python", - "Go", - "Rust" + "Rust", + "Go" ], "date": "2026-06-23" }, @@ -325,8 +325,8 @@ description: Runnable recipes for using Steel with your favorite libraries and f "languages": [ "TypeScript", "Python", - "Go", - "Rust" + "Rust", + "Go" ], "date": "2025-11-25" }, @@ -354,8 +354,8 @@ description: Runnable recipes for using Steel with your favorite libraries and f "languages": [ "TypeScript", "Python", - "Go", - "Rust" + "Rust", + "Go" ], "date": "2025-10-13" }, @@ -442,8 +442,8 @@ description: Runnable recipes for using Steel with your favorite libraries and f "languages": [ "TypeScript", "Python", - "Go", - "Rust" + "Rust", + "Go" ], "date": "2025-07-16" }, @@ -457,8 +457,8 @@ description: Runnable recipes for using Steel with your favorite libraries and f "languages": [ "TypeScript", "Python", - "Go", - "Rust" + "Rust", + "Go" ], "date": "2025-03-19" }, @@ -473,8 +473,8 @@ description: Runnable recipes for using Steel with your favorite libraries and f "languages": [ "TypeScript", "Python", - "Go", - "Rust" + "Rust", + "Go" ], "date": "2025-03-11" }, @@ -556,8 +556,8 @@ description: Runnable recipes for using Steel with your favorite libraries and f "languages": [ "TypeScript", "Python", - "Go", - "Rust" + "Rust", + "Go" ], "date": "2024-11-19" }, @@ -572,8 +572,8 @@ description: Runnable recipes for using Steel with your favorite libraries and f "languages": [ "TypeScript", "Python", - "Go", - "Rust" + "Rust", + "Go" ], "date": "2024-11-19" }, @@ -588,9 +588,66 @@ description: Runnable recipes for using Steel with your favorite libraries and f "languages": [ "TypeScript", "Python", - "Go", - "Rust" + "Rust", + "Go" ], "date": "2024-11-19" } +]} featured={[ + { + "slug": "scrape", + "title": "Scrape a page to Markdown, screenshot, and PDF", + "description": "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.", + "topics": [ + "Steel APIs" + ], + "languages": [ + "TypeScript", + "Python", + "Rust", + "Go" + ], + "date": "2026-06-23" + }, + { + "slug": "playwright", + "title": "Automate a cloud browser with Playwright", + "description": "Use Steel with Playwright in TypeScript for cloud browser automation.", + "topics": [ + "Browser automation", + "Playwright" + ], + "languages": [ + "TypeScript", + "Python", + "Go" + ], + "date": "2024-11-19" + }, + { + "slug": "vercel-ai-sdk", + "title": "Build a typed browser agent with the Vercel AI SDK", + "description": "Use Steel with the Vercel AI SDK v6 ToolLoopAgent for typed, tool-using browser agents.", + "topics": [ + "Agents", + "Typed output" + ], + "languages": [ + "TypeScript" + ], + "date": "2026-04-23" + }, + { + "slug": "claude-agent-sdk", + "title": "Build a browser agent with the Claude Agent SDK", + "description": "Use Steel with the Claude Agent SDK (TypeScript) to build a tool-using browser agent on Anthropic's first-party agent loop.", + "topics": [ + "Agents" + ], + "languages": [ + "TypeScript", + "Python" + ], + "date": "2026-04-29" + } ]} /> diff --git a/content/docs/cookbook/openai-computer-use.mdx b/content/docs/cookbook/openai-computer-use.mdx index 496a0477..b92642e5 100644 --- a/content/docs/cookbook/openai-computer-use.mdx +++ b/content/docs/cookbook/openai-computer-use.mdx @@ -5,7 +5,7 @@ description: "Connect OpenAI's Computer Use Assistant to a Steel browser session - + @@ -263,6 +263,101 @@ A run typically takes 60-180 seconds and 10-30 iterations. Screenshots are cache + + + + + + +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 = 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. + + + @@ -384,107 +479,12 @@ The published OpenAI Python and TypeScript computer-use recipes target the `{"ty - - - - - - -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 = 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. - - - ## Related recipes - + - + diff --git a/content/docs/cookbook/profiles.mdx b/content/docs/cookbook/profiles.mdx index 278fd08a..40f3541b 100644 --- a/content/docs/cookbook/profiles.mdx +++ b/content/docs/cookbook/profiles.mdx @@ -5,7 +5,7 @@ description: Maintain authenticated sessions across Steel browser instances usin - + @@ -215,79 +215,6 @@ Other ports of this recipe: [profiles-ts](/cookbook/profiles) (interactive picke - - - - - - -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) - - - @@ -362,12 +289,85 @@ A full round trip takes ~30 seconds. Both sessions go through `client.sessions() + + + + + + +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 - - - + + + diff --git a/content/docs/cookbook/scrape.mdx b/content/docs/cookbook/scrape.mdx index d665480f..0f219822 100644 --- a/content/docs/cookbook/scrape.mdx +++ b/content/docs/cookbook/scrape.mdx @@ -5,7 +5,7 @@ description: "Use the Steel TypeScript SDK's direct API to scrape a page to clea - + @@ -183,6 +183,80 @@ The other recipes in the cookbook connect a browser library (Playwright, Seleniu + + + + + + +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 +scraped.content.markdown; // Option +``` + +`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). + + + @@ -262,86 +336,12 @@ A scrape call costs a few cents of browser time. Steel starts and tears down the - - - - - - -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 -scraped.content.markdown; // Option -``` - -`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). - - - ## Related recipes - - + + diff --git a/content/docs/cookbook/topics/authentication.mdx b/content/docs/cookbook/topics/authentication.mdx index e54f54ac..f752ace2 100644 --- a/content/docs/cookbook/topics/authentication.mdx +++ b/content/docs/cookbook/topics/authentication.mdx @@ -4,7 +4,7 @@ description: Patterns for persisting and replaying authenticated sessions across --- - - - + + + diff --git a/content/docs/cookbook/topics/computer-use.mdx b/content/docs/cookbook/topics/computer-use.mdx index aad19250..b5fdefac 100644 --- a/content/docs/cookbook/topics/computer-use.mdx +++ b/content/docs/cookbook/topics/computer-use.mdx @@ -4,8 +4,8 @@ description: Model-native browser control where the LLM sees the screen and emit --- - + - - + + diff --git a/content/docs/cookbook/topics/playwright.mdx b/content/docs/cookbook/topics/playwright.mdx index fc5e61e6..2d12b3fb 100644 --- a/content/docs/cookbook/topics/playwright.mdx +++ b/content/docs/cookbook/topics/playwright.mdx @@ -5,7 +5,7 @@ description: Recipes that use Playwright to drive a Steel session, either as the - - - + + + diff --git a/content/docs/cookbook/topics/steel-apis.mdx b/content/docs/cookbook/topics/steel-apis.mdx index 07ed00a7..a3e737c3 100644 --- a/content/docs/cookbook/topics/steel-apis.mdx +++ b/content/docs/cookbook/topics/steel-apis.mdx @@ -4,11 +4,11 @@ description: "Recipes for Steel's first-party APIs: credentials, auth contexts, --- - + - - - - - + + + + + diff --git a/scripts/sync-cookbook.ts b/scripts/sync-cookbook.ts index f0fa15a9..68e0d7c9 100644 --- a/scripts/sync-cookbook.ts +++ b/scripts/sync-cookbook.ts @@ -53,7 +53,11 @@ const TOPIC_DESCRIPTIONS: Record = { // Display order for language tabs in merged concept pages. Entries not // listed fall back to the end in insertion order. -const LANGUAGE_ORDER: string[] = ['TypeScript', 'Python', 'Go', 'Rust']; +const LANGUAGE_ORDER: string[] = ['TypeScript', 'Python', 'Rust', 'Go']; + +// Curated recipes surfaced in a "Featured" row at the top of the cookbook +// home page, shown in this order. Slugs must match concept slugs. +const FEATURED_SLUGS: string[] = ['scrape', 'playwright', 'vercel-ai-sdk', 'claude-agent-sdk']; interface CookbookLock { repo: string; // "owner/name" on GitHub @@ -523,12 +527,15 @@ async function emitHome(concepts: Concept[]): Promise { date: conceptCreatedDate(c), })); const recipesLiteral = JSON.stringify(recipeData, null, 2); + const bySlug = new Map(recipeData.map((r) => [r.slug, r])); + const featuredData = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter((r) => r !== undefined); + const featuredLiteral = JSON.stringify(featuredData, null, 2); const fm = frontmatter({ title: 'Cookbook', sidebarTitle: 'Home', description: 'Runnable recipes for using Steel with your favorite libraries and frameworks.', }); - const body = ``; + const body = ``; await fs.writeFile(path.join(OUTPUT_DIR, 'index.mdx'), `${fm}\n\n${body}\n`); } From 7ef2f052555d95974a0adf50e4595291a64ae41c Mon Sep 17 00:00:00 2001 From: junhsss Date: Wed, 24 Jun 2026 18:26:00 +0900 Subject: [PATCH 09/11] fix: select first available tab when stored language is missing --- components/docskit/code.client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/docskit/code.client.tsx b/components/docskit/code.client.tsx index 35ecabcb..cb2b57aa 100644 --- a/components/docskit/code.client.tsx +++ b/components/docskit/code.client.tsx @@ -18,7 +18,7 @@ export function MultiCode({ group, className }: { group: CodeGroup; className?: return ( Date: Wed, 24 Jun 2026 19:02:12 +0900 Subject: [PATCH 10/11] chore: bump cookbook version --- content/docs/cookbook/agentkit.mdx | 6 +- content/docs/cookbook/agno.mdx | 6 +- content/docs/cookbook/auth-context.mdx | 10 +- content/docs/cookbook/authors/junhsss.mdx | 5 +- .../cookbook/browser-use-captcha-auto.mdx | 6 +- .../cookbook/browser-use-captcha-manual.mdx | 6 +- content/docs/cookbook/browser-use.mdx | 6 +- content/docs/cookbook/chromedp.mdx | 8 +- content/docs/cookbook/chromiumoxide.mdx | 6 +- content/docs/cookbook/claude-agent-sdk.mdx | 8 +- .../cookbook/claude-computer-use-mobile.mdx | 4 +- content/docs/cookbook/claude-computer-use.mdx | 10 +- .../docs/cookbook/convex-chat-with-page.mdx | 6 +- content/docs/cookbook/convex-price-watch.mdx | 4 +- content/docs/cookbook/credentials.mdx | 10 +- content/docs/cookbook/crewai.mdx | 6 +- content/docs/cookbook/deep-research.mdx | 8 +- content/docs/cookbook/eino.mdx | 6 +- content/docs/cookbook/extensions.mdx | 10 +- content/docs/cookbook/files.mdx | 10 +- content/docs/cookbook/gemini-computer-use.mdx | 10 +- content/docs/cookbook/genkit.mdx | 6 +- content/docs/cookbook/google-adk.mdx | 8 +- content/docs/cookbook/headless-chrome.mdx | 6 +- content/docs/cookbook/index.mdx | 20 +- content/docs/cookbook/langchaingo.mdx | 6 +- content/docs/cookbook/langgraph.mdx | 4 +- content/docs/cookbook/magnitude.mdx | 6 +- content/docs/cookbook/mastra.mdx | 4 +- content/docs/cookbook/mcp.mdx | 277 ++++++++++++++++++ .../cookbook/microsoft-agent-framework.mdx | 6 +- content/docs/cookbook/notte.mdx | 6 +- content/docs/cookbook/openai-agents.mdx | 6 +- content/docs/cookbook/openai-computer-use.mdx | 10 +- content/docs/cookbook/playwright.mdx | 14 +- content/docs/cookbook/profiles.mdx | 10 +- content/docs/cookbook/puppeteer.mdx | 6 +- content/docs/cookbook/pydantic-ai.mdx | 4 +- content/docs/cookbook/rig.mdx | 6 +- content/docs/cookbook/rod.mdx | 22 +- content/docs/cookbook/scrape.mdx | 12 +- content/docs/cookbook/selenium.mdx | 6 +- content/docs/cookbook/stagehand.mdx | 8 +- content/docs/cookbook/swiftide.mdx | 6 +- content/docs/cookbook/topics/agents.mdx | 1 + .../cookbook/topics/browser-automation.mdx | 2 +- content/docs/cookbook/topics/mcp.mdx | 8 + content/docs/cookbook/topics/meta.json | 1 + .../docs/cookbook/vercel-ai-sdk-nextjs.mdx | 4 +- content/docs/cookbook/vercel-ai-sdk.mdx | 4 +- content/docs/cookbook/you-com-search.mdx | 6 +- cookbook.lock.json | 2 +- niko/faq/faq-preview.png | Bin 122670 -> 0 bytes 53 files changed, 471 insertions(+), 167 deletions(-) create mode 100644 content/docs/cookbook/mcp.mdx create mode 100644 content/docs/cookbook/topics/mcp.mdx delete mode 100644 niko/faq/faq-preview.png diff --git a/content/docs/cookbook/agentkit.mdx b/content/docs/cookbook/agentkit.mdx index 09fa293f..72323981 100644 --- a/content/docs/cookbook/agentkit.mdx +++ b/content/docs/cookbook/agentkit.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Inngest AgentKit description: "Integrate Steel with Inngest's AgentKit framework." --- - + - + @@ -116,7 +116,7 @@ A run lands in the ~20-40 second range. ## Related recipes + - diff --git a/content/docs/cookbook/agno.mdx b/content/docs/cookbook/agno.mdx index 23c7a8cc..b062e0e1 100644 --- a/content/docs/cookbook/agno.mdx +++ b/content/docs/cookbook/agno.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Agno description: Integrate Steel with the Agno agent framework. --- - + - + @@ -77,7 +77,7 @@ The `finally` block in `main()` calls `tools.close_session()`, which releases th ## Related recipes + - diff --git a/content/docs/cookbook/auth-context.mdx b/content/docs/cookbook/auth-context.mdx index fee287cd..5a6b1f3b 100644 --- a/content/docs/cookbook/auth-context.mdx +++ b/content/docs/cookbook/auth-context.mdx @@ -3,13 +3,13 @@ title: Reuse authenticated sessions across browsers description: Maintain authenticated sessions across Steel browser instances by capturing and reusing cookies and local storage. --- - + - + @@ -95,7 +95,7 @@ If you want Steel to store credentials and handle the login itself, see [credent - + @@ -165,7 +165,7 @@ A run takes about 20 seconds and costs a few cents of session time. Both session - + @@ -223,7 +223,7 @@ A run takes ~20 seconds. Both sessions go through `client.sessions().release(... - + diff --git a/content/docs/cookbook/authors/junhsss.mdx b/content/docs/cookbook/authors/junhsss.mdx index 5c979e1b..997baac2 100644 --- a/content/docs/cookbook/authors/junhsss.mdx +++ b/content/docs/cookbook/authors/junhsss.mdx @@ -1,14 +1,15 @@ --- title: Jun Ryu -description: 39 recipes contributed to the Steel Cookbook by Jun Ryu. +description: 40 recipes contributed to the Steel Cookbook by Jun Ryu. --- + - + diff --git a/content/docs/cookbook/browser-use-captcha-auto.mdx b/content/docs/cookbook/browser-use-captcha-auto.mdx index 9c235740..f0ee555a 100644 --- a/content/docs/cookbook/browser-use-captcha-auto.mdx +++ b/content/docs/cookbook/browser-use-captcha-auto.mdx @@ -3,9 +3,9 @@ title: Solve CAPTCHAs automatically in a Browser Use agent description: Build an AI agent with browser-use and Steel that solves CAPTCHAs automatically. --- - + - + @@ -101,5 +101,5 @@ A run usually finishes in under a minute: a few cents of Steel session time plus - + diff --git a/content/docs/cookbook/browser-use-captcha-manual.mdx b/content/docs/cookbook/browser-use-captcha-manual.mdx index 1d203728..fcaa68c5 100644 --- a/content/docs/cookbook/browser-use-captcha-manual.mdx +++ b/content/docs/cookbook/browser-use-captcha-manual.mdx @@ -3,9 +3,9 @@ title: Solve reCAPTCHA v2 manually with Browser Use description: "Manually solve reCAPTCHA v2 using Steel's CAPTCHA API with the browser-use framework." --- - + - + @@ -139,5 +139,5 @@ A run takes ~60 seconds and costs Steel session time plus OpenAI tokens for each - + diff --git a/content/docs/cookbook/browser-use.mdx b/content/docs/cookbook/browser-use.mdx index 36538e28..19b2c753 100644 --- a/content/docs/cookbook/browser-use.mdx +++ b/content/docs/cookbook/browser-use.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Browser Use description: Integrate Steel with the browser-use framework for AI-driven web automation. --- - + - + @@ -76,5 +76,5 @@ A run costs a few cents of Steel session time plus OpenAI tokens for each step t - + diff --git a/content/docs/cookbook/chromedp.mdx b/content/docs/cookbook/chromedp.mdx index a50f3450..3b94d7e3 100644 --- a/content/docs/cookbook/chromedp.mdx +++ b/content/docs/cookbook/chromedp.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with chromedp description: Use Steel with chromedp to connect over CDP, navigate to Hacker News, extract the top stories, and capture a screenshot. --- - + - + @@ -79,12 +79,12 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the ## Related -[Playwright version](/cookbook/playwright) and [Python Playwright](/cookbook/playwright) connect over CDP the same way with a different driver. [go-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. +[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 - + diff --git a/content/docs/cookbook/chromiumoxide.mdx b/content/docs/cookbook/chromiumoxide.mdx index 93db1053..97b4803a 100644 --- a/content/docs/cookbook/chromiumoxide.mdx +++ b/content/docs/cookbook/chromiumoxide.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with chromiumoxide description: Use Steel with chromiumoxide to connect over CDP, drive the handler task, extract page content, and capture a screenshot. --- - + - + @@ -94,5 +94,5 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the - + diff --git a/content/docs/cookbook/claude-agent-sdk.mdx b/content/docs/cookbook/claude-agent-sdk.mdx index 07d5edc0..f9e3f369 100644 --- a/content/docs/cookbook/claude-agent-sdk.mdx +++ b/content/docs/cookbook/claude-agent-sdk.mdx @@ -3,13 +3,13 @@ title: Build a browser agent with the Claude Agent SDK description: "Use Steel with the Claude Agent SDK (TypeScript) to build a tool-using browser agent on Anthropic's first-party agent loop." --- - + - + @@ -120,7 +120,7 @@ A run takes ~25 to 45 seconds and 3 to 6 turns. Cost is Steel session-minutes pl - + @@ -232,7 +232,7 @@ A run takes ~30 to 50 seconds and 3 to 6 turns. Cost is Steel session-minutes pl ## Related recipes + - diff --git a/content/docs/cookbook/claude-computer-use-mobile.mdx b/content/docs/cookbook/claude-computer-use-mobile.mdx index b5726de3..c409df2f 100644 --- a/content/docs/cookbook/claude-computer-use-mobile.mdx +++ b/content/docs/cookbook/claude-computer-use-mobile.mdx @@ -3,9 +3,9 @@ title: Drive a mobile browser with Claude Computer Use description: Claude Computer Use with Steel for autonomous task execution in mobile browser environments. --- - + - + diff --git a/content/docs/cookbook/claude-computer-use.mdx b/content/docs/cookbook/claude-computer-use.mdx index add1b856..1752cc6f 100644 --- a/content/docs/cookbook/claude-computer-use.mdx +++ b/content/docs/cookbook/claude-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with Claude Computer Use description: Connect Claude to a Steel browser session for autonomous web interactions. --- - + - + @@ -118,7 +118,7 @@ Expect ~60-120 seconds and 15-40 iterations for a simple browsing task. - + @@ -236,7 +236,7 @@ A run typically takes 60-180 seconds and 10-30 loop iterations. - + @@ -356,7 +356,7 @@ Expect 60 to 180 seconds and 10 to 30 iterations for a simple browse, plus Anthr - + diff --git a/content/docs/cookbook/convex-chat-with-page.mdx b/content/docs/cookbook/convex-chat-with-page.mdx index a5eac4fb..156dce7c 100644 --- a/content/docs/cookbook/convex-chat-with-page.mdx +++ b/content/docs/cookbook/convex-chat-with-page.mdx @@ -3,9 +3,9 @@ title: Chat with any webpage on Convex description: "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." --- - + - + @@ -111,7 +111,7 @@ The result is then chunked at paragraph boundaries into ~25k-character pieces (` ## Related recipes + - diff --git a/content/docs/cookbook/convex-price-watch.mdx b/content/docs/cookbook/convex-price-watch.mdx index 4620e308..408d2660 100644 --- a/content/docs/cookbook/convex-price-watch.mdx +++ b/content/docs/cookbook/convex-price-watch.mdx @@ -3,9 +3,9 @@ title: Watch Claude pricing for divergent A/B variants description: 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. --- - + - + diff --git a/content/docs/cookbook/credentials.mdx b/content/docs/cookbook/credentials.mdx index 779752a0..cdde699f 100644 --- a/content/docs/cookbook/credentials.mdx +++ b/content/docs/cookbook/credentials.mdx @@ -3,13 +3,13 @@ title: Automate logins with the Credentials API description: Use the Steel Credentials API with Playwright to automate flows with stored credentials. --- - + - + @@ -102,7 +102,7 @@ Reach for credentials when you want a stable, long-lived setup tied to an accoun - + @@ -180,7 +180,7 @@ Both persist a login across runs, by different means. Credentials stores a usern - + @@ -254,7 +254,7 @@ On a second run the first lines read `Credential already exists, moving on`; the - + diff --git a/content/docs/cookbook/crewai.mdx b/content/docs/cookbook/crewai.mdx index c37dca3b..c372d622 100644 --- a/content/docs/cookbook/crewai.mdx +++ b/content/docs/cookbook/crewai.mdx @@ -3,9 +3,9 @@ title: Build a multi-agent browser workflow with CrewAI description: Integrate Steel with the CrewAI multi-agent framework. --- - + - + @@ -89,7 +89,7 @@ A run takes ~60-90 seconds. `report.md` is overwritten each run. ## Related recipes + - diff --git a/content/docs/cookbook/deep-research.mdx b/content/docs/cookbook/deep-research.mdx index 5e8a9b0e..dc9dcb43 100644 --- a/content/docs/cookbook/deep-research.mdx +++ b/content/docs/cookbook/deep-research.mdx @@ -3,13 +3,13 @@ title: Deep research with Claude Agent SDK subagents description: Lead orchestrator dispatches parallel researcher subagents, each driving its own Steel browser, and synthesizes findings into a cited Markdown report. --- - + - + @@ -177,7 +177,7 @@ A run takes ~4 to 6 minutes wall-clock with 3 Steel sessions in parallel. Cost i - + @@ -350,7 +350,7 @@ A run takes ~4 to 6 minutes wall-clock with 3 Steel sessions running in parallel ## Related recipes + - diff --git a/content/docs/cookbook/eino.mdx b/content/docs/cookbook/eino.mdx index bf96cf30..c81f6cba 100644 --- a/content/docs/cookbook/eino.mdx +++ b/content/docs/cookbook/eino.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Eino description: "Use Steel with the ByteDance Eino framework to build a ReAct agent that calls Steel's scrape API as a tool to research and answer a web question." --- - + - + @@ -104,7 +104,7 @@ A run is typically 5 to 9 agent turns and ~15 to 35 seconds against Hacker News. ## Related recipes + - diff --git a/content/docs/cookbook/extensions.mdx b/content/docs/cookbook/extensions.mdx index f62ace5f..20c21869 100644 --- a/content/docs/cookbook/extensions.mdx +++ b/content/docs/cookbook/extensions.mdx @@ -3,13 +3,13 @@ title: Upload and run browser extensions description: Use the Steel Extensions API with Playwright to upload and run browser extensions. --- - + - + @@ -99,7 +99,7 @@ A run takes ~20 seconds and costs a few cents of session time. First run uploads - + @@ -174,7 +174,7 @@ A run takes ~20 seconds and costs a few cents of session time. The first run upl - + @@ -245,7 +245,7 @@ The first run uploads the extension; later runs print `Reusing uploaded extensio - + diff --git a/content/docs/cookbook/files.mdx b/content/docs/cookbook/files.mdx index 1068fb3b..acef9ce9 100644 --- a/content/docs/cookbook/files.mdx +++ b/content/docs/cookbook/files.mdx @@ -3,13 +3,13 @@ title: Move files between your machine and a cloud browser description: Use the Steel Files API with Playwright to automate file uploads and downloads in the cloud. --- - + - + @@ -105,7 +105,7 @@ There's also `client.files` (without `.sessions`), an organization-scoped store - + @@ -189,7 +189,7 @@ Done! - + @@ -272,7 +272,7 @@ Session released - + diff --git a/content/docs/cookbook/gemini-computer-use.mdx b/content/docs/cookbook/gemini-computer-use.mdx index 36b4ed53..fc5aefba 100644 --- a/content/docs/cookbook/gemini-computer-use.mdx +++ b/content/docs/cookbook/gemini-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with Gemini Computer Use description: "Connect Google's Gemini Computer Use to a Steel browser session for autonomous web interactions." --- - + - + @@ -114,7 +114,7 @@ Expect roughly 60-120 seconds and 15-40 turns for a simple browsing task. - + @@ -237,7 +237,7 @@ A run typically takes 60-180 seconds and 10-30 iterations. Because `generate_con - + @@ -320,7 +320,7 @@ Expect roughly 60 to 120 seconds and 15 to 40 turns for a simple browsing task. - + diff --git a/content/docs/cookbook/genkit.mdx b/content/docs/cookbook/genkit.mdx index 04b8cf2b..37977095 100644 --- a/content/docs/cookbook/genkit.mdx +++ b/content/docs/cookbook/genkit.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Genkit description: Use Steel with Genkit Go to build a tool-calling agent that navigates and extracts from a chromedp-backed browser and completes a web task. --- - + - + @@ -111,7 +111,7 @@ A run takes about 20 to 40 seconds and 4 to 8 model turns. Cost is a few cents o ## Related recipes + - diff --git a/content/docs/cookbook/google-adk.mdx b/content/docs/cookbook/google-adk.mdx index 1026b1a3..ff5a6d1d 100644 --- a/content/docs/cookbook/google-adk.mdx +++ b/content/docs/cookbook/google-adk.mdx @@ -3,13 +3,13 @@ title: Build a browser agent with Google ADK description: "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." --- - + - + @@ -114,7 +114,7 @@ This agent has no `outputSchema`. ADK disables tool calls when an output schema - + @@ -250,7 +250,7 @@ A run takes ~20 to 40 seconds and a handful of agent turns on Hacker News. Cost - + diff --git a/content/docs/cookbook/headless-chrome.mdx b/content/docs/cookbook/headless-chrome.mdx index a1ce54e4..5238c1db 100644 --- a/content/docs/cookbook/headless-chrome.mdx +++ b/content/docs/cookbook/headless-chrome.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with headless_chrome description: Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles. --- - + - + @@ -88,6 +88,6 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the - + diff --git a/content/docs/cookbook/index.mdx b/content/docs/cookbook/index.mdx index 2e321a33..aeaf5206 100644 --- a/content/docs/cookbook/index.mdx +++ b/content/docs/cookbook/index.mdx @@ -17,6 +17,22 @@ description: Runnable recipes for using Steel with your favorite libraries and f ], "date": "2026-06-24" }, + { + "slug": "mcp", + "title": "Expose a Steel browser to any MCP client", + "description": "Build a Model Context Protocol server in Go with the official SDK and chromedp that hands any MCP client a Steel cloud browser through explicit session-handle tools.", + "topics": [ + "Agents", + "MCP" + ], + "languages": [ + "TypeScript", + "Python", + "Rust", + "Go" + ], + "date": "2026-06-24" + }, { "slug": "chromedp", "title": "Automate a cloud browser with chromedp", @@ -31,8 +47,8 @@ description: Runnable recipes for using Steel with your favorite libraries and f }, { "slug": "rod", - "title": "Automate a cloud browser with go-rod", - "description": "Use Steel with go-rod's fluent, chainable API to connect over CDP and scrape quotes.toscrape.com from a cloud browser.", + "title": "Automate a cloud browser with Rod", + "description": "Use Steel with Rod's fluent, chainable API to connect over CDP and scrape quotes.toscrape.com from a cloud browser.", "topics": [ "Browser automation" ], diff --git a/content/docs/cookbook/langchaingo.mdx b/content/docs/cookbook/langchaingo.mdx index b7435e1c..5d6b4626 100644 --- a/content/docs/cookbook/langchaingo.mdx +++ b/content/docs/cookbook/langchaingo.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with LangChainGo description: "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." --- - + - + @@ -80,7 +80,7 @@ Each scrape call spins up a short-lived Steel browser server-side, so a run cost ## Related recipes + - diff --git a/content/docs/cookbook/langgraph.mdx b/content/docs/cookbook/langgraph.mdx index 3f77f365..a4a0ef7d 100644 --- a/content/docs/cookbook/langgraph.mdx +++ b/content/docs/cookbook/langgraph.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with LangGraph description: Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node. --- - + - + diff --git a/content/docs/cookbook/magnitude.mdx b/content/docs/cookbook/magnitude.mdx index 137a59ff..06427730 100644 --- a/content/docs/cookbook/magnitude.mdx +++ b/content/docs/cookbook/magnitude.mdx @@ -3,9 +3,9 @@ title: Build an AI browser agent with Magnitude description: Use Steel with Magnitude for AI-powered browser automation. --- - + - + @@ -120,7 +120,7 @@ A full run takes ~45 seconds. The `finally` block stops the agent first, then re ## Related recipes + - diff --git a/content/docs/cookbook/mastra.mdx b/content/docs/cookbook/mastra.mdx index 69db09e7..07cb209b 100644 --- a/content/docs/cookbook/mastra.mdx +++ b/content/docs/cookbook/mastra.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with Mastra description: Use Steel with Mastra to build a typed browser agent with the Mastra Model Router and Studio playground. --- - + - + diff --git a/content/docs/cookbook/mcp.mdx b/content/docs/cookbook/mcp.mdx new file mode 100644 index 00000000..5bf6ff0c --- /dev/null +++ b/content/docs/cookbook/mcp.mdx @@ -0,0 +1,277 @@ +--- +title: Expose a Steel browser to any MCP client +description: Build a Model Context Protocol server in Go with the official SDK and chromedp that hands any MCP client a Steel cloud browser through explicit session-handle tools. +--- + + + + + + + + + + + +This is a [Model Context Protocol](https://modelcontextprotocol.io) server that hands any MCP client 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. + + + + + + + + + +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. + + + + + + + + + +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>>`, 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` 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. + + + + + + + + + +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 + + + + + + diff --git a/content/docs/cookbook/microsoft-agent-framework.mdx b/content/docs/cookbook/microsoft-agent-framework.mdx index 3828c4da..e7d66344 100644 --- a/content/docs/cookbook/microsoft-agent-framework.mdx +++ b/content/docs/cookbook/microsoft-agent-framework.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Microsoft Agent Framework description: Use Steel with Microsoft Agent Framework 1.0 (the successor to AutoGen and Semantic Kernel) to build a tool-using browser agent. --- - + - + @@ -98,7 +98,7 @@ A run takes ~20 to 40 seconds and 5 to 10 agent turns on GitHub Trending. Cost i ## Related recipes + - diff --git a/content/docs/cookbook/notte.mdx b/content/docs/cookbook/notte.mdx index 3ca247e7..a950b5ad 100644 --- a/content/docs/cookbook/notte.mdx +++ b/content/docs/cookbook/notte.mdx @@ -3,9 +3,9 @@ title: "Control a browser with Notte's reasoning engine" description: "Control browsers with AI using Steel's infrastructure and Notte's reasoning engine." --- - + - + @@ -81,7 +81,7 @@ A default run takes ~25 seconds. The `finally` block calls `client.sessions.rele ## Related recipes + - diff --git a/content/docs/cookbook/openai-agents.mdx b/content/docs/cookbook/openai-agents.mdx index e27e8d65..4cfc96b9 100644 --- a/content/docs/cookbook/openai-agents.mdx +++ b/content/docs/cookbook/openai-agents.mdx @@ -3,13 +3,13 @@ title: Build a typed browser agent with the OpenAI Agents SDK description: Use Steel with the OpenAI Agents SDK for TypeScript to build typed, tool-using browser agents. --- - + - + @@ -96,7 +96,7 @@ A full run is ~20-40 seconds. Cost is a few cents of Steel session time plus Ope - + diff --git a/content/docs/cookbook/openai-computer-use.mdx b/content/docs/cookbook/openai-computer-use.mdx index b92642e5..8be41322 100644 --- a/content/docs/cookbook/openai-computer-use.mdx +++ b/content/docs/cookbook/openai-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with OpenAI Computer Use description: "Connect OpenAI's Computer Use Assistant to a Steel browser session for autonomous web interactions." --- - + - + @@ -139,7 +139,7 @@ Expect roughly 60-120 seconds and 15-40 turns for a simple browsing task. - + @@ -265,7 +265,7 @@ A run typically takes 60-180 seconds and 10-30 iterations. Screenshots are cache - + @@ -360,7 +360,7 @@ A run drives a real session and a vision model across many turns, so it costs a - + diff --git a/content/docs/cookbook/playwright.mdx b/content/docs/cookbook/playwright.mdx index 7db6086c..11df4cdc 100644 --- a/content/docs/cookbook/playwright.mdx +++ b/content/docs/cookbook/playwright.mdx @@ -3,13 +3,13 @@ title: Automate a cloud browser with Playwright description: Use Steel with Playwright in TypeScript for cloud browser automation. --- - + - + @@ -81,7 +81,7 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the - + @@ -156,7 +156,7 @@ One run costs a few cents of session time. Steel bills per session-minute, which - + @@ -173,7 +173,7 @@ Steel returns a context with a page already open, so there is no `NewContext` / ## The driver, not the browser -playwright-go is not a pure-Go CDP client the way [chromedp](/cookbook/chromedp) and [go-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`: +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 { @@ -230,7 +230,7 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the ## Related -[chromedp](/cookbook/chromedp) and [go-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. +[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. @@ -241,5 +241,5 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the - + diff --git a/content/docs/cookbook/profiles.mdx b/content/docs/cookbook/profiles.mdx index 40f3541b..760e7572 100644 --- a/content/docs/cookbook/profiles.mdx +++ b/content/docs/cookbook/profiles.mdx @@ -3,13 +3,13 @@ title: Persist authenticated sessions with Profiles description: Maintain authenticated sessions across Steel browser instances using profiles. --- - + - + @@ -125,7 +125,7 @@ Three recipes handle "start the browser already signed in." Pick by lifetime: - + @@ -217,7 +217,7 @@ Other ports of this recipe: [profiles-ts](/cookbook/profiles) (interactive picke - + @@ -291,7 +291,7 @@ A full round trip takes ~30 seconds. Both sessions go through `client.sessions() - + diff --git a/content/docs/cookbook/puppeteer.mdx b/content/docs/cookbook/puppeteer.mdx index 0e417e2f..4135111d 100644 --- a/content/docs/cookbook/puppeteer.mdx +++ b/content/docs/cookbook/puppeteer.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with Puppeteer description: Use Steel with Puppeteer in TypeScript for cloud browser automation. --- - + - + @@ -78,5 +78,5 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the - + diff --git a/content/docs/cookbook/pydantic-ai.mdx b/content/docs/cookbook/pydantic-ai.mdx index c48b62d7..3a05e7f0 100644 --- a/content/docs/cookbook/pydantic-ai.mdx +++ b/content/docs/cookbook/pydantic-ai.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with Pydantic AI description: Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection. --- - + - + diff --git a/content/docs/cookbook/rig.mdx b/content/docs/cookbook/rig.mdx index e25f966d..8ccc1cc8 100644 --- a/content/docs/cookbook/rig.mdx +++ b/content/docs/cookbook/rig.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with rig description: 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. --- - + - + @@ -100,7 +100,7 @@ A run costs a few cents of browser time plus the Anthropic tokens for up to eigh ## Related recipes + - diff --git a/content/docs/cookbook/rod.mdx b/content/docs/cookbook/rod.mdx index 1553028a..1abcfece 100644 --- a/content/docs/cookbook/rod.mdx +++ b/content/docs/cookbook/rod.mdx @@ -1,15 +1,15 @@ --- -title: Automate a cloud browser with go-rod -description: "Use Steel with go-rod's fluent, chainable API to connect over CDP and scrape quotes.toscrape.com from a cloud browser." +title: Automate a cloud browser with Rod +description: "Use Steel with Rod's fluent, chainable API to connect over CDP and scrape quotes.toscrape.com from a cloud browser." --- - + - + -go-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 go-rod the session's websocket URL with your key appended, and the rest of your code is ordinary go-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. +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. ```go cdpURL := fmt.Sprintf("%s&apiKey=%s", session.WebsocketURL, apiKey) @@ -23,7 +23,7 @@ page := browser.MustPage("https://quotes.toscrape.com").MustWaitStable() ## 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. go-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. +`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. @@ -35,7 +35,7 @@ session, err := client.Sessions.Create(ctx, steel.SessionCreateParams{ 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 go-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 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 @@ -53,9 +53,9 @@ for i, card := range cards { } ``` -`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 go-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. +`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: go-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. +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 @@ -78,7 +78,7 @@ Your output varies with the site. Structure looks like this: Creating Steel session... Session live at https://app.steel.dev/sessions/ab12cd34... -Connected to browser via go-rod +Connected to browser via Rod Scraping quotes.toscrape.com... Found 10 quotes on the page: @@ -111,7 +111,7 @@ A run takes a few seconds and costs a few cents of browser time. Steel bills per ## 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 [go-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. +[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 diff --git a/content/docs/cookbook/scrape.mdx b/content/docs/cookbook/scrape.mdx index 0f219822..014c452e 100644 --- a/content/docs/cookbook/scrape.mdx +++ b/content/docs/cookbook/scrape.mdx @@ -3,13 +3,13 @@ title: Scrape a page to Markdown, screenshot, and PDF description: "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." --- - + - + @@ -116,7 +116,7 @@ Each of the three calls is one billed request against Steel, so a full run costs - + @@ -185,7 +185,7 @@ The other recipes in the cookbook connect a browser library (Playwright, Seleniu - + @@ -259,7 +259,7 @@ Three calls cost a few cents of browser time total. Steel bills per session-minu - + @@ -332,7 +332,7 @@ A scrape call costs a few cents of browser time. Steel starts and tears down the ## 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. +[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. diff --git a/content/docs/cookbook/selenium.mdx b/content/docs/cookbook/selenium.mdx index d95511be..334ca625 100644 --- a/content/docs/cookbook/selenium.mdx +++ b/content/docs/cookbook/selenium.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with Selenium description: Use Steel with Selenium in Python for cloud browser automation. --- - + - + @@ -101,5 +101,5 @@ A run costs a few cents of session time. Steel bills per session-minute, so `mai - + diff --git a/content/docs/cookbook/stagehand.mdx b/content/docs/cookbook/stagehand.mdx index b4b8a31f..bb1950d3 100644 --- a/content/docs/cookbook/stagehand.mdx +++ b/content/docs/cookbook/stagehand.mdx @@ -3,13 +3,13 @@ title: Automate browsing with natural-language instructions using Stagehand description: Use Steel with Stagehand for natural-language-driven AI browser automation. --- - + - + @@ -105,7 +105,7 @@ A full run takes ~30 seconds and costs a few cents of Steel session time plus Op - + @@ -252,5 +252,5 @@ A full run takes ~30 seconds. The `finally` block in `main()` calls `stagehand.s - + diff --git a/content/docs/cookbook/swiftide.mdx b/content/docs/cookbook/swiftide.mdx index 5f4a703c..94d8c15b 100644 --- a/content/docs/cookbook/swiftide.mdx +++ b/content/docs/cookbook/swiftide.mdx @@ -3,9 +3,9 @@ title: Build a research agent with Swiftide description: "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." --- - + - + @@ -102,7 +102,7 @@ Steel's request builders implement `IntoFuture` with a `Send` future, so `client ## Related recipes + - diff --git a/content/docs/cookbook/topics/agents.mdx b/content/docs/cookbook/topics/agents.mdx index d1bb3801..7e01b02f 100644 --- a/content/docs/cookbook/topics/agents.mdx +++ b/content/docs/cookbook/topics/agents.mdx @@ -4,6 +4,7 @@ description: Agent frameworks that run a perception-plan-act loop against a Stee --- + diff --git a/content/docs/cookbook/topics/browser-automation.mdx b/content/docs/cookbook/topics/browser-automation.mdx index f3ce87ab..0f44cf7f 100644 --- a/content/docs/cookbook/topics/browser-automation.mdx +++ b/content/docs/cookbook/topics/browser-automation.mdx @@ -6,7 +6,7 @@ description: "Drive a cloud browser with familiar automation libraries: Playwrig - + diff --git a/content/docs/cookbook/topics/mcp.mdx b/content/docs/cookbook/topics/mcp.mdx new file mode 100644 index 00000000..66ed0f4f --- /dev/null +++ b/content/docs/cookbook/topics/mcp.mdx @@ -0,0 +1,8 @@ +--- +title: MCP +description: 1 recipe tagged MCP. +--- + + + + diff --git a/content/docs/cookbook/topics/meta.json b/content/docs/cookbook/topics/meta.json index 53e7dcc5..83e0bbb7 100644 --- a/content/docs/cookbook/topics/meta.json +++ b/content/docs/cookbook/topics/meta.json @@ -8,6 +8,7 @@ "captchas", "computer-use", "convex", + "mcp", "mobile", "nextjs", "playwright", diff --git a/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx b/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx index 55164dd4..170a66a1 100644 --- a/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx +++ b/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx @@ -3,9 +3,9 @@ title: Stream a browser agent into a Next.js chat app description: A Next.js App Router chat app where a Vercel AI SDK agent drives a Steel cloud browser with embedded Live View. --- - + - + diff --git a/content/docs/cookbook/vercel-ai-sdk.mdx b/content/docs/cookbook/vercel-ai-sdk.mdx index 1144e6a1..a04e344b 100644 --- a/content/docs/cookbook/vercel-ai-sdk.mdx +++ b/content/docs/cookbook/vercel-ai-sdk.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with the Vercel AI SDK description: Use Steel with the Vercel AI SDK v6 ToolLoopAgent for typed, tool-using browser agents. --- - + - + diff --git a/content/docs/cookbook/you-com-search.mdx b/content/docs/cookbook/you-com-search.mdx index e8571ec8..c452c5e6 100644 --- a/content/docs/cookbook/you-com-search.mdx +++ b/content/docs/cookbook/you-com-search.mdx @@ -3,9 +3,9 @@ title: Combine You.com search with Steel browser actions description: 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. --- - + - + @@ -133,7 +133,7 @@ If the agent answers from search and contents alone, the `open_session`, `naviga ## Related recipes + - diff --git a/cookbook.lock.json b/cookbook.lock.json index ab911247..2d9ce52c 100644 --- a/cookbook.lock.json +++ b/cookbook.lock.json @@ -1,5 +1,5 @@ { "repo": "steel-dev/steel-cookbook", "ref": "main", - "sha": "b9235a859ef0ff94fccc4b443b10b7341f16209e" + "sha": "6bf72c7152ad4f37e283113f231eae56c1655f94" } diff --git a/niko/faq/faq-preview.png b/niko/faq/faq-preview.png deleted file mode 100644 index 86137508c8bcf2ad6f886c1553d002bc378420dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 122670 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 Date: Wed, 24 Jun 2026 21:16:11 +0900 Subject: [PATCH 11/11] chore: bump cookbook version --- content/docs/cookbook/agentkit.mdx | 4 ++-- content/docs/cookbook/agno.mdx | 4 ++-- content/docs/cookbook/auth-context.mdx | 10 +++++----- content/docs/cookbook/browser-use-captcha-auto.mdx | 4 ++-- content/docs/cookbook/browser-use-captcha-manual.mdx | 4 ++-- content/docs/cookbook/browser-use.mdx | 4 ++-- content/docs/cookbook/chromedp.mdx | 4 ++-- content/docs/cookbook/chromiumoxide.mdx | 4 ++-- content/docs/cookbook/claude-agent-sdk.mdx | 6 +++--- content/docs/cookbook/claude-computer-use-mobile.mdx | 4 ++-- content/docs/cookbook/claude-computer-use.mdx | 11 +++++------ content/docs/cookbook/convex-chat-with-page.mdx | 4 ++-- content/docs/cookbook/convex-price-watch.mdx | 4 ++-- content/docs/cookbook/credentials.mdx | 10 +++++----- content/docs/cookbook/crewai.mdx | 4 ++-- content/docs/cookbook/deep-research.mdx | 6 +++--- content/docs/cookbook/eino.mdx | 4 ++-- content/docs/cookbook/extensions.mdx | 10 +++++----- content/docs/cookbook/files.mdx | 10 +++++----- content/docs/cookbook/gemini-computer-use.mdx | 10 +++++----- content/docs/cookbook/genkit.mdx | 4 ++-- content/docs/cookbook/google-adk.mdx | 8 ++++---- content/docs/cookbook/headless-chrome.mdx | 4 ++-- content/docs/cookbook/langchaingo.mdx | 4 ++-- content/docs/cookbook/langgraph.mdx | 4 ++-- content/docs/cookbook/magnitude.mdx | 4 ++-- content/docs/cookbook/mastra.mdx | 4 ++-- content/docs/cookbook/mcp.mdx | 10 +++++----- content/docs/cookbook/microsoft-agent-framework.mdx | 4 ++-- content/docs/cookbook/notte.mdx | 4 ++-- content/docs/cookbook/openai-agents.mdx | 6 +++--- content/docs/cookbook/openai-computer-use.mdx | 12 ++++++------ content/docs/cookbook/playwright.mdx | 8 ++++---- content/docs/cookbook/profiles.mdx | 10 +++++----- content/docs/cookbook/puppeteer.mdx | 4 ++-- content/docs/cookbook/pydantic-ai.mdx | 4 ++-- content/docs/cookbook/rig.mdx | 4 ++-- content/docs/cookbook/rod.mdx | 4 ++-- content/docs/cookbook/scrape.mdx | 10 +++++----- content/docs/cookbook/selenium.mdx | 4 ++-- content/docs/cookbook/stagehand.mdx | 6 +++--- content/docs/cookbook/swiftide.mdx | 4 ++-- content/docs/cookbook/vercel-ai-sdk-nextjs.mdx | 4 ++-- content/docs/cookbook/vercel-ai-sdk.mdx | 4 ++-- content/docs/cookbook/you-com-search.mdx | 4 ++-- cookbook.lock.json | 2 +- 46 files changed, 130 insertions(+), 131 deletions(-) diff --git a/content/docs/cookbook/agentkit.mdx b/content/docs/cookbook/agentkit.mdx index 72323981..5ca0d51b 100644 --- a/content/docs/cookbook/agentkit.mdx +++ b/content/docs/cookbook/agentkit.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Inngest AgentKit description: "Integrate Steel with Inngest's AgentKit framework." --- - + - + diff --git a/content/docs/cookbook/agno.mdx b/content/docs/cookbook/agno.mdx index b062e0e1..cefedd49 100644 --- a/content/docs/cookbook/agno.mdx +++ b/content/docs/cookbook/agno.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Agno description: Integrate Steel with the Agno agent framework. --- - + - + diff --git a/content/docs/cookbook/auth-context.mdx b/content/docs/cookbook/auth-context.mdx index 5a6b1f3b..f3c2eb47 100644 --- a/content/docs/cookbook/auth-context.mdx +++ b/content/docs/cookbook/auth-context.mdx @@ -3,13 +3,13 @@ title: Reuse authenticated sessions across browsers description: Maintain authenticated sessions across Steel browser instances by capturing and reusing cookies and local storage. --- - + - + @@ -95,7 +95,7 @@ If you want Steel to store credentials and handle the login itself, see [credent - + @@ -165,7 +165,7 @@ A run takes about 20 seconds and costs a few cents of session time. Both session - + @@ -223,7 +223,7 @@ A run takes ~20 seconds. Both sessions go through `client.sessions().release(... - + diff --git a/content/docs/cookbook/browser-use-captcha-auto.mdx b/content/docs/cookbook/browser-use-captcha-auto.mdx index f0ee555a..829ad446 100644 --- a/content/docs/cookbook/browser-use-captcha-auto.mdx +++ b/content/docs/cookbook/browser-use-captcha-auto.mdx @@ -3,9 +3,9 @@ title: Solve CAPTCHAs automatically in a Browser Use agent description: Build an AI agent with browser-use and Steel that solves CAPTCHAs automatically. --- - + - + diff --git a/content/docs/cookbook/browser-use-captcha-manual.mdx b/content/docs/cookbook/browser-use-captcha-manual.mdx index fcaa68c5..a4f2c535 100644 --- a/content/docs/cookbook/browser-use-captcha-manual.mdx +++ b/content/docs/cookbook/browser-use-captcha-manual.mdx @@ -3,9 +3,9 @@ title: Solve reCAPTCHA v2 manually with Browser Use description: "Manually solve reCAPTCHA v2 using Steel's CAPTCHA API with the browser-use framework." --- - + - + diff --git a/content/docs/cookbook/browser-use.mdx b/content/docs/cookbook/browser-use.mdx index 19b2c753..5c11a4ec 100644 --- a/content/docs/cookbook/browser-use.mdx +++ b/content/docs/cookbook/browser-use.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Browser Use description: Integrate Steel with the browser-use framework for AI-driven web automation. --- - + - + diff --git a/content/docs/cookbook/chromedp.mdx b/content/docs/cookbook/chromedp.mdx index 3b94d7e3..f98c5b17 100644 --- a/content/docs/cookbook/chromedp.mdx +++ b/content/docs/cookbook/chromedp.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with chromedp description: Use Steel with chromedp to connect over CDP, navigate to Hacker News, extract the top stories, and capture a screenshot. --- - + - + diff --git a/content/docs/cookbook/chromiumoxide.mdx b/content/docs/cookbook/chromiumoxide.mdx index 97b4803a..a5248b9f 100644 --- a/content/docs/cookbook/chromiumoxide.mdx +++ b/content/docs/cookbook/chromiumoxide.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with chromiumoxide description: Use Steel with chromiumoxide to connect over CDP, drive the handler task, extract page content, and capture a screenshot. --- - + - + diff --git a/content/docs/cookbook/claude-agent-sdk.mdx b/content/docs/cookbook/claude-agent-sdk.mdx index f9e3f369..457e024a 100644 --- a/content/docs/cookbook/claude-agent-sdk.mdx +++ b/content/docs/cookbook/claude-agent-sdk.mdx @@ -3,13 +3,13 @@ title: Build a browser agent with the Claude Agent SDK description: "Use Steel with the Claude Agent SDK (TypeScript) to build a tool-using browser agent on Anthropic's first-party agent loop." --- - + - + @@ -120,7 +120,7 @@ A run takes ~25 to 45 seconds and 3 to 6 turns. Cost is Steel session-minutes pl - + diff --git a/content/docs/cookbook/claude-computer-use-mobile.mdx b/content/docs/cookbook/claude-computer-use-mobile.mdx index c409df2f..a46af5ee 100644 --- a/content/docs/cookbook/claude-computer-use-mobile.mdx +++ b/content/docs/cookbook/claude-computer-use-mobile.mdx @@ -3,9 +3,9 @@ title: Drive a mobile browser with Claude Computer Use description: Claude Computer Use with Steel for autonomous task execution in mobile browser environments. --- - + - + diff --git a/content/docs/cookbook/claude-computer-use.mdx b/content/docs/cookbook/claude-computer-use.mdx index 1752cc6f..5a020d70 100644 --- a/content/docs/cookbook/claude-computer-use.mdx +++ b/content/docs/cookbook/claude-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with Claude Computer Use description: Connect Claude to a Steel browser session for autonomous web interactions. --- - + - + @@ -118,7 +118,7 @@ Expect ~60-120 seconds and 15-40 iterations for a simple browsing task. - + @@ -236,7 +236,7 @@ A run typically takes 60-180 seconds and 10-30 loop iterations. - + @@ -268,7 +268,6 @@ Going the other direction, `execute_computer_action` reads that loose `input` an ```rust "left_click" | "right_click" | "middle_click" | "double_click" | "triple_click" => { SessionComputerParams::ClickMouse(ComputerActionRequestClickMouse { - action: ComputerActionRequestVariant1Action::ClickMouse, button: Some(button), coordinates: Some(vec![coords.0, coords.1]), num_clicks, @@ -356,7 +355,7 @@ Expect 60 to 180 seconds and 10 to 30 iterations for a simple browse, plus Anthr - + diff --git a/content/docs/cookbook/convex-chat-with-page.mdx b/content/docs/cookbook/convex-chat-with-page.mdx index 156dce7c..3ebe957e 100644 --- a/content/docs/cookbook/convex-chat-with-page.mdx +++ b/content/docs/cookbook/convex-chat-with-page.mdx @@ -3,9 +3,9 @@ title: Chat with any webpage on Convex description: "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." --- - + - + diff --git a/content/docs/cookbook/convex-price-watch.mdx b/content/docs/cookbook/convex-price-watch.mdx index 408d2660..1f1352f6 100644 --- a/content/docs/cookbook/convex-price-watch.mdx +++ b/content/docs/cookbook/convex-price-watch.mdx @@ -3,9 +3,9 @@ title: Watch Claude pricing for divergent A/B variants description: 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. --- - + - + diff --git a/content/docs/cookbook/credentials.mdx b/content/docs/cookbook/credentials.mdx index cdde699f..aeec6e85 100644 --- a/content/docs/cookbook/credentials.mdx +++ b/content/docs/cookbook/credentials.mdx @@ -3,13 +3,13 @@ title: Automate logins with the Credentials API description: Use the Steel Credentials API with Playwright to automate flows with stored credentials. --- - + - + @@ -102,7 +102,7 @@ Reach for credentials when you want a stable, long-lived setup tied to an accoun - + @@ -180,7 +180,7 @@ Both persist a login across runs, by different means. Credentials stores a usern - + @@ -254,7 +254,7 @@ On a second run the first lines read `Credential already exists, moving on`; the - + diff --git a/content/docs/cookbook/crewai.mdx b/content/docs/cookbook/crewai.mdx index c372d622..e4a3d55f 100644 --- a/content/docs/cookbook/crewai.mdx +++ b/content/docs/cookbook/crewai.mdx @@ -3,9 +3,9 @@ title: Build a multi-agent browser workflow with CrewAI description: Integrate Steel with the CrewAI multi-agent framework. --- - + - + diff --git a/content/docs/cookbook/deep-research.mdx b/content/docs/cookbook/deep-research.mdx index dc9dcb43..a23440c6 100644 --- a/content/docs/cookbook/deep-research.mdx +++ b/content/docs/cookbook/deep-research.mdx @@ -3,13 +3,13 @@ title: Deep research with Claude Agent SDK subagents description: Lead orchestrator dispatches parallel researcher subagents, each driving its own Steel browser, and synthesizes findings into a cited Markdown report. --- - + - + @@ -177,7 +177,7 @@ A run takes ~4 to 6 minutes wall-clock with 3 Steel sessions in parallel. Cost i - + diff --git a/content/docs/cookbook/eino.mdx b/content/docs/cookbook/eino.mdx index c81f6cba..80651ca9 100644 --- a/content/docs/cookbook/eino.mdx +++ b/content/docs/cookbook/eino.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Eino description: "Use Steel with the ByteDance Eino framework to build a ReAct agent that calls Steel's scrape API as a tool to research and answer a web question." --- - + - + diff --git a/content/docs/cookbook/extensions.mdx b/content/docs/cookbook/extensions.mdx index 20c21869..e9c38905 100644 --- a/content/docs/cookbook/extensions.mdx +++ b/content/docs/cookbook/extensions.mdx @@ -3,13 +3,13 @@ title: Upload and run browser extensions description: Use the Steel Extensions API with Playwright to upload and run browser extensions. --- - + - + @@ -99,7 +99,7 @@ A run takes ~20 seconds and costs a few cents of session time. First run uploads - + @@ -174,7 +174,7 @@ A run takes ~20 seconds and costs a few cents of session time. The first run upl - + @@ -245,7 +245,7 @@ The first run uploads the extension; later runs print `Reusing uploaded extensio - + diff --git a/content/docs/cookbook/files.mdx b/content/docs/cookbook/files.mdx index acef9ce9..8fe1c296 100644 --- a/content/docs/cookbook/files.mdx +++ b/content/docs/cookbook/files.mdx @@ -3,13 +3,13 @@ title: Move files between your machine and a cloud browser description: Use the Steel Files API with Playwright to automate file uploads and downloads in the cloud. --- - + - + @@ -105,7 +105,7 @@ There's also `client.files` (without `.sessions`), an organization-scoped store - + @@ -189,7 +189,7 @@ Done! - + @@ -272,7 +272,7 @@ Session released - + diff --git a/content/docs/cookbook/gemini-computer-use.mdx b/content/docs/cookbook/gemini-computer-use.mdx index fc5aefba..bddea390 100644 --- a/content/docs/cookbook/gemini-computer-use.mdx +++ b/content/docs/cookbook/gemini-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with Gemini Computer Use description: "Connect Google's Gemini Computer Use to a Steel browser session for autonomous web interactions." --- - + - + @@ -114,7 +114,7 @@ Expect roughly 60-120 seconds and 15-40 turns for a simple browsing task. - + @@ -237,7 +237,7 @@ A run typically takes 60-180 seconds and 10-30 iterations. Because `generate_con - + @@ -320,7 +320,7 @@ Expect roughly 60 to 120 seconds and 15 to 40 turns for a simple browsing task. - + diff --git a/content/docs/cookbook/genkit.mdx b/content/docs/cookbook/genkit.mdx index 37977095..64a313ef 100644 --- a/content/docs/cookbook/genkit.mdx +++ b/content/docs/cookbook/genkit.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Genkit description: Use Steel with Genkit Go to build a tool-calling agent that navigates and extracts from a chromedp-backed browser and completes a web task. --- - + - + diff --git a/content/docs/cookbook/google-adk.mdx b/content/docs/cookbook/google-adk.mdx index ff5a6d1d..3ab78f0e 100644 --- a/content/docs/cookbook/google-adk.mdx +++ b/content/docs/cookbook/google-adk.mdx @@ -3,13 +3,13 @@ title: Build a browser agent with Google ADK description: "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." --- - + - + @@ -114,7 +114,7 @@ This agent has no `outputSchema`. ADK disables tool calls when an output schema - + @@ -250,7 +250,7 @@ A run takes ~20 to 40 seconds and a handful of agent turns on Hacker News. Cost - + diff --git a/content/docs/cookbook/headless-chrome.mdx b/content/docs/cookbook/headless-chrome.mdx index 5238c1db..aa8775de 100644 --- a/content/docs/cookbook/headless-chrome.mdx +++ b/content/docs/cookbook/headless-chrome.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with headless_chrome description: Use Steel with headless_chrome, the synchronous Rust equivalent of Puppeteer, to connect over CDP and scrape quotes with element handles. --- - + - + diff --git a/content/docs/cookbook/langchaingo.mdx b/content/docs/cookbook/langchaingo.mdx index 5d6b4626..d26e644e 100644 --- a/content/docs/cookbook/langchaingo.mdx +++ b/content/docs/cookbook/langchaingo.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with LangChainGo description: "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." --- - + - + diff --git a/content/docs/cookbook/langgraph.mdx b/content/docs/cookbook/langgraph.mdx index a4a0ef7d..19de43ff 100644 --- a/content/docs/cookbook/langgraph.mdx +++ b/content/docs/cookbook/langgraph.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with LangGraph description: Use Steel with LangGraph to build a typed browser agent with an explicit state-machine loop and a structured-output formatter node. --- - + - + diff --git a/content/docs/cookbook/magnitude.mdx b/content/docs/cookbook/magnitude.mdx index 06427730..07f38db5 100644 --- a/content/docs/cookbook/magnitude.mdx +++ b/content/docs/cookbook/magnitude.mdx @@ -3,9 +3,9 @@ title: Build an AI browser agent with Magnitude description: Use Steel with Magnitude for AI-powered browser automation. --- - + - + diff --git a/content/docs/cookbook/mastra.mdx b/content/docs/cookbook/mastra.mdx index 07cb209b..487459c3 100644 --- a/content/docs/cookbook/mastra.mdx +++ b/content/docs/cookbook/mastra.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with Mastra description: Use Steel with Mastra to build a typed browser agent with the Mastra Model Router and Studio playground. --- - + - + diff --git a/content/docs/cookbook/mcp.mdx b/content/docs/cookbook/mcp.mdx index 5bf6ff0c..4732dcf8 100644 --- a/content/docs/cookbook/mcp.mdx +++ b/content/docs/cookbook/mcp.mdx @@ -3,13 +3,13 @@ title: Expose a Steel browser to any MCP client description: Build a Model Context Protocol server in Go with the official SDK and chromedp that hands any MCP client a Steel cloud browser through explicit session-handle tools. --- - + - + @@ -79,7 +79,7 @@ Restart the client and ask it to open a page and read it back. It calls `create_ - + @@ -145,7 +145,7 @@ Restart the client and ask it to open a page and read it back. It calls `create_ - + @@ -204,7 +204,7 @@ Restart the client and ask it to open a page and read it back. It calls `create_ - + diff --git a/content/docs/cookbook/microsoft-agent-framework.mdx b/content/docs/cookbook/microsoft-agent-framework.mdx index e7d66344..78b4895f 100644 --- a/content/docs/cookbook/microsoft-agent-framework.mdx +++ b/content/docs/cookbook/microsoft-agent-framework.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with Microsoft Agent Framework description: Use Steel with Microsoft Agent Framework 1.0 (the successor to AutoGen and Semantic Kernel) to build a tool-using browser agent. --- - + - + diff --git a/content/docs/cookbook/notte.mdx b/content/docs/cookbook/notte.mdx index a950b5ad..a35dad6c 100644 --- a/content/docs/cookbook/notte.mdx +++ b/content/docs/cookbook/notte.mdx @@ -3,9 +3,9 @@ title: "Control a browser with Notte's reasoning engine" description: "Control browsers with AI using Steel's infrastructure and Notte's reasoning engine." --- - + - + diff --git a/content/docs/cookbook/openai-agents.mdx b/content/docs/cookbook/openai-agents.mdx index 4cfc96b9..0e457003 100644 --- a/content/docs/cookbook/openai-agents.mdx +++ b/content/docs/cookbook/openai-agents.mdx @@ -3,13 +3,13 @@ title: Build a typed browser agent with the OpenAI Agents SDK description: Use Steel with the OpenAI Agents SDK for TypeScript to build typed, tool-using browser agents. --- - + - + @@ -96,7 +96,7 @@ A full run is ~20-40 seconds. Cost is a few cents of Steel session time plus Ope - + diff --git a/content/docs/cookbook/openai-computer-use.mdx b/content/docs/cookbook/openai-computer-use.mdx index 8be41322..67f10db6 100644 --- a/content/docs/cookbook/openai-computer-use.mdx +++ b/content/docs/cookbook/openai-computer-use.mdx @@ -3,13 +3,13 @@ title: Drive a browser with OpenAI Computer Use description: "Connect OpenAI's Computer Use Assistant to a Steel browser session for autonomous web interactions." --- - + - + @@ -139,7 +139,7 @@ Expect roughly 60-120 seconds and 15-40 turns for a simple browsing task. - + @@ -265,7 +265,7 @@ A run typically takes 60-180 seconds and 10-30 iterations. Screenshots are cache - + @@ -360,7 +360,7 @@ A run drives a real session and a vision model across many turns, so it costs a - + @@ -371,7 +371,7 @@ The interesting part in Go is the seam between the two SDKs, because each models ```go case "click": body := &steel.ComputerActionRequestClickMouse{ - Action: steel.ComputerActionRequestVariant1ActionClickMouse, + Action: steel.ComputerActionRequestClickMouseActionClickMouse, Button: ptr(mapButton(act.Button)), Coordinates: coords(), Screenshot: ptr(true), diff --git a/content/docs/cookbook/playwright.mdx b/content/docs/cookbook/playwright.mdx index 11df4cdc..4f5d0566 100644 --- a/content/docs/cookbook/playwright.mdx +++ b/content/docs/cookbook/playwright.mdx @@ -3,13 +3,13 @@ title: Automate a cloud browser with Playwright description: Use Steel with Playwright in TypeScript for cloud browser automation. --- - + - + @@ -81,7 +81,7 @@ A run costs a few cents of browser time. Steel bills per session-minute, so the - + @@ -156,7 +156,7 @@ One run costs a few cents of session time. Steel bills per session-minute, which - + diff --git a/content/docs/cookbook/profiles.mdx b/content/docs/cookbook/profiles.mdx index 760e7572..aa373c0a 100644 --- a/content/docs/cookbook/profiles.mdx +++ b/content/docs/cookbook/profiles.mdx @@ -3,13 +3,13 @@ title: Persist authenticated sessions with Profiles description: Maintain authenticated sessions across Steel browser instances using profiles. --- - + - + @@ -125,7 +125,7 @@ Three recipes handle "start the browser already signed in." Pick by lifetime: - + @@ -217,7 +217,7 @@ Other ports of this recipe: [profiles-ts](/cookbook/profiles) (interactive picke - + @@ -291,7 +291,7 @@ A full round trip takes ~30 seconds. Both sessions go through `client.sessions() - + diff --git a/content/docs/cookbook/puppeteer.mdx b/content/docs/cookbook/puppeteer.mdx index 4135111d..ee9dd926 100644 --- a/content/docs/cookbook/puppeteer.mdx +++ b/content/docs/cookbook/puppeteer.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with Puppeteer description: Use Steel with Puppeteer in TypeScript for cloud browser automation. --- - + - + diff --git a/content/docs/cookbook/pydantic-ai.mdx b/content/docs/cookbook/pydantic-ai.mdx index 3a05e7f0..6382e871 100644 --- a/content/docs/cookbook/pydantic-ai.mdx +++ b/content/docs/cookbook/pydantic-ai.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with Pydantic AI description: Use Steel with Pydantic AI to build typed, provider-agnostic browser agents with dependency injection. --- - + - + diff --git a/content/docs/cookbook/rig.mdx b/content/docs/cookbook/rig.mdx index 8ccc1cc8..3dedaed2 100644 --- a/content/docs/cookbook/rig.mdx +++ b/content/docs/cookbook/rig.mdx @@ -3,9 +3,9 @@ title: Build a browser agent with rig description: 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. --- - + - + diff --git a/content/docs/cookbook/rod.mdx b/content/docs/cookbook/rod.mdx index 1abcfece..dc151e93 100644 --- a/content/docs/cookbook/rod.mdx +++ b/content/docs/cookbook/rod.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with Rod description: "Use Steel with Rod's fluent, chainable API to connect over CDP and scrape quotes.toscrape.com from a cloud browser." --- - + - + diff --git a/content/docs/cookbook/scrape.mdx b/content/docs/cookbook/scrape.mdx index 014c452e..ac47b6fd 100644 --- a/content/docs/cookbook/scrape.mdx +++ b/content/docs/cookbook/scrape.mdx @@ -3,13 +3,13 @@ title: Scrape a page to Markdown, screenshot, and PDF description: "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." --- - + - + @@ -116,7 +116,7 @@ Each of the three calls is one billed request against Steel, so a full run costs - + @@ -185,7 +185,7 @@ The other recipes in the cookbook connect a browser library (Playwright, Seleniu - + @@ -259,7 +259,7 @@ Three calls cost a few cents of browser time total. Steel bills per session-minu - + diff --git a/content/docs/cookbook/selenium.mdx b/content/docs/cookbook/selenium.mdx index 334ca625..4ab5bc15 100644 --- a/content/docs/cookbook/selenium.mdx +++ b/content/docs/cookbook/selenium.mdx @@ -3,9 +3,9 @@ title: Automate a cloud browser with Selenium description: Use Steel with Selenium in Python for cloud browser automation. --- - + - + diff --git a/content/docs/cookbook/stagehand.mdx b/content/docs/cookbook/stagehand.mdx index bb1950d3..7c67a81e 100644 --- a/content/docs/cookbook/stagehand.mdx +++ b/content/docs/cookbook/stagehand.mdx @@ -3,13 +3,13 @@ title: Automate browsing with natural-language instructions using Stagehand description: Use Steel with Stagehand for natural-language-driven AI browser automation. --- - + - + @@ -105,7 +105,7 @@ A full run takes ~30 seconds and costs a few cents of Steel session time plus Op - + diff --git a/content/docs/cookbook/swiftide.mdx b/content/docs/cookbook/swiftide.mdx index 94d8c15b..c4c45c30 100644 --- a/content/docs/cookbook/swiftide.mdx +++ b/content/docs/cookbook/swiftide.mdx @@ -3,9 +3,9 @@ title: Build a research agent with Swiftide description: "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." --- - + - + diff --git a/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx b/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx index 170a66a1..016f2ae5 100644 --- a/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx +++ b/content/docs/cookbook/vercel-ai-sdk-nextjs.mdx @@ -3,9 +3,9 @@ title: Stream a browser agent into a Next.js chat app description: A Next.js App Router chat app where a Vercel AI SDK agent drives a Steel cloud browser with embedded Live View. --- - + - + diff --git a/content/docs/cookbook/vercel-ai-sdk.mdx b/content/docs/cookbook/vercel-ai-sdk.mdx index a04e344b..9fc2e702 100644 --- a/content/docs/cookbook/vercel-ai-sdk.mdx +++ b/content/docs/cookbook/vercel-ai-sdk.mdx @@ -3,9 +3,9 @@ title: Build a typed browser agent with the Vercel AI SDK description: Use Steel with the Vercel AI SDK v6 ToolLoopAgent for typed, tool-using browser agents. --- - + - + diff --git a/content/docs/cookbook/you-com-search.mdx b/content/docs/cookbook/you-com-search.mdx index c452c5e6..2b3189a9 100644 --- a/content/docs/cookbook/you-com-search.mdx +++ b/content/docs/cookbook/you-com-search.mdx @@ -3,9 +3,9 @@ title: Combine You.com search with Steel browser actions description: 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. --- - + - + diff --git a/cookbook.lock.json b/cookbook.lock.json index 2d9ce52c..053a30b4 100644 --- a/cookbook.lock.json +++ b/cookbook.lock.json @@ -1,5 +1,5 @@ { "repo": "steel-dev/steel-cookbook", "ref": "main", - "sha": "6bf72c7152ad4f37e283113f231eae56c1655f94" + "sha": "e08de0575649d82b60ddfad343541f336589d919" }