Deep Research on Your Own Laptop
“Deep research” is the feature every AI vendor shipped this year: you hand over a question, an agent plans, searches the web, takes notes, and comes back with a cited report. It is genuinely useful — and it normally runs in someone else’s cloud, on someone else’s model, with your questions and half-formed thoughts as the payload.
This lab builds the miniature, private version: a TypeScript CLI where
search <question> runs the whole plan → search → report loop against
qwen3:4b served by Ollama on your own machine, and analyze answers
follow-up questions grounded only in what you have already researched. The
single thing that leaves your laptop is the web-search API call itself (Tavily,
free tier). No cloud LLM anywhere.
The honest sales pitch, though, is not the privacy — it is the education. A 4B model is small enough that nothing works by accident. Every convenience a frontier model quietly absorbs — parallel tool calls, vague instructions, content-block messages, its own sense of what year it is — becomes a visible, debuggable failure on a small one. By the end of this lab you don’t just have a research assistant; you know exactly which parts of a deep agent are load-bearing.
Every code block below is copied verbatim from the lab; you can clone it and reproduce everything.
The Deep Agents harness in five minutes
LangChain’s deepagents package (now with an official TypeScript port) is a
“batteries included” harness on top of LangGraph: you give it a model and your
custom tools, and it adds the agent loop, a filesystem the agent can read and
write as its working memory, and a subagent mechanism. The core idea is that a
filesystem beats a giant context window: instead of holding everything it has
learned in the conversation, the agent writes notes and drafts to files and
reads back only what it needs.
Building the research agent is one call:
labs/lab-local-deep-research/src/agents/research-agent.ts:
return createDeepAgent({
model,
tools: [searchTool],
// virtualMode: the system prompt has the agent address files as absolute
// paths (/report.md, /notes/*.md). Without this, FilesystemBackend passes
// absolute paths through as real filesystem paths, escaping topicDir entirely.
backend: new FilesystemBackend({ rootDir: topicDir, virtualMode: true }),
systemPrompt: buildResearchSystemPrompt({ today }),
middleware: [
createStepLoggingMiddleware({ logger }),
createStringifyToolContentMiddleware(),
createToolAllowlistMiddleware({ allowedTools: RESEARCH_AGENT_TOOLS }),
],
});
That virtualMode: true earned its comment the hard way. The system prompt
tells the agent to write /report.md and /notes/*.md — absolute paths inside
its sandbox. With the default (virtualMode: false), FilesystemBackend
passed those through as real absolute paths, and the first live run cheerfully
wrote its research to the root of my actual filesystem instead of the topic
folder. The agent didn’t misbehave; the backend did exactly what it was told.
If you take one config flag away from this post, take this one.
The three middleware entries are the other half of the story — each one exists because a live run failed without it. We’ll take them in turn.
Scope → research → report, miniaturized
LangChain’s open_deep_research reference architecture splits deep research
into phases: scope the question into a brief, research iteratively, then write
the report. That structure scales down beautifully — not as separate graph
nodes, but as numbered phases in one system prompt that a small model can
follow:
labs/lab-local-deep-research/src/agents/prompts.ts:
Do these phases in order:
1. SCOPE: write /brief.md — 3-6 lines stating what the question asks and which distinct facts you need to find.
2. RESEARCH: call internet_search with ONE focused query per turn. You MUST run at least 3 searches before writing the report — one search is never enough. Make each query target a DIFFERENT aspect of the question (definition, timeline, key people, numbers, criticisms, current status). Use at most 5 searches.
3. REPORT: call write_file to create /report.md with this structure:
- a title line: # <the question>
- an opening paragraph that directly answers the question
- 3-6 named sections that go deep: concrete facts, dates, numbers and names from the search results; note where sources disagree
- a final section: ## Sources — a markdown bullet list of the URLs you actually used.
Aim for the depth of a well-researched briefing note, not a summary. Use the detail from the search results — do not compress everything into two sentences per section.
One line in there carries a lesson that cost a full 15-minute run to learn:
“You MUST run at least 3 searches”. An earlier draft said “3 to 5 searches,
stop early if results get repetitive” — reasonable guidance for a person, or
for a frontier model. qwen3:4b read it, ran one search, decided that was
plenty, and wrote a thin report. Small models don’t do discretion; they do
mandatory numbers. Every soft judgment call you leave in the prompt is a place
where a 4B model will take the cheapest exit.
The analyze side is the same harness pointed at the accumulated corpus: the
filesystem backend is rooted at the whole research/ tree, and the agent is
told to ls, grep, and read_file its way to an answer, citing the paths it
used. No embeddings, no vector store — the corpus is small markdown and a
model that can grep doesn’t need retrieval infrastructure yet.
Making a 4B model behave
Here is the collected toll every deep-agent tutorial forgets to mention, because their model is 100× bigger than ours. Each item below broke a real run before it earned its fix.
Deterministic sampling, thinking off
labs/lab-local-deep-research/src/agents/model.ts:
import { ChatOllama } from "@langchain/ollama";
import type { AppConfig } from "../config.js";
export function buildChatModel(config: AppConfig): ChatOllama {
return new ChatOllama({
model: config.ollamaModel,
baseUrl: config.ollamaBaseUrl,
// A 4B model calls tools reliably only with deterministic sampling
// and thinking mode off; both are load-bearing, not preferences.
temperature: 0,
think: false,
numCtx: config.ollamaNumCtx,
keepAlive: "10m",
});
}
With temperature above zero or thinking mode on, tool-call JSON degrades from “occasionally malformed” to “reliably malformed” — the Qwen guides all say this, and they are right. These two settings are the difference between an agent and a random-walk generator.
One tool call per turn — as a mandatory rule
qwen3:4b’s favorite failure was emitting several write_todos calls in one
turn, which hard-stopped the LangGraph run. The fix is the bluntest possible
prompt engineering, right at the top of the system prompt:
labs/lab-local-deep-research/src/agents/prompts.ts:
MANDATORY RULE: call AT MOST ONE tool per turn. Never call the same tool twice in one turn. Never call two different tools in one turn. One tool call, then wait for its result, every time.
Yes, it says the same thing four ways. That redundancy is the feature: a small model needs the rule restated until there is no phrasing of the mistake left uncovered.
Pin today’s date, or “current” means 2024
The subtlest bug in the lab: a local model’s sense of now is frozen at its training cutoff. Ask it for “the latest developments” and it will happily search for the state of the art as of its training year — in the search queries it writes, too. So both system prompts open with the date:
labs/lab-local-deep-research/src/agents/prompts.ts:
// A local model's sense of "now" is frozen at its training cutoff, so every
// prompt pins today's date — otherwise "current" and "latest" quietly mean
// the model's training year.
function todayLine(today: string): string {
return `Today's date is ${today}. Interpret "current", "latest" and "recent" relative to this date — NOT your training data. Use this date when your search queries need a year.`;
}
Cloud agents get this injected for free by their platform prompt; run the model yourself and you are the platform now.
Fewer tools than the harness wants to give you
The stock harness hands the model its full toolbox, including a task tool for
spawning subagents — exactly the delegation machinery a 4B model cannot be
trusted to drive. deepagents can disable tools per model profile, but its
provider detection only knows Anthropic, OpenAI and Google; a ChatOllama
instance falls through. So the lab trims the toolset the general way, with a
middleware that filters what the model is ever shown:
labs/lab-local-deep-research/src/agents/tool-allowlist.ts:
import { createMiddleware } from "langchain";
export const RESEARCH_AGENT_TOOLS = [
"internet_search",
"ls",
"read_file",
"write_file",
] as const;
export const ANALYZE_AGENT_TOOLS = ["ls", "read_file", "glob", "grep"] as const;
export function createToolAllowlistMiddleware({
allowedTools,
}: {
allowedTools: readonly string[];
}) {
const allowed = new Set(allowedTools);
return createMiddleware({
name: "ToolAllowlistMiddleware",
wrapModelCall: (request, handler) =>
handler({
...request,
tools: request.tools.filter((candidate) =>
allowed.has((candidate as { name: string }).name),
),
}),
});
}
A tool the model never sees is a tool it can never fumble. This is also what
makes analyze read-only: it isn’t a permissions system, the write tools are
simply not in its world. And it’s the natural extension point — running
qwen3:8b or better on real hardware? Add task back to the list and the
harness’s subagents light up again.
Flatten tool output, because the Ollama adapter says so
An integration bug you will hit within your first minute: deepagents’ own
read_file tool returns its result as content blocks
([{ type: "text", text }]), and @langchain/ollama’s message converter
throws on any ToolMessage whose content isn’t a plain string. The harness
crashes on its own built-in tool. One more middleware:
labs/lab-local-deep-research/src/agents/stringify-tool-content-middleware.ts:
import { isToolMessage, ToolMessage } from "@langchain/core/messages";
import { createMiddleware } from "langchain";
// deepagents' read_file tool returns text files as content blocks
// (`[{ type: "text", text }]`) rather than a plain string, but
// @langchain/ollama's message converter only accepts string content on
// ToolMessage and throws otherwise. Flatten any non-string tool content
// to text before it reaches the model.
export function createStringifyToolContentMiddleware() {
return createMiddleware({
name: "StringifyToolContentMiddleware",
wrapModelCall: (request, handler) =>
handler({
...request,
messages: request.messages.map((message) => {
if (!isToolMessage(message) || typeof message.content === "string") {
return message;
}
return new ToolMessage({ ...message, content: message.text });
}),
}),
});
}
Strip the leaked monologue
Even with think: false, qwen3:4b sometimes leaks its internal reasoning
into a final message — as a full <think>...</think> block, or as a stray
closing tag when the opener got truncated. A shared helper scrubs both before
anything downstream sees the text:
labs/lab-local-deep-research/src/agents/strip-think-blocks.ts:
// qwen3:4b sometimes leaks its internal reasoning into a final message as
// <think>...</think>, or — when the opening tag is truncated away — as
// reasoning text ending in a stray </think> with no opener. Strip both so
// downstream consumers never see the model's internal monologue.
export function stripThinkBlocks(text: string): string {
const withoutClosedBlocks = text.replace(THINK_BLOCK_PATTERN, "");
const lastCloseIndex = withoutClosedBlocks.lastIndexOf(THINK_CLOSE_TAG);
const withoutLeadingReasoning =
lastCloseIndex === -1
? withoutClosedBlocks
: withoutClosedBlocks.slice(lastCloseIndex + THINK_CLOSE_TAG.length);
return withoutLeadingReasoning.trim();
}
When the model answers instead of acting
The most expensive failure we hit came late, and it is worth telling in full.
A run would do everything right — brief, three focused searches — and then, on
the turn where it should call write_file with the report, the model would
leak a reasoning block and compose the entire, well-formed report as its
reply. A reply with no tool call ends the agent loop; report.md never
exists; a 15-minute run dies with nothing to show, even though the report was
right there in the transcript.
The fix has two layers, in the same spirit as everything above. The prompt now states the failure mode explicitly — the REPORT phase says “call write_file to create /report.md”, and the rules gained: “The report only exists once you call write_file with file_path /report.md. NEVER put the report text in your reply.” And because a prompt is a request, not a guarantee, the CLI keeps a deterministic backstop:
labs/lab-local-deep-research/src/commands/search.ts:
// qwen3:4b occasionally composes the finished report in its reply instead of
// calling write_file — the loop then ends with no /report.md and a 15-minute
// run would die. If the (think-stripped) reply IS the report, recover it.
export function extractInlineReport(reply: string): string | undefined {
const stripped = stripThinkBlocks(reply);
return stripped.startsWith("# ") ? stripped : undefined;
}
When the loop ends without report.md but the think-stripped reply is a
markdown report, the CLI writes it to disk itself (with a warning in the
logs) instead of failing the run. The pattern is the same one the search tool
established: the model’s job is the research; making its output durable is the
harness’s job.
Spend model turns on research, not bookkeeping
On a laptop, every model turn costs about a minute. That budget reframes tool
design: anything the tool can do in code is a turn the model gets back for
actual research. The search tool is where that principle pays off most. It’s a
DynamicStructuredTool with a rich schema and a description that tells the
model when not to use it:
labs/lab-local-deep-research/src/search/tavily-tool.ts:
return new DynamicStructuredTool({
name: "internet_search",
description:
"Search the web for up-to-date information. Use it for facts you do not " +
"already have: definitions, dates, numbers, people, announcements. Each " +
"call archives its full results (with URLs) to /notes/ automatically — " +
"do NOT save search notes yourself. Do NOT use it to read local files. " +
"Returns JSON with a short synthesized answer and a list of results " +
"(title, url, content snippet).",
schema: z.object({
query: z
.string()
.min(3)
.describe(
"A focused web search query targeting ONE aspect of the research " +
"question — not the whole question repeated verbatim.",
),
}),
Note what the description promises: the tool archives its own results. Every
search’s full, untruncated output is written to notes/ in code — zero model
turns spent on note-taking — while the model itself sees a trimmed 1,800-char
snippet per result to protect its context window. The earlier design had the
agent maintain a todo list and write its own notes; dropping all of that took
the run from ~18 model turns to 6, cut the wall time by a third, and — because
the turns that remained were all research — the report got longer and better
sourced, not worse.
The error path matters just as much on a slow machine:
labs/lab-local-deep-research/src/search/tavily-tool.ts:
} catch (err) {
// Returned as content instead of thrown: a transient search failure
// should cost the agent one turn, not abort the whole research run.
logger.error({ err, query }, "Searching the web failed.");
const message = err instanceof Error ? err.message : String(err);
return JSON.stringify({
error: `Search failed: ${message}. Try ONE different query, or continue with what you already have.`,
});
}
A thrown error would abort a run that’s already ten minutes deep. Returned as structured content, a flaky Tavily call costs exactly one turn: the model reads the error, tries a different query, and moves on — the recovery instruction is right there in the payload.
The last middleware from earlier, createStepLoggingMiddleware, closes the
loop on the human side: it wraps every model call and every tool call — the
harness’s built-in file tools included — and logs start, success or failure,
duration, and which tools the model just asked for. A multi-minute local run
streams its progress as JSON lines instead of being a black box you stare at,
wondering whether it’s thinking or dead.
A run, end to end
From a fresh clone (Ollama running natively — the model wants Metal/GPU acceleration that containers don’t get on macOS):
labs/lab-local-deep-research/README.md:
ollama pull qwen3:4b
cp .env.example .env # put your Tavily key in TAVILY_API_KEY
npm ci
npm run search -- "What is the Model Context Protocol and who created it?"
On Air-class hardware a search takes 10–15 minutes: about 6–9 sequential
model turns — brief, three to five searches, report — each visible in the logs
as it happens. Here is the actual opening of a report the lab produced for
“How does the European Union’s AI Act classify high-risk AI systems?”, written
by a 4B model from its own searches:
labs/lab-local-deep-research/research/how-does-the-european-union-s-ai-act-classify-high-risk-ai-s/report.md:
# How the European Union's AI Act Classifies High-Risk AI Systems
The European Union's AI Act classifies high-risk AI systems as those that pose significant risks to health, safety, or fundamental rights but have major socio-economic benefits, requiring stringent compliance measures such as third-party conformity assessments, EU database registration, and human oversight mechanisms.
## Definition and Criteria for High-Risk AI Systems
Under Article 6(1) of the AI Act, high-risk AI systems are defined as those that pose significant risks to health, safety, or fundamental rights but have major socio-economic benefits (Recital 46). Classification is based on specific use cases listed in Annexes I and III, including:
- Biometric identification systems
- Critical infrastructure management
- Education and vocational training
- Employment and worker management
- Essential public services (e.g., healthcare, housing)
- Law enforcement and migration systems
Alongside it: a brief.md from the scoping phase, a notes/ folder holding
every search’s full results, and a closing ## Sources section listing only
URLs the searches actually returned — the prompt forbids invented citations,
and the e2e test asserts real http links are present.
Then the corpus becomes queryable:
labs/lab-local-deep-research/README.md:
npm run analyze -- "Who created the Model Context Protocol?"
npm run analyze # interactive session
analyze answers a one-shot question in well under a minute — it’s just
reading files — and cites the paths it used. If the corpus doesn’t contain the
answer, it is instructed to say so rather than fall back on its training data:
your research assistant, grounded in your research.
The whole flow is verified by a scripted e2e smoke test (npm run e2e): a real
search against live Ollama and Tavily, structural assertions on the report
(exists, non-trivial, cites real links — model output is nondeterministic, so
the assertions are structural), then a real analyze question about it.
Honest limits, and where to take it
Be honest with your expectations: this is a rig for learning how deep agents
work, not a Google replacement. On a laptop CPU/iGPU each model turn costs
about a minute, so a research run is a coffee break; the free Tavily tier’s
1,000 credits/month is a real budget, and advanced-depth searches draw it
down faster than basic ones; and
a 16K context window is why the tool trims what the model sees. A machine with
a real GPU — or qwen3:8b, one .env variable away — is where it starts
feeling fast.
But the architecture is the part that transfers. Planning phases in the
prompt, filesystem-as-memory instead of a swollen context, tools that do their
own bookkeeping and return errors as content, an allowlist that shows the
model only what it can handle, and logs on every step: none of that is
specific to small models. It’s just that a small model refuses to work until
you get it right — which is exactly what makes it the best teacher. The
natural next steps are all one-line invitations the lab leaves open:
re-enable subagents on a bigger model, raise the step budget, point analyze
at a corpus you’ve built up over weeks. The
lab
has everything to start from.