PA

prowl-ai/research-agent

Developer tools
0 stars 0 forks Quality 78 Trend 20MIT

Runbook-driven research agent over the Prowl MCP tool bank — evidence-ledger reports where every number traces to a source. CLI + MCP server, Fabric-compatible.

Overview

Runbook-driven research agent. It follows structured (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 , and produces reports with source citations and verification disclosures. It is a CLI for humans and has two MCP surfaces for agents: - the (prowl-research-service, HTTP MCP at http://127.0.0.1:18764/mcp, the connection fabric-agent.json declares) — 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 (prowl-research mcp) — 4 tools: research.run, research.list_runbooks, research.get_status, research.get_report.

README

Prowl Research Agent

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 at http://127.0.0.1:18764/mcp, the connection fabric-agent.json declares) — 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.

Quick start for a new teammate

Install

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]"

Configure

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: set RESEARCH_LLM_BASE_URL / RESEARCH_LLM_API_KEY / RESEARCH_LLM_MODEL to override.

Other environment knobs (all optional):

  • PROWL_MCP_URL — Prowl MCP endpoint (default https://prowl.chat/mcp).
  • RESEARCH_LLM_MODEL_STRONG — the strong-tier model (planning, writing, repair; default xiaomi/mimo-v2.6-pro); RESEARCH_LLM_MODEL covers the cheap tier (claim extraction, transforms, citation checks; default deepseek/deepseek-v4.1-flash); RESEARCH_JUDGE_MODEL the eval judge (default anthropic/claude-haiku-5.5 — another vendor than the default writer, so a report is not graded by the family that wrote it; keep that true if you change the writer). Defaults apply to new installs and the environment: an installed service keeps the models saved in its settings. Judge scores before and after the judge default changed (2026-10-17, from the strong model) are not comparable. Prices for the cost estimate are in research_agent/pricing.py (OpenRouter catalog, read 2026-10-17; scripts/check_prices.py re-reads it).
  • RESEARCH_MAX_LLM_USD — the run’s LLM spend ceiling (see “How it works”).
  • RESEARCH_RUNS_DIR — where runs are written, for the stdio MCP server and every CLI command alike (run, status, report, rewrite, export, prune). Default ./runs relative 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 [--run-id ] [--max-llm-usd ] [--key value …], list-runbooks, status , report , rewrite , export [--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.

MCP

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 the next LLM call would cross max_llm_usd (minus the writer reserve for research stages), or a report-stage call (writer, repair, citation check) was refused; the report is written from what was gathered, or is the evidence ledger listed
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//.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.com

Expected: a markdown report under runs//report.md plus an HTML export, with a source log and confidence ladder.

Develop

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/   # 5-dimension quality rubric for a finished run

Authoring a new runbook: docs/runbook-authoring.md. Architecture decisions and inherited traps: docs/knowledge-pack.md.

How it works

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 against max_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 of max_usd, never zero); stats.cost_source says server, mixed, estimate, or null before any tool attempt. The runbook budget caps Prowl tool calls; the agent’s own LLM spend (planning, claim extraction, transforms, writing, repair, citation checks) is capped separately by max_llm_usd: --max-llm-usd / MCP max_llm_usd, else RESEARCH_MAX_LLM_USD, else the cap a resumed run recorded, else the runbook’s budget.max_llm_usd, else USD 5. It is a check before every LLM HTTP attempt (retries included), against an estimate of that call: the last call it admits can overshoot by its actual cost minus its estimate, so stats.llm_budget.spend_usd can end slightly above the cap. Each attempt is recorded in the attempt ledger with the provider’s usage.cost (OpenRouter is asked for it), else a price-table estimate (research_agent/pricing.py; an unlisted model is priced at USD 15 / 75 per 1M tokens, above every listed model — never 0); an HTTP error response (429, 5xx) is recorded as unbilled. Research stages stop at the cap minus a writer reserve (25%, at most USD 1) with terminal_code=budget_llm, and once a research call is refused no further tool is dispatched; the writer, repair and citation passes may spend the reserve — a refusal there also ends the run partial / budget_llm — and if even that is gone the report is the evidence ledger as gathered. A resume reads the spend and cap already recorded (also after a re-plan) and keeps an existing report when it gathered nothing new and cannot afford a writer. rewrite --max-llm-usd spends from the same ledger. stats.llm_budget reports cap, reserve, spend, cost source and refused stages; stats.llm_usage the tokens.

  • 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. verified needs 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_trends and serpapi_google_trends are 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 group conflict (a missing unit is compatible with any unit; $ and USD are 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’s verbatim claim without a quote loads as assumed with the reason in verification.detail. Reports written before schema 2 keep their old tags — regenerate them with rewrite_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 (with run_id; under the service they go to ~/Library/Logs/prowl-research/events.log, rotated at 5 × 5 MB).

  • Charts are rendered deterministically from ledger data, never described by the LLM.

Current verification and resume limits

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 (::) 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 (::). 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.

Fabric service and operator dashboard

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.

License

MIT — see LICENSE.

View this README on GitHub

Install

This server does not publish a one-line install command.

Open the repository installation guide

Configuration

{ "mcpServers": { "research-agent": { "command": "prowl-research", "args": ["mcp"], "env": { "PROWL_API_KEY": "prowl_…", "OPENROUTER_API_KEY": "…" } } } }