alphabrief / README.md
Abdr007's picture
AlphaBrief β€” deployed tree
69e310f
|
Raw
History Blame Contribute Delete
18.8 kB
metadata
title: AlphaBrief
emoji: πŸ›°οΈ
colorFrom: indigo
colorTo: gray
sdk: docker
app_port: 7860
pinned: false
short_description: Research agents that cannot make a number up

AlphaBrief

CI

Multi-Agent Research Orchestration System with MCP Tooling & Human-in-the-Loop Governance

A supervisor-pattern agent system that produces a verified morning research brief for a watchlist. A supervisor plans the run, a data agent and a news agent work in parallel through a Model Context Protocol tool server, a writer synthesises a Pydantic-enforced brief, a deterministic verifier recomputes every number, and a human approves before anything ships.

The LLM never does arithmetic. Tools compute over MCP, a deterministic node recomputes every figure in the final brief, and a human gate signs off β€” hallucinated numbers are impossible by construction, not by prompt-begging.

Stack LangGraph Β· MCP (Model Context Protocol) Β· Claude Sonnet 4.6 + Haiku 4.5 Β· FastAPI Β· yfinance Β· Next.js 15 Β· Neon Postgres Β· Langfuse Β· Docker Β· GCP Cloud Run Β· Terraform Β· Vercel Cron
Cost $0. yfinance and RSS are free and keyless; Cloud Run, Vercel, Neon and Langfuse free tiers; ~$0.05–0.15 of Claude per full 5-ticker brief
Quality gates ruff Β· ruff-format Β· mypy --strict Β· 190 pytest tests Β· eslint Β· tsc Β· next build β€” all zero-warning
Verification 100% of numeric claims recomputed from raw price bars before delivery

Why this exists

I did financial data analysis manually for two years β€” pulling prices, computing ratios, scanning news, writing the same morning picture. AlphaBrief is that job as a governed agent system.

The interesting part is not that agents can fetch data. It is that the system is built so that a wrong number cannot reach the reader:

  1. Agents decide what to compute; code computes it. Every figure comes from an MCP tool. The model chooses which metric matters, never what it equals.
  2. The brief cannot contain a typed number. The Pydantic schema rejects any narrative string containing a bare numeral. Figures are written as {{c7}} references into a claim table minted from tool output.
  3. Every claim is recomputed independently. A deterministic node recalculates each figure from the raw price bars, through a different implementation than the tool used, and compares to the cent.
  4. Every quotation is matched. Quoted headlines must appear verbatim in the news actually retrieved for that ticker.
  5. A human signs off. The graph pauses at a LangGraph interrupt over a checkpointer. There is no path from the verifier to delivery that skips it.

Architecture

graph TD
    TRIGGER["Vercel Cron 07:00 weekdays<br/>or the RUN button"] --> SUP

    SUP["<b>SUPERVISOR</b> Β· Haiku 4.5<br/>plans Β· parallel fan-out Β· retries once<br/>hard cap 15 iterations + budget guard"]

    SUP -.parallel fan-out.-> DATA["<b>DATA AGENT</b> Β· Sonnet 4.6<br/>price_history Β· fundamentals Β· compute_metrics"]
    SUP -.parallel fan-out.-> NEWS["<b>NEWS AGENT</b> Β· Sonnet 4.6<br/>fetch_rss_news β†’ sentiment Β· risk events"]

    DATA --> MCP["<b>MCP TOOL SERVER</b><br/>standardised Β· discoverable Β· reusable by any framework"]
    NEWS --> MCP

    MCP --> STATE["<b>SHARED STATE</b> β€” typed, reducer-merged<br/>parallel writes merge; no lost updates"]

    STATE --> SUP
    STATE --> WRITER["<b>WRITER</b> Β· Sonnet 4.6<br/>Pydantic-enforced brief Β· assembles only"]

    WRITER --> VERIFY["<b>VERIFICATION NODE</b> β€” deterministic, never an LLM<br/>recomputes every claim from raw bars"]
    VERIFY -.mismatch, once.-> WRITER
    VERIFY --> GATE["<b>HUMAN GATE</b> β€” LangGraph interrupt + checkpointer<br/>Approve / Edit / Reject"]

    GATE --> DELIVER["<b>DELIVERY & ARCHIVE</b><br/>SMTP email Β· Neon Postgres Β· Langfuse trace"]

The graph is assembled in app/graph/build.py and this diagram is reproducible with:

python -c "import sys; sys.path.insert(0,'apps/api'); from app.graph.build import mermaid_diagram; print(mermaid_diagram())"

The parallel fan-out, concretely

The supervisor's conditional edge returns a list of node names, which puts both workers in the same LangGraph superstep. They write to the same state object concurrently, so every shared channel carries an explicit reducer:

prices: Annotated[dict[str, PriceHistory], merge_mapping]  # data agent
sentiment: Annotated[dict[str, Sentiment], merge_mapping]  # news agent
errors: Annotated[list[RunError], append_errors]  # BOTH
attempts: Annotated[dict[str, int], merge_counters]  # BOTH
token_spend: Annotated[TokenSpend, merge_spend]  # BOTH
iterations: Annotated[int, operator.add]  # supervisor

There is no read-modify-write anywhere β€” only commutative merges applied by the runtime. tests/test_reducers.py proves both agents' writes survive a real superstep, not a mocked one.


The MCP tool server

All market, news and metric capability is exposed over MCP, so the tools are standardised, discoverable, and reusable by any agent framework β€” not just this app. The server runs over stdio inside the same container (python -m app.mcp_server), and the same tools are listable over HTTP at GET /v1/mcp/tools.

The tool set is a closed whitelist, enforced on both the server and the client. There is no run_python, no eval, no shell.

Tool Signature Returns
get_price_history (ticker: str, days: int = 120) Daily OHLCV bars, oldest β†’ newest, in the quote currency. On an unknown ticker: an error string and empty bars β€” never an exception.
get_fundamentals (ticker: str) Company name, sector, currency, trailing and forward P/E, market cap. Individual fields may be null (a loss-making company has no trailing P/E).
compute_metrics (ticker: str, bars: list[PriceBar], pe_ratio: float | None) last_close, previous_close, change_1d_pct, return_30d_pct, volatility_annualised_pct, max_drawdown_pct, pe_ratio, plus the exact baseline date used for the 30-day return.
fetch_rss_news (ticker: str, limit: int = 6) De-duplicated headlines merged from Yahoo Finance and Google News RSS. An empty list with no error is a legitimate outcome.

Metric definitions are fixed and published, because the verifier has to reproduce them exactly:

  • return_30d_pct β€” change from the close of the last bar dated on or before last_trading_date βˆ’ 30 calendar days to the latest close.
  • volatility_annualised_pct β€” sample standard deviation (ddof = 1) of daily log returns over the window, Γ— √252, in percent.
  • max_drawdown_pct β€” the most negative value of close / running_peak βˆ’ 1, always ≀ 0.

Tools are cached per run, politely rate limited, and failure-tolerant by contract: they return a structured error field rather than raising, which is what makes graceful degradation possible at all.


How a number gets into the brief

yfinance bars ──► compute_metrics (MCP) ──► claim table  c1..cN
                                                β”‚
                     writer may cite {{c7}} ─────  (schema rejects bare numerals)
                                                β”‚
raw bars ──► app/graph/recompute.py ────────────┴──► compare to the cent
                (independent implementation)          β”‚
                                                      β”œβ”€ match     β†’ human gate
                                                      └─ mismatch  β†’ 1 regeneration
                                                                    β†’ HUMAN_REVIEW

recompute.py deliberately does not import the tool's maths. It implements the same published definitions through different code β€” Welford's online variance instead of a two-pass mean, itertools.accumulate instead of a running-peak loop, bisect instead of a forward scan. A test asserts the two paths agree on live market data, so the dual-path check is real rather than decorative.


Production quality bar

Category How it is met
Loop safety Supervisor hard cap of 15 iterations and an independent per-run token/USD budget guard that aborts with BUDGET_ABORT. test_forced_loop_terminates wires workers that never complete and proves the run still stops.
Race conditions Reducer-merged state (above); DB writes are single committed transactions; one active run per watchlist is enforced by a partial unique index in the database, not by an application check.
Graceful degradation Fake ticker, dead network and empty RSS each have dedicated tests that induce the failure for real β€” an unknown symbol, an unroutable proxy, a genuinely empty feed. The run completes with per-ticker errors and a brief explicitly marked partial.
Numeric integrity 100% of claims recomputed; mismatch β†’ exactly one regeneration β†’ HUMAN_REVIEW. The eval reports post-verification accuracy across 20 runs.
Security MCP tools whitelisted; agents cannot widen their assigned ticker scope; news text is hardened, fenced in <untrusted_data> and treated as data; secrets only via env; approval endpoints behind a constant-time bearer check; security headers, body-size cap and per-client rate limiting on every request.
Zero warnings ruff, ruff-format, mypy --strict, pytest (with filterwarnings = error), eslint, tsc and next build all clean.

Running it locally

Prerequisites

Python 3.12 (via uv) and Node 20+.

1. API

cd apps/api
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python -e ".[dev]"

cp ../../.env.example ../../.env      # then edit
.venv/bin/uvicorn app.main:app --port 7877 --reload

Open http://127.0.0.1:7877/docs.

You do not need a Claude API key to run this. With ANTHROPIC_API_KEY unset, the app uses a deterministic engine that drives the identical graph, the identical MCP tools and the identical verifier β€” only the model's decision-making is replaced by rules. Market data is still live. This is what keeps CI and the 20-run eval free. Add the key and it switches to Claude.

2. Web console

cd apps/web
npm install
ALPHABRIEF_API_URL=http://127.0.0.1:7877 \
ALPHABRIEF_APPROVAL_TOKEN=<same as APPROVAL_TOKEN> \
npm run dev

Open http://localhost:3001 and press RUN.

The browser never talks to the API directly β€” every call is proxied through a Next route handler so the approval token stays server-side.

3. Quality gates

make check      # ruff + format + mypy + pytest + eslint + tsc + next build
make eval       # 20 scored runs β†’ eval/results.md

Free deployment map

Service Free tier used Limit to respect
GCP Cloud Run (primary API host) 2M requests + 360k GB-seconds/month, forever Keep min_instance_count = 0; 1 vCPU / 512Mi. Needs a card on file, though this usage does not bill
Koyeb (no-card fallback, untested) 1 web service from a Docker image 0.1 vCPU / 512Mi. pandas and numpy arrive via yfinance, so memory is tight β€” a sibling project with a heavier stack was OOM-killed at this size
Vercel Hobby: web + Cron Cron on Hobby is once/day β€” exactly the 07:00 run
Neon Free Postgres Ample for runs, briefs, approvals
Langfuse Free tier Ample for traces
yfinance / RSS / Gmail SMTP Free, keyless / app password Cached per run; polite rate limiting
Claude API Your key Haiku-routed supervisor + capped iterations β†’ ~$0.05–0.15 per brief

GCP Cloud Run (recommended)

PROJECT=your-project
REGION=us-central1
REPO=$REGION-docker.pkg.dev/$PROJECT/alphabrief-api-images

gcloud auth login
gcloud config set project $PROJECT
gcloud services enable run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com
gcloud artifacts repositories create alphabrief-api-images --repository-format=docker --location=$REGION

gcloud auth configure-docker $REGION-docker.pkg.dev
docker build -t $REPO/api:v1 -f apps/api/Dockerfile .
docker push $REPO/api:v1

gcloud run deploy alphabrief-api \
  --image $REPO/api:v1 --region $REGION \
  --port 7860 --allow-unauthenticated \
  --min-instances 0 --max-instances 2 \
  --memory 512Mi --cpu 1 --timeout 900 \
  --set-env-vars ENVIRONMENT=production,MCP_TRANSPORT=stdio,CORS_ALLOW_ORIGINS=https://alphabrief.vercel.app \
  --set-secrets ANTHROPIC_API_KEY=alphabrief-api-anthropic-api-key:latest,APPROVAL_TOKEN=alphabrief-api-approval-token:latest,DATABASE_URL=alphabrief-api-database-url:latest

Or declaratively β€” which is what puts Terraform (IaC) on the resume honestly:

cd infra/terraform
terraform init
terraform apply -var project_id=$PROJECT -var image=$REPO/api:v1

Koyeb (no card needed)

Create a web service from apps/api/Dockerfile. The image already listens on 7860 and runs as uid 1000, so it drops straight onto any host that refuses root β€” no changes needed. Set the same environment variables as service secrets.

The free instance is 0.1 vCPU / 512 MiB. yfinance pulls in pandas and numpy, so memory is tight and briefs are slower than the figures in this README, which were measured on a laptop. Fine for a shareable link; use Cloud Run for a live demo.

Vercel (web + cron)

cd apps/web
vercel deploy --prod

Set ALPHABRIEF_API_URL, ALPHABRIEF_APPROVAL_TOKEN and ALPHABRIEF_DEFAULT_WATCHLIST as project environment variables. vercel.json already declares the weekday 07:00 cron hitting /api/trigger; Vercel supplies CRON_SECRET, which the route verifies in constant time.

Neon, Langfuse, Gmail

  • Neon β€” create a project, copy the connection string into DATABASE_URL. Tables and the LangGraph Postgres checkpointer are created on boot.
  • Langfuse β€” create a project, set LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY. Without them the tracer is a silent no-op.
  • Gmail β€” enable 2FA, create an app password, set SMTP_*. Without SMTP the brief is archived but not emailed, and the run says so.

Production upgrade path

yfinance is free, real and rate-limited by courtesy rather than contract. For production the swap is one module: app/mcp_server/providers.py is the only place a provider is called, so moving to Polygon, Databento or a paid feed means changing that file and nothing else β€” the MCP tool contracts, the graph, the verifier and the UI are unaffected.


Design decisions worth defending

Why LangGraph over CrewAI or AutoGen. I evaluated all three. CrewAI is faster to a first demo but gives less control over state and routing; AutoGen models work as a conversation between agents, which is hard to make deterministic. LangGraph gives an explicit graph, typed reducer-merged state, and β€” decisively here β€” native human-in-the-loop via interrupts over a checkpointer. Production control beat time-to-first-demo.

Why MCP for the tool layer. It is the USB-C of AI tools: the same four tools are consumable by this LangGraph app, by Claude Desktop, or by any other MCP client, with schemas and documentation discoverable at runtime. It also draws a hard security boundary β€” a closed, whitelisted tool surface.

Why runtime context, not config["configurable"]. LangGraph filters unknown configurable keys when a checkpointer is attached, so live handles (the open MCP session, the event bus) must travel in the runtime context channel, which is deliberately not persisted. This was found by testing, not by reading.

Why n8n is not in the cloud path. I used n8n for scheduling in an earlier project. Here Vercel Cron does the same job with one less service to run, and the scheduled path is a plain authenticated HTTP call that is trivial to test.


Visual identity

AlphaBrief is an amber phosphor terminal: true black #08090b, amber #ffb454, phosphor green #35d07f for verified figures, monospace throughout, ruled columns and CRT scanlines. Not a dashboard β€” a trading-floor instrument.

That is a deliberate departure from the build spec, which asked all three portfolio demos to share one dark/cyan design system. Two of them already had byte-identical tokens, so a third would have read as the same template three times. The reasoning is recorded in SPEC_TRACE.md Β§Deviations.


Repository layout

alphabrief/
β”œβ”€β”€ apps/
β”‚   β”œβ”€β”€ api/                    FastAPI + LangGraph + MCP server (one container)
β”‚   β”‚   β”œβ”€β”€ app/
β”‚   β”‚   β”‚   β”œβ”€β”€ graph/          state Β· supervisor Β· data_agent Β· news_agent
β”‚   β”‚   β”‚   β”‚                   writer Β· verify Β· gate Β· deliver Β· recompute
β”‚   β”‚   β”‚   β”œβ”€β”€ mcp_server/     prices Β· fundamentals Β· metrics Β· rss_news
β”‚   β”‚   β”‚   β”œβ”€β”€ core/           claude router Β· langfuse Β· budget guard Β· settings
β”‚   β”‚   β”‚   β”œβ”€β”€ models/         brief schema Β· market contracts Β· SQLAlchemy tables
β”‚   β”‚   β”‚   β”œβ”€β”€ services/       runner Β· repository Β· render Β· email
β”‚   β”‚   β”‚   └── api/            routes Β· schemas
β”‚   β”‚   └── tests/              190 tests incl. every mandated case
β”‚   └── web/                    Next.js 15 console
β”œβ”€β”€ eval/                       run_eval.py β†’ results.md
β”œβ”€β”€ infra/terraform/            Cloud Run + Secret Manager, declaratively
β”œβ”€β”€ AUDIT.md                    every Definition-of-Done check, with evidence
β”œβ”€β”€ SPEC_TRACE.md               spec requirement β†’ implementation β†’ test
β”œβ”€β”€ LEARNING.md                 the study path through this codebase
└── RESUME.md                   resume bullets + ATS skills block

Documentation

  • AUDIT.md β€” the Definition of Done, check by check, with the command that proves each one.
  • SPEC_TRACE.md β€” every requirement in the build spec mapped to the file that implements it and the test that holds it.
  • LEARNING.md β€” the order to read this codebase in, and the interview question each file answers.
  • RESUME.md β€” resume bullets with real measured numbers.
  • eval/results.md β€” the latest 20-run evaluation.

License

MIT.