Runbook-driven research agent. It follows structured research runbooks (SaaS competitor teardowns, ads & creative research, subscription-app audits, market sizing, equity research, SEO, AI visibility and more), calls the Prowl MCP tool bank (440+ data tools: SEO, ads libraries, reviews, scraping, app intelligence, LLM cross-checks), stores extracted claims in an evidence ledger, and produces reports with source citations and verification disclosures.
It is a CLI for humans and has two MCP surfaces for agents:
- the Fabric service (
prowl-research-service, HTTP MCP athttp://127.0.0.1:18764/mcp, the connectionfabric-agent.jsondeclares) — 7 tools:research.run,research.tools.call,research.tutorial,research.list_runbooks,research.artifact.get,fabric.job.get,fabric.job.cancel(see Fabric service); - the legacy local stdio MCP server (
prowl-research mcp) — 4 tools:research.run,research.list_runbooks,research.get_status,research.get_report.
git clone https://github.com/PROWL-AI/research-agent.git
cd research-agent
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"Two keys, both via environment (key names only — values never in the repo):
PROWL_API_KEY— your Prowl API key (prowl_…), billed per tool call. Get one at https://prowl.chat.OPENROUTER_API_KEY— the agent's own LLM (planning, synthesis, writing). Any OpenAI-compatible endpoint works: setRESEARCH_LLM_BASE_URL/RESEARCH_LLM_API_KEY/RESEARCH_LLM_MODELto override.
Other environment knobs (all optional):
PROWL_MCP_URL— Prowl MCP endpoint (defaulthttps://prowl.chat/mcp).RESEARCH_LLM_MODEL_STRONG— the strong-tier model (planning, writing, repair);RESEARCH_LLM_MODELcovers the cheap tier.RESEARCH_RUNS_DIR— where runs are written, for the stdio MCP server and every CLI command alike (run,status,report,rewrite,export,prune). Default./runsrelative to the process; set it explicitly in MCP client configs.RESEARCH_NO_SUBAGENTS=1— force sequential execution (no worker fan-out).RESEARCH_PROWL_CALL_TIMEOUT_S— read timeout in seconds for each Prowl MCP call (default 180; a hung server fails the call instead of parking it).
CLI subcommands: run <runbook> [--run-id <id>] [--key value …],
list-runbooks, status <run_id>, report <run_id>, rewrite <run_id>,
export <run_id> [--format html|md], prune [--older-than 30d] [--keep-last N] [--yes], validate [--online], mcp.
Exit codes: run exits 1 when the run ends partial (budget or failure —
the report says why); rewrite exits 1 when lint issues remain; validate
exits 0 when all runbooks are valid, 1 when a runbook fails validation, 2 on
usage or network/config errors (CI can tell "runbook invalid" apart from
"catalog unreachable"); other input and usage errors exit 2.
list-runbooks, status, report, export, prune exit 0 on success; mcp
serves on stdio until stopped.
runs/ grows forever unless you prune it: prune lists runs older than
--older-than (by checkpoint created_at, default 30d) without deleting
anything; pass --yes to actually delete. --keep-last N always spares the N
newest runs, and any run whose .lock is held by a live process is skipped.
Register the agent as an MCP server (stdio) in any MCP client:
{
"mcpServers": {
"research-agent": {
"command": "prowl-research",
"args": ["mcp"],
"env": { "PROWL_API_KEY": "prowl_…", "OPENROUTER_API_KEY": "…" }
}
}
}The stdio server exposes four tools: research.run, research.list_runbooks,
research.get_status, research.get_report. It is the legacy local surface;
the Fabric bundle points at the HTTP service, which exposes seven (listed at
the top of this file).
Proving tool call (verifies install and registration; needs no keys and makes no billed calls):
research.list_runbooks()
Expected: the runbook list (saas-competitor-teardown, …) with inputs and
budgets. research.get_status and research.get_report are key-free too —
only research.run requires the keys above (it fails fast, naming the missing
env var, before spending anything).
Runs take minutes, so for long runs prefer the job pattern: call
research.run with wait=false (the default) to get {run_id, status: "running"} back immediately, then poll research.get_status(run_id) until
the checkpoint shows complete, partial, failed, or interrupted.
failed/interrupted require inspecting the checkpoint; a report may not exist.
For complete/partial, fetch the result with
research.get_report(run_id). Use wait=true only when the caller can block
for the whole run.
Next to status, every terminal state carries a terminal_code saying why
the run stopped (null while it is live). The set is closed:
terminal_code |
status |
meaning |
|---|---|---|
completed |
complete (or partial when a disclosed omission remains, e.g. a step excluded because its tool left the catalog) |
every runnable step ran and its evidence was saved |
budget_tools / budget_usd / budget_minutes |
partial |
a runbook budget ran out; the report marks what is missing |
budget_llm |
partial |
LLM-spend budget (reserved; not set yet) |
step_failed |
partial |
a step failed hard or a worker crashed |
evidence_incomplete |
partial |
evidence is missing with no other stop: a claim extraction failed (the resume finishes it from the saved raw, no new paid call), or a required step's raw could not be written (the resume fetches it again — a new, paid call; an optional step is skipped_on_error instead) |
cancelled |
partial |
any cancellation of the owning task: a caller or service cancel, the service timeout (job error timeout), Ctrl-C, a client closing its session or a graceful service shutdown |
error |
failed |
an exception escaped the run; stop_reason carries it |
interrupted |
interrupted |
the owning process died without a terminal write (a hard kill or crash); written by reconciliation, resumable |
Run state has exactly one writer: the process holding the run's lock
(runs/<id>/.lock). The orchestrator records cancelled/error itself before
releasing the lock; a second process on the same run is refused
(run … is locked by another process) and writes nothing — on stdio MCP the
refusal is returned synchronously, also for wait=false. get_status reports
active (some process holds the run lock) and orphaned (running, no
live owner anywhere). interrupted is
written by reconciliation — at stdio server start, at CLI run/status, and
at Fabric service start — and only for a running checkpoint whose lock is
free. A run whose version-1 checkpoint cannot be migrated without guessing is
reported with status: needs_attention and the reason in stop_reason: its report
stays readable and exportable, it can be pruned, and research.run refuses to
resume it. get_status also reports has_checkpoint: false while a run accepted
by this server is still starting, or when it failed or was cancelled before it
took the run lock (the status then comes from the in-process job and
disappears with it).
First real run (makes billed Prowl calls; see budget limits below):
prowl-research run saas-competitor-teardown --competitors example.com,rival.comExpected: a markdown report under runs/<id>/report.md plus an HTML export,
with a source log and confidence ladder.
pytest # unit tests + runbook validation (offline)
python scripts/validate_runbooks.py --online # frontmatter + tool names vs live Prowl catalog (needs PROWL_API_KEY)
python evals/judge.py --run runs/<id> # 5-dimension quality rubric for a finished runAuthoring a new runbook: docs/runbook-authoring.md.
Architecture decisions and inherited traps: docs/knowledge-pack.md.
runbook (SKILL.md) → brief → plan → parallel research sub-agents
(data segments; transforms stay on the lead)
→ evidence ledger → writer + citation-repair loop
→ citation verification pass → markdown + HTML
-
Runbooks ask for sourced numbers and triangulation. The writer consumes the evidence ledger and runs citation repair/verification, but this is not a guarantee that every fact is true or has two independent sources. Inspect the report's verification disclosures and source evidence before relying on it.
-
Runbooks declare their tool allowlist and budget (
max_tool_calls/max_usd/max_minutes). On budget exhaustion the agent emits a partial report that marks what is missing — it never fails silently. The budget is read from the run's attempt ledger: every dispatched call is recorded before it is sent (so in-flight calls count againstmax_tool_calls), and its cost is stored when it returns or fails — a billed failure costs money too. Spend = server-reported actual costs + estimates for calls without one (the catalog price, else the runbook's per-call share ofmax_usd, never zero);stats.cost_sourcesaysserver,mixed,estimate, ornullbefore any tool attempt. Budgets cap Prowl tool calls only: the agent's own LLM usage (planning, claim extraction, transforms, writing, citation repair) is billed by your LLM provider on top ofmax_usd— it is metered, not capped, and reported understats.llm_usagein every run's output. -
Evidence statuses (ledger schema 2). A claim's status is recomputed over its whole subject group on every change and on load, so it never depends on arrival order.
verifiedneeds either:- span — the claim's quote is found in the step's whole stored raw (its values or its JSON, sha256-checked on resume; a bare number is not a quote) and the value sits inside the quote on number boundaries (1200 is not in 11,200). A span-verified claim is one source saying it; or
- corroboration — two agreeing claims from two different steps with
distinct known sources: different publishers (registrable domain of a URL
the raw or the request actually names — an extractor-supplied URL that the
raw does not contain is dropped) or different upstream data providers
(delivery wrappers removed:
google_trendsandserpapi_google_trendsare one Google Trends source).
Never a source: a transform note and an LLM-answer tool (Perplexity, Gemini, Google AI mode/overview,
*_responses) — their own text never span-verifies and they add no independence; a web search/scrape claim without a URL; a claim whose quote was not found. Two values that disagree make every valued claim of the groupconflict(a missing unit is compatible with any unit;$andUSDare one unit); a claim without a value never confirms or resolves one, and a chart over a conflicting claim is rejected. The extractor's own word never verifies; a v1 ledger'sverbatimclaim without a quote loads asassumedwith the reason inverification.detail. Reports written before schema 2 keep their old tags — regenerate them withrewrite_report, which also re-saves the ledger; judge status shares are not comparable across this change. Events:claim.verified,claim.conflict,claim.quote_not_found(withrun_id; under the service they reachservice.logonce B-035 lands). -
Charts are rendered deterministically from ledger data, never described by the LLM.
The deep agent audit adds
seven defects: lifecycle ownership (KA-06 — wave 1), hidden artifact/extraction
failures and budget accounting across mixed receipts/resume (KA-10…KA-12 — wave 2),
and false evidence verification (KA-07…KA-09 — wave 3, ledger schema 2). The ported
contracts live in tests/test_ka_contracts.py. The historical probe file stays as the
audit's cut: 11 of its 12 cases pass; its success control asserts a cwd-relative
raw_ref, which wave 2 replaced with a run-dir-relative one.
The 2026-10-10 handoff links the remediation plan and records the audited source revision,
checks and next repair tasks. The offline suite is .venv/bin/python -m pytest tests/ -q (network-free); its pass count is a fact about one revision, so it is
recorded with that revision in the handoff rather than here. A green run does
not establish live provider quality or Fabric host acceptance.
Resume works from the checkpoint's step records (schema version 2, wave 2):
every step has an immutable step_id assigned when the plan is made, so a tool
leaving the catalog excludes that step by id and shifts nothing. A step's state is
pending, dispatching, outcome_unknown, raw_saved, completed, failed,
skipped_on_error, skipped_budget or excluded. A resume runs pending and
failed steps, finishes raw_saved steps from their saved raw without a new
paid call, and re-sends an outcome_unknown call with the same idempotency
key (<run_id>:<step_id>:<attempt>) so the server can replay it instead of
billing twice. A client read timeout counts as an unknown outcome, never as a
refusal, and a budget stop leaves such a step outcome_unknown so its key
survives. The Prowl server deduplicates a re-sent key only within its retry
window (15 minutes by default): a resume after that may be charged again, and
the re-send is still recorded as one attempt — the budget then undercounts by that
call. A step migrated from version 1 whose dispatch may have reached the server is
first re-sent under its v1 key (<run_id>:<index>:<tool>). Version-1 checkpoints are migrated on load; a v1 state that cannot be mapped
without guessing is reported needs_attention (above) and never resumed.
The upgrade is one-way for a run directory: the first save of a migrated run
keeps its v1 file as checkpoint.v1.json, but a pre-v2 build reading the new
checkpoint.json re-plans (and re-bills) the run. Restart stdio sessions and
reinstall the Fabric service before resuming runs a v2 build has touched; a build
refuses a checkpoint whose schema_version is newer than it understands.
Status and checkpoint shape (schema 2). research.get_status returns
completed_steps as step ids (not indexes), planned_steps without excluded
steps, step_counts per state, skipped_steps entries carrying step_id,
index, step, tool, reason and state (while a run is live, steps still in
flight are not listed), and counters with data_calls, attempted_calls,
cost_usd, cost_estimate_usd, spend_usd and cost_unknown_attempts (the same
keys appear in a run's stats). stats.cost_source is server, mixed,
estimate or null (no tool attempt yet). checkpoint.json (also what
research.artifact.get serves) holds plan_raw, steps and attempts; it no
longer persists plan, completed_steps, skipped_steps or counters.
Crash injection at every durable write is covered by tests/test_durability.py.
Finance/subscription live acceptance remains unverified. With several workers,
the USD check can still overshoot by the in-flight calls' unknown cost (each
counts at its estimate); max_usd is not a cap on the separate LLM provider
spend.
The separate prowl-research-service entry point adds durable jobs, observed tool/LLM receipts, human decisions and a service-owned dashboard for Fabric Dashboards. It reuses the existing research core. See Fabric setup and architecture, dashboard design, and current verification/handoff. Legacy CLI/stdio and their existing run directories remain separate entry points.
MIT — see LICENSE.