Build a browser agent with rig
Use Steel with rig to build an agent that drives a cloud browser over CDP with chromiumoxide through navigate and extract tools, then answers a multi-step web task.
Scaffolds a starter project locally. Requires the Steel CLI.
rig 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. 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:
struct Navigate { page: chromiumoxide::Page }impl Tool for Navigate {const NAME: &'static str = "navigate";type Error = ToolError;type Args = NavigateArgs; // { url: String }, Deserializetype Output = NavigateOutput; // { url, title }, Serializeasync fn definition(&self, _prompt: String) -> ToolDefinition {ToolDefinition { name: Self::NAME.to_string(), description: "...", parameters: json!({ ... }) }}async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {self.page.goto(args.url).await.map_err(|e| ToolError(e.to_string()))?;self.page.wait_for_navigation().await.map_err(|e| ToolError(e.to_string()))?;// ... return the resolved url and page title}}
definition is the JSON Schema Claude sees; call is what runs when the model picks the tool. rig deserializes Args from the model's arguments and serializes Output back into the transcript, so those two types are the whole contract. ExtractText is the second tool: it runs document.body.innerText and a querySelectorAll('a[href]') snippet through page.evaluate(...).into_value(), returning capped body text plus up to 50 links so the model reads real anchors instead of guessing selectors.
Wiring the agent is one builder chain:
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
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
cd examples/rigcp .env.example .env # set STEEL_API_KEY and ANTHROPIC_API_KEYcargo run
Get a Steel key at app.steel.dev/settings/api-keys and an Anthropic key at console.anthropic.com. 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:
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
TASKand the preamble inmain.rs. The tools stay the same; the agent re-plans against the new goal. - Add a tool. A
clicktool (page.find_element(...).click()) or ascreenshottool (page.screenshot(...)) drops in as anotherimpl Tooland one more.tool(...)call. The model picks per turn. - Tune the reach. Raise
max_turnsto let it crawl deeper, or lower the link cap andmax_charsinextract_textto spend fewer tokens per read. - Change the model. Any Anthropic model id works in
.agent(...). rig also ships OpenAI, Gemini, and other providers; swap theanthropic::Clientfor one of those and the tools are unaffected.
Related
Steel + Swiftide (Rust) 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 is the same CDP browser without the agent layer. genkit and pydantic-ai are the tool-calling-agent shape in other languages. The rig docs cover the Tool trait, multi-turn prompting, and the provider list.
Related recipes
Run a durable browser agent with Restate
Build a Restate Virtual Object in TypeScript that uses durable OpenAI planning steps and Steel scraping to answer browser research questions.
Expose a Steel browser to any MCP client
Build a Model Context Protocol server in Go with the official SDK and chromedp that hands any MCP client a Steel cloud browser through explicit session-handle tools.
Build a browser agent with Genkit
Use Steel with Genkit Go to build a tool-calling agent that navigates and extracts from a chromedp-backed browser and completes a web task.