@devs30/local-deep-researcher
v0.7.1
Published
Fully local web research assistant (LangGraph.js port of langchain-ai/local-deep-researcher) - library, CLI and MCP server
Maintainers
Readme
@devs30/local-deep-researcher
A JavaScript/TypeScript port of langchain-ai/local-deep-researcher: a fully local web research assistant. It runs against a local LLM via Ollama by default, so no API keys are required to get a cited research report. Use it as a library, a CLI, an MCP server, or a LangGraph Studio graph.
It ships two research modes, each available in every interface:
| Mode | Engine | Who decides the next step | CLI | Model requirement |
| ---------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------- | ------------------------------- |
| Workflow (default) | LangGraph.js state machine | The graph: fixed generate -> search -> grade -> summarize loop | local-deep-researcher "<topic>" | Any local LLM |
| Agentic | createAgent (LangChain v1) loop | The LLM: it picks its own searches, page fetches and notes | local-deep-researcher agent "<topic>" | Tool-calling LLM (e.g. qwen3) |
How the workflow mode works
The assistant runs a small research loop as a LangGraph state machine:
generate query → web search → grade sources → summarize → reflect on gaps
^ |
└───────────────── repeat ─────────────────┘
|
finalize summary- Generate query - the LLM turns your topic into a targeted search query.
- Web search - the query is run against the configured search provider.
- Grade sources - results are filtered in two stages: deterministic credibility heuristics
(domain blocklist, thin/empty content, cross-loop URL dedup - no LLM cost), then a per-source
binary LLM relevance check ("when in doubt, keep"). Rejected sources never reach the summary or
final bibliography; every drop is logged to stderr with a reason. If a whole round is rejected,
the research loop tries a different query next iteration - these empty rounds (all sources
rejected or a failed search) get free retries that don't consume the budget. The budget counts
only productive rounds (at least one source kept), up to a hard cap of
2 * (maxWebResearchLoops + 1)total rounds. Use--count-empty-loopsto restore the v0.2.x behavior where empty rounds consume budget. Note that MCP progressloopcounts all attempts and can exceedtotalwhen free retries happen. - Summarize - the new results are folded into a running summary. Empty rounds skip this step and go straight to reflection, which receives the list of failed queries with a do-not-repeat instruction; a run where every round is empty produces an honestly empty report instead of a summary hallucinated from empty context.
- Reflect - the LLM looks for knowledge gaps and produces a follow-up query.
- Steps 2-5 repeat until the configured loop count is reached (default 3 loops), then the
summary is finalized into a markdown report with a deduplicated source list. (Like the Python
original,
--max-loops Nperforms N+1 productive rounds; empty rounds get free retries up to a hard cap of2 * (N + 1)total rounds.)
Behavior change vs the Python original (
ollama-deep-researcher): source grading is ON by default and adds up to one LLM call per gathered source. Disable it with--no-grade-sources(CLI),GRADE_SOURCES=false(env), orgradeSources: false(library/MCP) to restore upstream-identical behavior.
Quickstart (CLI)
ollama pull gemma4:e4b
npx @devs30/local-deep-researcher "history of liquid rocket engines"This prints a markdown report to stdout, with progress logged to stderr. No search API key is needed - the default search provider is DuckDuckGo. For the autonomous variant, see Agentic mode.
CLI flags
| Flag | Description |
| ----------------------- | ----------------------------------------------------------------------------- |
| --max-loops <n> | Research loops (default 3; N yields N+1 productive rounds) |
| --count-empty-loops | Empty rounds (no sources kept) also consume the loop budget (v0.2.x behavior) |
| --provider <name> | ollama | openai_compatible (default ollama) |
| --model <name> | Model name (default gemma4:e4b, env LOCAL_LLM) |
| --base-url <url> | LLM base URL (Ollama or OpenAI-compatible endpoint) |
| --search-api <name> | duckduckgo | tavily | perplexity | searxng (default duckduckgo) |
| --fetch-full-page | Fetch full page content for each source |
| --no-grade-sources | Disable source grading (credibility + relevance filter) |
| --blocklist <domains> | Comma-separated domains to always reject |
| -o, --output <file> | Write the report to a file instead of stdout |
| --json | Output {"summary", "sources"} JSON instead of markdown |
| -q, --quiet | Suppress progress output on stderr |
| -h, --help | Show help |
| -v, --version | Show version |
local-deep-researcher mcp starts the MCP stdio server instead of running a one-off research
task - see Use as an MCP server below.
Use as a Claude Code subagent
Copy .claude/agents/deep-researcher.md into your project's
.claude/agents/ directory. Claude Code will pick it up automatically and delegate research
tasks to it - it shells out to the CLI (npx -y @devs30/local-deep-researcher "<topic>" --quiet)
and returns the markdown report verbatim.
Use as an MCP server (Claude Code / Codex)
Register the server with Claude Code:
claude mcp add deep-researcher -- npx -y @devs30/local-deep-researcher mcpFor Codex, add the server to ~/.codex/config.toml:
[mcp_servers.deep-researcher]
command = "npx"
args = ["-y", "@devs30/local-deep-researcher", "mcp"]The server exposes two tools: deep_research, which takes topic (required), and optional
max_loops, search_api, grade_sources, source_domain_blocklist and count_empty_loops
parameters, and streams progress notifications while it runs; and deep_research_agent,
documented in the Agentic mode section below.
Library API
import { research } from "@devs30/local-deep-researcher";
const report = await research(
"history of liquid rocket engines",
{ maxWebResearchLoops: 3 },
{
onProgress: (event) => {
// event.phase: "generating_query" | "searching" | "grading" | "summarizing" | "reflecting" | "finalizing"
console.error(`[${event.loop}/${event.maxLoops}] ${event.phase}`);
},
},
);
console.log(report.markdown); // full "## Summary" + "### Sources:" report
console.log(report.summary); // the running summary text
console.log(report.sources); // [{ title, url }, ...] deduplicated sourcesresearch(topic, options?, hooks?, deps?) returns a ResearchReport:
interface ResearchReport {
summary: string;
sources: Array<{ title: string; url: string }>;
markdown: string;
}options is a Partial<Configuration> (see Configuration below) and takes
precedence over environment variables. deps lets you override the LLM factory or search
provider - mainly useful for testing.
The agentic counterpart researchAgentic(topic, options?, hooks?, deps?) has the same signature
and returns the same ResearchReport; see Agentic mode for its example and the
extra progress fields (step/maxSteps).
Agentic mode
Alongside the fixed research loop above, this repo also ships an agentic mode: a single LLM agent decides its own searches, page fetches and notes in a tool-calling loop, instead of following the generate-query -> search -> grade -> summarize -> reflect steps. Once the agent stops (or hits the step cap), a separate one-shot LLM call writes the report from the gathered notes; the sources section is built deterministically from the notes' source URLs.
The step budget (--max-steps, default 20 model calls) is a hard cap, not a target: the agent
stops on its own once its notes cover the question. Two guarantees shape the loop: while the agent
has zero findings and unspent budget, it is automatically re-engaged with instructions to try
different queries (it cannot give up early), and when nothing relevant exists it records an honest
negative finding ("no evidence found") instead of failing - the run errors out only when the whole
budget is spent without a single note.
Agentic mode requires an Ollama model with tool calling (e.g. qwen3) - the default gemma4:e4b
does not support tools. A preflight check fails fast with a hint to set --agent-model /
AGENT_LLM if the configured model can't call tools. In agentic mode the model itself chooses
which URLs to fetch; fetching localhost and private-network addresses is blocked.
ollama pull qwen3
npx @devs30/local-deep-researcher agent "history of liquid rocket engines" --agent-model qwen3 --max-steps 15CLI flags
| Flag | Description |
| ---------------------- | --------------------------------------------------------------------------- |
| --max-steps <n> | Max model calls in the agent loop (default 20, env MAX_AGENT_STEPS) |
| --agent-model <name> | Tool-calling model for the agent loop (default: --model, env AGENT_LLM) |
The MCP server exposes a second tool alongside deep_research: deep_research_agent, which takes
topic (required), and optional max_steps, agent_llm, search_api and
source_domain_blocklist parameters.
import { researchAgentic } from "@devs30/local-deep-researcher";
const report = await researchAgentic("history of liquid rocket engines", {
agentLlm: "qwen3",
maxAgentSteps: 15,
});
console.log(report.markdown); // same ResearchReport shape as research()researchAgentic(topic, options?, hooks?, deps?) returns the same ResearchReport shape as
research(). agentLlm falls back to localLlm when unset; maxAgentSteps caps the number of
model calls in the loop (default 20) - when the cap is hit, the report is written from whatever
notes were gathered so far.
Existing workflow behavior (research() / local-deep-researcher <topic>) is unchanged;
agentic mode is purely additive.
LangGraph Studio
This repo ships a langgraph.json with two graphs, so you can open them directly in LangGraph
Studio from the repo root:
local_deep_researcher- the fixed research loop (src/graph.ts:graph)local_deep_researcher_agent- agentic mode (src/agent.ts:agenticGraph)
npx @langchain/langgraph-cli devConfiguration
All settings can be set via environment variables (see .env.example), and programmatic callers
can also pass them directly as the options argument to research(). Programmatic options take
precedence over environment variables, which take precedence over the defaults below.
| Config key | Env var | Default |
| ------------------------- | ---------------------------- | ----------------------------------------------------- |
| llmProvider | LLM_PROVIDER | ollama |
| localLlm | LOCAL_LLM | gemma4:e4b |
| agentLlm | AGENT_LLM | (none, falls back to localLlm) |
| ollamaBaseUrl | OLLAMA_BASE_URL | http://localhost:11434 |
| openaiCompatibleBaseUrl | OPENAI_COMPATIBLE_BASE_URL | (none, required if llmProvider=openai_compatible) |
| openaiCompatibleApiKey | OPENAI_COMPATIBLE_API_KEY | (none) |
| searchApi | SEARCH_API | duckduckgo |
| maxWebResearchLoops | MAX_WEB_RESEARCH_LOOPS | 3 |
| maxAgentSteps | MAX_AGENT_STEPS | 20 |
| fetchFullPage | FETCH_FULL_PAGE | false |
| gradeSources | GRADE_SOURCES | true |
| sourceDomainBlocklist | SOURCE_DOMAIN_BLOCKLIST | (empty) |
| countEmptyLoops | COUNT_EMPTY_LOOPS | false |
| stripThinkingTokens | STRIP_THINKING_TOKENS | true |
| tavilyApiKey | TAVILY_API_KEY | (none, required if searchApi=tavily) |
| perplexityApiKey | PERPLEXITY_API_KEY | (none, required if searchApi=perplexity) |
| searxngUrl | SEARXNG_URL | (none, required if searchApi=searxng) |
| langsmithTracing | LANGSMITH_TRACING | false |
| langsmithApiKey | LANGSMITH_API_KEY | (none, required if langsmithTracing=true) |
| langsmithProject | LANGSMITH_PROJECT | local-deep-researcher (when tracing is enabled) |
| langsmithEndpoint | LANGSMITH_ENDPOINT | (none, langsmith default endpoint) |
A .env file applies wherever the process is started from: the CLI and the MCP server load
.env from the current working directory on startup (also when run via npx), and LangGraph
Studio loads it as well (langgraph.json points at .env). The library API loads no file -
programmatic callers set real environment variables or pass settings via the options argument.
.env.example is a ready-to-copy template for all of the variables above. It ships in the git
repository only (not in the npm package): working from a clone, copy it to .env and adjust.
With npx, create a .env in the directory you run the command from, or export the variables
in your shell.
LangSmith tracing
Both research modes (workflow and agentic) can send full traces - every graph node, LLM call and tool call - to LangSmith. Tracing is built into LangChain/LangGraph; enabling it requires no code changes:
# .env
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=lsv2_...
# LANGSMITH_PROJECT=local-deep-researcher # optional, this is the default
# LANGSMITH_ENDPOINT=https://eu.api.smith.langchain.com # optional, self-hosted / EU regionWith npx, either export the variables in your shell
(LANGSMITH_TRACING=true LANGSMITH_API_KEY=lsv2_... npx @devs30/local-deep-researcher "topic")
or put them in a .env file in the directory you run the command from.
For the MCP server, pass them through the MCP client's env block, e.g. in .mcp.json:
{
"mcpServers": {
"local-deep-researcher": {
"command": "npx",
"args": ["-y", "@devs30/local-deep-researcher", "mcp"],
"env": {
"LANGSMITH_TRACING": "true",
"LANGSMITH_API_KEY": "lsv2_..."
}
}
}
}The same settings also work programmatically via the options argument
(research(topic, { langsmithTracing: true, langsmithApiKey: "..." })). When tracing is enabled
without an API key, the run fails fast with a configuration error. When it is disabled (the
default), no trace data leaves your machine.
Note that tracing is process-wide: an environment-level LANGSMITH_TRACING=true
cannot be turned off per-call with langsmithTracing: false, and once a traced run has started
in a process, the API key and endpoint are fixed for that process (only the project name can
change between runs).
Known upstream issue
(langchainjs#11189): with tracing
enabled, agentic mode makes @langchain/core log harmless
Error in handler LangChainTracer, ... No chain run to end lines (a duplicated-tracer bug with
nested graphs). Traces in LangSmith are complete despite the warnings; the CLI and MCP server
filter these lines out. EU-region accounts must set
LANGSMITH_ENDPOINT=https://eu.api.smith.langchain.com, otherwise uploads fail with 403.
Search providers
- DuckDuckGo (default) - no API key required.
- Tavily - set
SEARCH_API=tavilyandTAVILY_API_KEY. - Perplexity - set
SEARCH_API=perplexityandPERPLEXITY_API_KEY. - SearXNG - set
SEARCH_API=searxngandSEARXNG_URLto your instance URL.
License
MIT. This is a port of langchain-ai/local-deep-researcher; credit to the original authors for the research loop design and prompts.
