
Web research is slow. Reading four pages and synthesising an answer takes seconds, sometimes tens of seconds, and there is no clever trick that makes it instant.
What you can do is stop making people stare at a spinner while it happens. This tutorial builds a research assistant on the Vercel AI SDK that streams its answer as it forms, using Tabstack for the web access. The whole thing is about thirty lines.
What you need
- A Node project (the package ships dual ESM and CJS builds)
- A Tabstack API key from console.tabstack.ai
- An OpenAI key, or any other provider the AI SDK supports
npm install @tabstack/ai ai zod @ai-sdk/openaiexport TABSTACK_API_KEY="your-key-here"
export OPENAI_API_KEY="your-openai-key"ai and zod are peer dependencies, so your app keeps one instance of each. The package works with AI SDK v5 and later, and is v6 ready.
Step 1: give the model tools and get out of the way
The AI SDK expects tools to be an object keyed by tool name. That is exactly what tabstackTools is, so it passes straight through.
import { openai } from "@ai-sdk/openai";
import { generateText, stepCountIs } from "ai";
import { tabstackTools } from "@tabstack/ai";
const { text } = await generateText({
model: openai("gpt-4o"),
tools: tabstackTools,
stopWhen: stepCountIs(5),
prompt: "What are Vercel's pricing plans and how do they compare?",
});
console.log(text);That is a working web-connected agent. tabstackTools reads TABSTACK_API_KEY from the environment, so there is no client to construct.
The one line people leave out is stopWhen. Without it the SDK makes a single call, the model asks for a tool, and you get back a response containing a tool call and no text. stepCountIs(5) tells it to keep going: call the tool, take the result, and carry on until it has an actual answer. Five is a reasonable ceiling for research work. Raise it if your agent legitimately needs to chain several lookups, lower it if you want a hard cost bound.
Step 2: stream it
generateText waits for everything. For anything user-facing, swap to streamText and start showing output immediately.
import { openai } from "@ai-sdk/openai";
import { streamText, stepCountIs } from "ai";
import { tabstackTools } from "@tabstack/ai";
const result = streamText({
model: openai("gpt-4o"),
tools: {
research_question: tabstackTools.research_question,
extract_page_content: tabstackTools.extract_page_content,
},
stopWhen: stepCountIs(5),
prompt: "Summarize the latest on quantum error correction, with sources.",
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}Note what happened to tools. Because it is a plain object keyed by tool name, narrowing the toolset is just picking keys. Here the assistant only researches and reads, so it gets two tools instead of five.
That is worth doing deliberately. Every tool you pass is another option the model weighs on every step, and another way for it to pick wrong. On smaller and cheaper models, cutting the toolset to what the job needs is one of the highest-leverage changes you can make.
One honest note about streaming with tools, and it is the thing I would fix first in any demo. The text stream goes quiet while a tool runs, because the model is waiting on a network call it cannot narrate. Users read that silence as a hang. Surface tool activity in your UI, even if it is just "searching the web", so the pause has an explanation.
Step 3: know your five tools
| Tool | What it does |
|---|---|
extract_structured_data | Pull specific fields from a URL into a JSON shape you define |
extract_page_content | Fetch a page as clean markdown |
research_question | A synthesised answer with cited sources across multiple pages |
generate_structured_data | Fetch a page, then AI-transform it into derived or reshaped JSON |
automate_browser_task | Run a multi-step, natural language browser task |
The model fills in the inputs, but knowing the shapes helps when you are debugging why it chose badly:
extract_structured_data:url,json_schema_json, a JSON encoded JSON Schema stringextract_page_content:urlresearch_question:querygenerate_structured_data:url,instructions,json_schema_jsonautomate_browser_task:task, plus optionalurl,guardrails,data,country,max_iterations,max_validation_attempts
The distinction that matters most is extract versus generate. Extract pulls out what is already written on the page. Generate fetches the page and produces something derived from it, like a category or a summary nobody wrote. If the field exists in the HTML, extract it. If you are asking for judgement, generate it.
Step 4: the flags that matter in production
The model can pass these itself, and they are only sent to Tabstack when present, so defaults stay intact when they are omitted.
On extract_structured_data, extract_page_content, and generate_structured_data:
effort:"min","standard", or"max". Reach for"max"on JavaScript-heavy pages, where it does full server-side browser rendering.nocache: bypass the cache when you need a fresh read.country: an ISO 3166-1 alpha-2 code for geotargeted fetches.
On research_question: mode as "fast" or "balanced", plus nocache.
If a page comes back suspiciously empty, it is nearly always a client-rendered app and effort: "max" is the fix. That is the whole workaround. No Playwright, no browser binary in your container image, no keeping either patched.
You can steer these from the system prompt rather than in code, which is often the cleaner move:
const result = streamText({
model: openai("gpt-4o"),
tools: tabstackTools,
stopWhen: stepCountIs(5),
system:
"You research questions using web tools. " +
"If a page returns little or no content, retry once with effort set to max. " +
"Prefer research_question for open questions and cite every source you use.",
prompt: userQuestion,
});Step 5: failures
When a tool call fails it throws TabstackToolError, carrying a readable message and, for API failures, an HTTP status. Inside an agent loop the AI SDK surfaces this in the tool result, so the model can see the failure and react. If you are calling a tool's execute directly, catch it yourself:
import { TabstackToolError, tabstackTools } from "@tabstack/ai";
try {
await tabstackTools.extract_page_content.execute(
{ url: "https://example.com" },
{ toolCallId: "1", messages: [] },
);
} catch (err) {
if (err instanceof TabstackToolError) {
console.error(`Tabstack failed (${err.status ?? "no status"}): ${err.message}`);
}
}One error type instead of separate handling for transport, parsing, and API problems.
Step 6: configuration
The defaults read TABSTACK_API_KEY from the environment, which is what you want in most apps. When you need a key per tenant, a custom base URL, or you already have an SDK client in scope:
import { createTabstackAiTools } from "@tabstack/ai";
const tools = createTabstackAiTools({ apiKey: process.env.MY_KEY });The client is created lazily, so importing the package never requires a key to be set. Your module imports stay safe in tests and in build steps that do not have secrets.
Where to take it next
Render the citations. research_question returns sources alongside the answer, and showing them is what separates an assistant people trust from one they double-check.
Narrow the toolset per route. A pricing page reader needs extract_structured_data and nothing else. A general assistant needs the lot. There is no rule that every surface in your app gets the same five.
If part of this workload moves to Python or another framework, the tool names and inputs are identical in @tabstack/langchain and langchain-tabstack. Your prompts carry over, because the model sees the same tools.
The package is on npm as @tabstack/ai, and the full API reference is at docs.tabstack.ai.
Ready to build? Sign up for 10,000 free credits, no card required, and stream your first cited answer. Full API in the docs.