Skip to content
Start free

Ground Your Own Model in Live Sources

Build a command-line tool that answers a current question with your own model, grounded in live sources through one /research call. Shows the difference between an ungrounded answer and a cited one.

Your model doesn’t come with the web. This tutorial closes that gap without making the model operate a search loop: one /research call does the web work, and your model does what you chose it for.

By the end you will have a small CLI that answers a current question two ways, so you can see the difference for yourself.

A command-line tool that:

  1. Takes a question.
  2. Calls /research once and gets back a cited answer from live sources.
  3. Hands that answer, plus its sources, to a model you run.
  4. Prints a short brief with the citations preserved.
  5. On request, answers the same question with no research at all, for comparison.
  • How to consume the /research stream and read the report off the complete event.
  • How to keep claim-level citations attached to the answer instead of losing them.
  • How to write a prompt that keeps your model inside the evidence it was given.
  • How to fail honestly when research returns no sources.
  • Python 3.9+ or Node.js 22+.
  • A Tabstack API key.
  • A model you run. This tutorial uses Ollama on http://localhost:11434, because it needs no key and keeps everything local. Any chat model works; only the ask_model function changes.

Create and Setup Your API Key

Before you can start using Tabstack API, you’ll need to create an API key and set it up in your environment.

1. Create Your API Key

  1. Visit the Tabstack Console
  2. Sign in to your account (or create one if you haven’t already)
  3. Navigate to the API Keys section and click the “Manage API Keys”
  4. Once you are on the API Keys page, Click “Create New API Key”
  5. Give your key a descriptive name (e.g., “Development”, “Production”) and click the “Create API Key”
  6. Copy the generated API key and store it securely

2. Set Up Environment Variable

For security and convenience, we recommend storing your API key as an environment variable rather than hardcoding it in your scripts.

macOS/Linux
Terminal window
# Add to your shell profile (~/.bashrc, ~/.zshrc, or ~/.bash_profile)
export TABSTACK_API_KEY="your_api_key_here"
# Or set it temporarily for the current session
export TABSTACK_API_KEY="your_api_key_here"
# Reload your shell or run:
source ~/.bashrc # or ~/.zshrc
Windows (Command Prompt)
# Set temporarily for current session
set TABSTACK_API_KEY=your_api_key_here
# Set permanently (requires restart)
setx TABSTACK_API_KEY "your_api_key_here"
Windows (PowerShell)
# Set temporarily for current session
$env:TABSTACK_API_KEY = "your_api_key_here"
# Set permanently for current user
[Environment]::SetEnvironmentVariable("TABSTACK_API_KEY", "your_api_key_here", "User")

3. Verify Your Setup

Test that your environment variable is set correctly:

macOS/Linux/Windows (Git Bash):

Terminal window
echo $TABSTACK_API_KEY

Windows (Command Prompt):

Terminal window
echo %TABSTACK_API_KEY%

Windows (PowerShell):

Terminal window
echo $env:TABSTACK_API_KEY

You should see your API key printed in the terminal.

Pull a model and confirm Ollama is up:

Terminal window
ollama pull llama3.2
curl http://localhost:11434/api/chat -d '{
"model": "llama3.2",
"messages": [{ "role": "user", "content": "reply with OK" }],
"stream": false
}'

Install the SDK:

Terminal window
npm install @tabstack/sdk

The Ollama calls below use the standard library (urllib) and built-in fetch, so there is nothing else to install.


/research always streams. Progress events arrive while the work happens, and the complete event carries the report and the sources it cited.

import Tabstack from "@tabstack/sdk";
const client = new Tabstack();
type Cited = { url: string; title?: string | null; claims?: string[] };
/** Return the report and cited pages for a question. */
async function research(question: string): Promise<{ report: string; cited: Cited[] }> {
const stream = await client.agent.research({ query: question, mode: "fast" });
for await (const event of stream) {
if (event.event === "iteration:start") {
console.log(" researching...");
}
if (event.event === "error") {
throw new Error(event.data.error?.message ?? "Research failed");
}
if (event.event === "complete") {
return {
report: event.data.report,
cited: event.data.metadata.citedPages ?? [],
};
}
}
throw new Error("Stream ended before the complete event");
}

Note what is absent. No query planning, no result ranking, no page fetching, no HTML parsing, no gap check, no second search. That is the whole point: the loop ran inside the call.

The citations are what make the answer checkable, so number the sources before the model sees them. Then a claim in the output can point at a specific URL.

/** Number the sources so the model can cite them as [1], [2], and so on. */
function formatSources(cited: Cited[]): string {
return cited
.map((page, i) => `[${i + 1}] ${page.title ?? "(untitled)"} - ${page.url}`)
.join("\n");
}

Your model’s job is now narrow: turn the research into a brief, and stay inside the evidence. The system prompt is doing the real work here.

const OLLAMA_URL = "http://localhost:11434/api/chat";
const MODEL = "llama3.2";
const SYSTEM = [
"You write short briefs from research you are given.",
"Use only the research provided. If the research does not cover part of the",
"question, say so plainly instead of filling the gap.",
"Cite sources inline as [1], [2] using the numbered list provided.",
].join(" ");
type Message = { role: "system" | "user" | "assistant"; content: string };
async function askModel(messages: Message[]): Promise<string> {
const response = await fetch(OLLAMA_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
// stream defaults to true, so turn it off for a single response.
body: JSON.stringify({ model: MODEL, messages, stream: false }),
});
if (!response.ok) {
throw new Error(`Ollama returned ${response.status}`);
}
const data = await response.json();
return data.message.content;
}
async function groundedAnswer(question: string): Promise<string> {
const { report, cited } = await research(question);
if (cited.length === 0) {
// No sources is a result. Do not let the model paper over it.
return "Unverified: research returned no cited sources for this question.";
}
const prompt = [
`Question: ${question}`,
"",
`Research:\n${report}`,
"",
`Sources:\n${formatSources(cited)}`,
].join("\n");
return askModel([
{ role: "system", content: SYSTEM },
{ role: "user", content: prompt },
]);
}

Two decisions in that code:

  • An empty citation list short-circuits the model. If research found nothing, saying so beats asking a model to improvise. This is the difference between a grounded system and a confident one.
  • The model never sees a URL it has to fetch. It receives finished text and a numbered source list. Nothing in this design depends on the model calling tools correctly.

Add the ungrounded path so you can compare answers to the same question.

/** The same question, with no research. For comparison only. */
async function ungroundedAnswer(question: string): Promise<string> {
return askModel([{ role: "user", content: question }]);
}

Ask something that changed after your model was trained: a current version number, a recent price, this month’s release notes. The ungrounded answer will usually be fluent, specific, and out of date, with nothing in the output telling you which.

ground.ts
import Tabstack from "@tabstack/sdk";
const client = new Tabstack();
const OLLAMA_URL = "http://localhost:11434/api/chat";
const MODEL = "llama3.2";
const SYSTEM = [
"You write short briefs from research you are given.",
"Use only the research provided. If the research does not cover part of the",
"question, say so plainly instead of filling the gap.",
"Cite sources inline as [1], [2] using the numbered list provided.",
].join(" ");
type Cited = { url: string; title?: string | null; claims?: string[] };
type Message = { role: "system" | "user" | "assistant"; content: string };
async function research(question: string): Promise<{ report: string; cited: Cited[] }> {
const stream = await client.agent.research({ query: question, mode: "fast" });
for await (const event of stream) {
if (event.event === "iteration:start") {
console.log(" researching...");
}
if (event.event === "error") {
throw new Error(event.data.error?.message ?? "Research failed");
}
if (event.event === "complete") {
return { report: event.data.report, cited: event.data.metadata.citedPages ?? [] };
}
}
throw new Error("Stream ended before the complete event");
}
function formatSources(cited: Cited[]): string {
return cited
.map((page, i) => `[${i + 1}] ${page.title ?? "(untitled)"} - ${page.url}`)
.join("\n");
}
async function askModel(messages: Message[]): Promise<string> {
const response = await fetch(OLLAMA_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: MODEL, messages, stream: false }),
});
if (!response.ok) {
throw new Error(`Ollama returned ${response.status}`);
}
const data = await response.json();
return data.message.content;
}
async function groundedAnswer(question: string): Promise<string> {
const { report, cited } = await research(question);
if (cited.length === 0) {
return "Unverified: research returned no cited sources for this question.";
}
const prompt = [
`Question: ${question}`,
"",
`Research:\n${report}`,
"",
`Sources:\n${formatSources(cited)}`,
].join("\n");
const answer = await askModel([
{ role: "system", content: SYSTEM },
{ role: "user", content: prompt },
]);
return `${answer}\n\nSources:\n${formatSources(cited)}`;
}
const args = process.argv.slice(2);
if (args.length === 0) {
console.log('Usage: npx tsx ground.ts "your question" [--no-research]');
process.exit(1);
}
const noResearch = args.includes("--no-research");
const question = args.filter((a) => a !== "--no-research").join(" ");
if (noResearch) {
console.log("\n--- model alone ---");
console.log(await askModel([{ role: "user", content: question }]));
} else {
console.log("\n--- grounded in live sources ---");
console.log(await groundedAnswer(question));
}
Terminal window
npx tsx ground.ts "What is the current long-term support release of Node.js?"
npx tsx ground.ts "What is the current long-term support release of Node.js?" --no-research

Grounded, the output ends with sources you can open:

--- grounded in live sources ---
researching...
Node.js 24 is the current long-term support release [1]. It entered LTS in
October 2025, replacing Node.js 22 as the recommended line for production [1].
Sources:
[1] Node.js Releases - https://nodejs.org/en/blog/release

Ungrounded, it ends with whatever the model remembers:

--- model alone ---
The current long-term support release of Node.js is version 20, which entered
LTS in October 2023 and is recommended for production use.

Run both against something that changed recently. The second answer is fluent, specific, and has nothing in it that tells you it is out of date.

  • Swap the model. Point ask_model at any chat API. Nothing else in the design changes, because the model was never doing the web work.
  • Add a known URL. When you already have the page, /extract/markdown is 10 credits and deterministic, against a /research call that runs several actions. Reach for the cheap path when you can.
  • Ask for typed output instead of prose. /extract/json returns JSON matching a schema you supply. Start with Schema design.
  • Skip the hand-rolled tools. If your model runs inside a framework, the maintained packages expose the same calls as native tools. See Hermes, LangChain (Python), or the full list.
  • Go deeper on the endpoint. Autonomous Research covers modes, every progress event, citation handling, and client-side timeouts.
  • Check a claim instead of answering a question. Verify a Model Answer Against Live Sources uses the same call for a narrower job.