Skip to content

About

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.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

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); RESEARCH_LLM_MODEL covers 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 ./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 <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.

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 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.com

Expected: a markdown report under runs/<id>/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/<id>   # 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. 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 of max_usd — it is metered, not capped, and reported under stats.llm_usage in 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. 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 reach service.log once B-035 lands).

  • 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 (<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.

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.

About

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.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages