Research Agent for the Open Web
import { Atlas } from "@steel-dev/atlas";
import { anthropic } from "@ai-sdk/anthropic";
const atlas = new Atlas({ model: anthropic("claude-fable-5") });
const { report } = await atlas.research(
"What's changing in browser automation for AI agents?",
);
One-off query, no install:
ANTHROPIC_API_KEY=sk-ant-... npx @steel-dev/atlas "How do reasoning models differ from standard LLMs?"
npm install @steel-dev/atlas ai @ai-sdk/anthropic
Search: tavily.search()
/ exa.search()
/ brave.search()
(auto from env keys), or native.search({ model })
to use the model provider's own web search.
Fetch: basic.fetch()
by default; steel.fetch()
for JS-rendered pages when STEEL_API_KEY
is set.
import { Atlas, exa, steel, basic } from "@steel-dev/atlas";
const atlas = new Atlas({
model,
search: exa.search(),
fetch: [basic.fetch(), steel.fetch({ proxy: true })],
});
Register other research agents — including Atlas itself — as a fleet. Atlas decomposes the question, routes each sub-task to the best-fit researcher (query → report
), runs them in isolation, then synthesizes one report. With no researchers
set it stays a single spine run.
import { Atlas, exa, perplexity, parallel } from "@steel-dev/atlas";
const atlas = new Atlas({
model,
researchers: {
exa: exa.agent(), // Exa deep-research (via exa-js)
perplexity: perplexity.agent(), // Perplexity Sonar
parallel: parallel.agent(), // parallel.ai task API
},
});
await atlas.research("Compare X across academic and shopping angles");
Any query → report
worker plugs in via researcher({ description, research })
— description
drives routing. atlas.asResearcher(description)
exposes an Atlas instance as a worker, so fan-out can recurse. A worker returns { report, sources }
and may add cost
(USD).
Plug in domain sources with researchTool
: anything via ctx.addSource
flows through the same source store:
import { Atlas, researchTool } from "@steel-dev/atlas";
const atlas = new Atlas({
model,
tools: {
pubmed_search: researchTool({
description: "Search PubMed.",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }, ctx) => {
const studies = await pubmed.search(query, { signal: ctx.signal });
for (const s of studies)
ctx.addSource({ url: s.url, title: s.title, content: s.abstract });
return studies.map((s) => `- ${s.title}`).join("\n");
},
}),
},
});
ctx
(ToolContext
) provides addSource({ url, title?, content })
, fetchText(url)
(a guarded fetch returning extracted text or null
), log(message)
, and signal
.
Models per role: the top-level model
is the lead; override the stage models with models.research
and models.write
. Each defaults to the lead model when unset.
Z.ai / GLM: use Z.ai through its OpenAI-compatible endpoint:
import { createOpenAI } from "@ai-sdk/openai";
import { Atlas, tavily } from "@steel-dev/atlas";
const zai = createOpenAI({
apiKey: process.env.ZAI_API_KEY!,
baseURL: "https://api.z.ai/api/paas/v4",
});
const atlas = new Atlas({
model: zai.chat("glm-5.2"),
search: tavily.search(),
});
The examples also accept --provider zai
with ZAI_API_KEY
/ ATLAS_ZAI_API_KEY
. Configure tavily.search()
, exa.search()
, or brave.search()
for search; Atlas does not map Z.ai's web-search API to an AI SDK native search tool.
Concurrency: concurrency: { models: 4, io: 10 }
or ATLAS_MODEL_CONCURRENCY
/ ATLAS_IO_CONCURRENCY
.
const run = atlas.start(question, { effort: "balanced" });
let preview = "";
for await (const e of run.events()) {
if (e.type === "report.reset") preview = "";
if (e.type === "report.delta") preview += e.text;
if (e.type === "source.fetched") console.error(e.url);
}
const result = await run.result();
report.delta
carries the working draft as it is written and revised; report.reset
precedes each rewrite, so clear your preview buffer on it. report.completed
(and result.report
) is canonical after citation binding. run.finish()
synthesizes from whatever's gathered so far; run.abort()
discards it. Late subscribers get full event history.
Journaled runs replay completed model/search/fetch calls without new provider charges after a crash or deploy:
import { Atlas, fileStore } from "@steel-dev/atlas";
const store = fileStore("./runs");
const atlas = new Atlas({ model, store });
atlas.start(question, { runId: "run_42" });
// …restart…
await new Atlas({ model, store }).resume("run_42");
Replay issues no new provider calls, but it re-charges the run's original budget and token caps as it restores progress — so a resumed run continues within the headroom left when it stopped, and resume()
keeps those original caps (it doesn't take a higher budget). result.stats.costUSD
reports only the new spend, excluding replayed calls.
result.report; // cited markdown
result.note; // short note on how the research was approached
result.citations; // { sourceId, marker } per cited source — marker is its [N] in the report (same contract on single-spine and orchestrated runs)
result.unboundCitations; // source ids cited in the draft that didn't resolve to a fetched source
result.warnings; // non-fatal issues (e.g. a researcher that returned nothing)
result.sources; // sources backing the report (via = fetch method, or the researcher that returned it on a fleet run)
result.stats; // cost, tokens, duration, …
result.trace; // timing/cost trace + bottleneck digest when trace !== "off"
Pass a schema
to get a typed object extracted from the finished report, returned alongside the full result. It runs as a final pass over the report, so it works on any path — single spine run, orchestrated fleet, or an outsourced researcher — and its cost is charged to the same budget and counted in result.stats.costUSD
.
import { z } from "zod";
const r = await atlas.research("Acme's latest annual revenue and CEO?", {
schema: z.object({ revenue: z.string(), ceo: z.string() }),
});
r.object; // typed: { revenue: string; ceo: string }
r.report; // the cited report it was extracted from
One meter for everything. Pick an effort, or override any cap with budget
.
| effort | ~budget | sources | tokens |
|---|---|---|---|
fast |
|||
| $0.50 | 15 | 200K | |
balanced |
|||
| $2.50 | 40 | 1M | |
deep |
|||
| $10 | 100 | 4M | |
max |
|||
| $40 | 250 | 16M |
await atlas.research(question, {
effort: "deep",
budget: { maxUSD: 5, maxTokens: 50_000_000 },
});
maxUSD
is a best-effort target, not a hard ceiling. The meter checks between agent turns, so a run stops starting work once spent, but in-flight calls (up to concurrency.models
) can overshoot. Pricing comes from a built-in table that can lag provider changes — pass pricing
to correct a rate when the cap must be accurate.
The real backstops are price-independent — each defaults to the effort row above and is enforced regardless of prices:
budget.maxTokens
— input + output tokens run-wide (cache reads excluded). Checked between turns likemaxUSD
, but never drifts with prices.budget.maxSources
,budget.maxDurationMs
— fetched-source and wall-clock caps.
result.stats
reports budgetExhausted
and tokensExhausted
so you can see which limit bound the run. Leave headroom on maxUSD
, or set a provider-side spend limit, when the cap is truly hard.
result.stats.stopReason
folds those into one value — "completed"
, "finished"
(run.finish()
), or a binding cap ("budget"
, "tokens"
, "timeout"
). When several apply, the most proximate wins.
git clone https://github.com/steel-dev/atlas.git && cd atlas
npm install && npm run dev -- "your question"
examples/cli.ts
: terminal runsexamples/serve.ts
: SSE web appevals/
: BrowseComp + DRACO (npm run eval:browsecomp
,npm run eval:draco
)
MIT