Build a research agent with Swiftide
Use Steel with Swiftide to build an agent whose tool reads the web through Steel's scrape endpoint, so the model works from clean Markdown with no browser library.
Scaffolds a starter project locally. Requires the Steel CLI.
Swiftide 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:
#[derive(Clone, swiftide::Tool)]#[tool(description = "Fetch a web page through a Steel cloud browser and return it as clean \Markdown along with the page's outbound links. Use this to read a URL.",param(name = "url", description = "Absolute URL of the page to read, including https://"))]struct ReadPage {client: Arc<Steel>,}impl ReadPage {async fn read_page(&self, _ctx: &dyn AgentContext, url: &str) -> Result<ToolOutput, ToolError> {let response = self.client.scrape(ClientScrapeParams {url: url.to_string(),format: Some(vec![ScrapeRequestFormatItem::Markdown]),..}).await?;// ... return response.content.markdown plus response.links}}
The derive macro reads the struct's snake-case name (ReadPage -> read_page), finds the method with that name, and turns each #[tool(param(...))] into a JSON Schema field via schemars. Anything that implements Tool slots into Agent::builder().tools(...), so a stateful struct and a #[swiftide::tool] free function are interchangeable at the call site. The struct form is what lets the tool hold Arc<Steel>; a free function has nowhere to put it.
Wiring the agent is four builder calls:
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
cd examples/swiftidecp .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. 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:
Steel + Swiftide research agent============================================================read_page: https://news.ycombinator.com (18243 chars)The highest-scoring story on the front page is "Show HN: ..." with 642points, 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 authorbuilt 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
TASKandSYSTEM_PROMPTinmain.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
screenshottool backed byclient.screenshot(...)(returns a base64 PNG) or apdftool backed byclient.pdf(...)drops in as another#[derive(Tool)]struct in thetools(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 theAnthropicbuilder for one of those and the tools are unaffected.
Related
Steel + rig (Rust) drives a real browser over CDP with chromiumoxide instead of the scrape endpoint. Swiftide agent docs cover hooks, the Tool trait, and multi-agent setups.
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.