Build a competitor brief agent with LangChain.js and Tabstack

A hands-on TypeScript walkthrough: pull pricing off a page, research positioning across sources, generate the judgement calls, then assemble a LangChain.js agent that produces a competitor brief with sources attached.

By Steve McDougall··9 min read
Build a competitor brief agent with LangChain.js and Tabstack

The Python tutorial built a research agent. This one builds something with a shape you can hand to a colleague: a competitor brief. Point it at a company, get back their pricing, their positioning, and what people are saying about them, with sources.

Same five tools as the Python package, same names, same inputs. If you have read the Python version, the only thing that changes here is the syntax, which I still find slightly surprising every time I move between them.

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 model LangChain.js supports
npm install @tabstack/langchain @langchain/core zod langchain @langchain/openai
export TABSTACK_API_KEY="your-key-here"
export OPENAI_API_KEY="your-openai-key"

@langchain/core and zod are peer dependencies rather than dependencies. That is intentional. Your app keeps a single instance of each, which avoids the genuinely miserable class of bug where two copies of Zod disagree about whether your schema is a schema.

Step 1: pull structured data off a page

Start with the piece the whole brief hangs on. You know the pricing page URL, and you know what you want from it, so describe the shape and ask for it.

import { extractStructuredDataTool } from "@tabstack/langchain";
 
const pricing = await extractStructuredDataTool.invoke({
  url: "https://example.com/pricing",
  json_schema_json: JSON.stringify({
    type: "object",
    properties: {
      plans: {
        type: "array",
        items: {
          type: "object",
          properties: {
            name: { type: "string", description: "Plan name" },
            price: { type: "number", description: "Monthly price in USD" },
            features: {
              type: "array",
              items: { type: "string" },
              description: "Included features",
            },
          },
        },
      },
    },
  }),
});
 
for (const plan of pricing.plans) {
  console.log(`${plan.name}: $${plan.price}`);
}

Two details worth pausing on.

The schema goes in as json_schema_json, a JSON encoded string rather than an object. That looks fussy until you remember who else calls this tool. When an agent invokes it, the model has to produce that argument, and a string is a shape models get right far more often than nested objects.

If you write one thing carefully in this whole tutorial, make it the field descriptions. The description on each field is not decoration. It is the instruction that tells the extractor which number on the page you meant when you said price. A pricing page has annual figures, discounted figures, and per-seat figures on it. The description is how you disambiguate. Vague or missing descriptions are the most common reason extraction results disappoint.

Unlike the Python package, the TypeScript tools return plain objects. No JSON.parse step.

Step 2: research what is not on any single page

Pricing lives on a pricing page. Positioning and reputation do not live anywhere in particular, which is what research_question is for.

import { researchQuestionTool } from "@tabstack/langchain";
 
const { answer, sources } = await researchQuestionTool.invoke({
  query: "How do developers describe Example Corp's developer experience?",
});
 
console.log(answer);
for (const source of sources) {
  console.log(`- ${source.title}: ${source.url}`);
}

sources comes back as { title, url } objects, which drops straight into a citation list. This is the part that makes a brief defensible rather than just confident. When someone asks where a claim came from, you have the page.

While iterating, pass mode: "fast". Move to "balanced" when you care more about quality than latency.

Step 3: generate what the page never said

Extraction pulls out what exists. Sometimes you want something derived, like a category or a one-line summary that nobody wrote down. That is generate_structured_data: fetch the page, then transform it.

import { generateStructuredDataTool } from "@tabstack/langchain";
 
const positioning = await generateStructuredDataTool.invoke({
  url: "https://example.com",
  instructions:
    "Identify the primary audience this homepage is written for, " +
    "the main problem it claims to solve, and its strongest differentiator.",
  json_schema_json: JSON.stringify({
    type: "object",
    properties: {
      audience: { type: "string" },
      problem: { type: "string" },
      differentiator: { type: "string" },
    },
  }),
});

The line between the two tools is simple. If the field is written on the page, extract it. If you are asking for judgement, generate it. Reaching for generate when extract would do costs you time and adds a layer where the answer can drift.

Step 4: assemble the agent

Three tool calls, three pieces of a brief. You could orchestrate that yourself with three awaits, and for a fixed report you probably should. But the moment the shape of the question varies, let the model choose.

import { createAgent } from "langchain";
import { tabstackTools } from "@tabstack/langchain";
 
const agent = createAgent({
  model: "openai:gpt-4o",
  tools: tabstackTools,
  systemPrompt:
    "You produce competitor briefs. " +
    "Use extract_structured_data for facts on a known page such as pricing, " +
    "extract_page_content when you need to read a page in full, " +
    "generate_structured_data when you need a judgement the page does not state outright, " +
    "and research_question for anything spread across multiple sources. " +
    "Always cite the sources you used.",
});
 
const result = await agent.invoke({
  messages: [
    {
      role: "user",
      content:
        "Build me a brief on Example Corp. Their pricing is at https://example.com/pricing.",
    },
  ],
});
 
console.log(result.messages.at(-1)?.content);

tabstackTools is all five, ready to pass straight in:

ToolWhen the model reaches for it
extract_structured_dataSpecific fields from a URL you already know
extract_page_contentThe whole page as clean markdown
research_questionAn open question needing several sources
generate_structured_dataFetch a page, then transform it into derived JSON
automate_browser_taskA multi-step browser task described in plain English

You do not have to pass all five. tabstackTools is an array here, so filter it or import individual tools and build your own array if the agent needs two. Fewer tools means fewer chances to pick the wrong one, which matters more the smaller your model is.

One inconsistency to watch, because it caught me out. The aggregate export shape is not the same across the Tabstack adapters. Here and in the OpenAI Agents and LlamaIndex packages it is an array. In @tabstack/ai and @tabstack/mastra it is an object keyed by tool name. That is not sloppiness, it is each package matching what its framework expects, but if you are wiring up two of these in one codebase, check before you spread.

Notice what the system prompt is doing. It is not describing the tools, since each package ships descriptions written for the model to read. It is describing the job. That division keeps your prompts short and means tool description improvements reach you on upgrade rather than requiring a prompt rewrite.

Step 5: when the data is behind an interaction

Some things are not on a page until you do something. A search box, a region selector, a "load more" button. That is automate_browser_task:

import { automateBrowserTaskTool } from "@tabstack/langchain";
 
const result = await automateBrowserTaskTool.invoke({
  task: "Find the top 3 trending repositories and their star counts",
  url: "https://github.com/trending",
  guardrails: "browse and extract only, do not submit forms",
});
 
console.log(result.answer, result.pagesVisited, result.durationMs);

You get back answer, success, iterations, actions, durationMs, extracted, and pagesVisited. The last few are what make failures diagnosable. When the answer is wrong, actions and pagesVisited usually tell you why in about ten seconds.

Set guardrails. It is a plain English constraint on what the run is allowed to do, and on anything pointed at a live site it is the difference between a read-only browse and an agent cheerfully clicking a button you did not want clicked.

One thing to know up front: this tool runs non-interactively. It never pauses for human input, so it cannot block your agent loop waiting for someone to fill in a form. If you genuinely need a human in the middle of a browser session, that is the interactive mode flow in the API rather than this tool.

Step 6: flags and failures

Four optional inputs cover nearly everything. They are only sent when you pass them, so omitting them keeps Tabstack's defaults.

// Render a JS-heavy SPA, fresh, as if from the UK:
await extractPageContentTool.invoke({
  url: "https://example.com/app",
  effort: "max",
  nocache: true,
  country: "GB",
});

effort takes "min", "standard", or "max". If a page comes back suspiciously empty, it is almost always a JavaScript-rendered app and "max" is the fix. That single keyword replaces installing Playwright, shipping a browser binary, and keeping both patched.

nocache matters when you are watching something that moves, like prices. country matters when the page varies by region, which for pricing pages is most of the time.

Every tool throws one error type:

import { TabstackToolError, extractPageContentTool } from "@tabstack/langchain";
 
try {
  await extractPageContentTool.invoke({ url: "https://example.com" });
} catch (err) {
  if (err instanceof TabstackToolError) {
    console.error(`Tabstack failed (${err.status ?? "no status"}): ${err.message}`);
  }
}

One catch instead of separate handling for transport, parsing, and API errors. status carries the HTTP status for API failures and is undefined for everything else, which is usually enough to decide between retrying and giving up.

Configuration, if you need it

The default tools read TABSTACK_API_KEY from the environment. If you need a different key per tenant, a custom base URL, or you already have an SDK client you want reused:

import { createTabstackLangchainTools } from "@tabstack/langchain";
 
const tools = createTabstackLangchainTools({ apiKey: process.env.MY_KEY });

Where to take it next

Turn the brief into a scheduled job. nocache: true plus a diff against last week's run is a change monitor, and it is maybe twenty more lines.

If you are moving this to a streaming UI, the same five tools exist in @tabstack/ai for the Vercel AI SDK, under identical names. Your system prompt carries over unchanged.

The package is on npm as @tabstack/langchain, and the full API reference is at docs.tabstack.ai.

Ready to build? Sign up for 10,000 free credits, no card required, and generate your first competitor brief. 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.