Stream a research assistant with the Vercel AI SDK and Tabstack

A hands-on walkthrough: give the model Tabstack's web tools with the Vercel AI SDK, then stream a cited answer token by token so users see progress forming instead of staring at a spinner.

By Steve McDougall··7 min read
Stream a research assistant with the Vercel AI SDK and Tabstack

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/openai
export 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

ToolWhat it does
extract_structured_dataPull specific fields from a URL into a JSON shape you define
extract_page_contentFetch a page as clean markdown
research_questionA synthesised answer with cited sources across multiple pages
generate_structured_dataFetch a page, then AI-transform it into derived or reshaped JSON
automate_browser_taskRun 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 string
  • extract_page_content: url
  • research_question: query
  • generate_structured_data: url, instructions, json_schema_json
  • automate_browser_task: task, plus optional url, 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.

Start automating the web in minutes.

The model, the browser, and the orchestration all run on Tabstack. You just make the call.