Beyond Markets — Agents

How the multi-agent pipeline is wired: which skill runs in which phase, on which model, with which tools and context. Everything below is verified against the code — agents/orchestrator.py, agents/config.json, agents/runner.py, the agents/skills/*.md files, and the route call sites.

Pipeline overview

The orchestrator runs agents in a strict phased pipeline; each phase's output becomes context for the next.

The Planner (Sonnet 5) runs first for free-form requests. A fast regex pre-check and the planner can each reject a request ("mode": "rejected"); otherwise the planner emits a JSON plan — mode, targets, and a per-agent {activate, task} map. The orchestrator itself hardcodes no activation matrix: an agent runs only when its plan activate flag is set. The planner skill (agents/skills/planner.md) is where the per-mode activation rules live.

The planner is skipped only for pre-baked requests: catalog / periodic entries in requests.json ship a full plan (_PLAN_MAP in orchestrator.py). See Pre-baked modes.

The mode splits the pipeline into two shapes that differ in their Phase-2 analysts and which backdrops are injected.

A. Ticker-driven — equity_deep_dive, swing_trade

The request names specific stock(s), so targets is non-empty. A background thread builds a fresh per-symbol snapshot for the first 2 targets (overlaps Phase 1, joined before Phase 2). Company news rides inside that snapshot, so no separate Market News block is built.

%%{init: {'flowchart': {'wrappingWidth': 420, 'nodeSpacing': 45, 'rankSpacing': 45}, 'themeVariables': {'fontSize': '12px'}}}%%
flowchart TD
    U["<b>User Request</b><div style='text-align:left'><u>In:</u> —<br/><u>Does:</u> user types a ticker request<br/><u>Out:</u> raw text (&quot;Analyse AAPL&quot; · &quot;swing trade NVDA&quot;)</div>"]
    P["<b>Planner · Sonnet 5</b><div style='text-align:left'><u>In:</u> raw request<br/><u>Does:</u> classify the mode and extract the named tickers<br/><u>Out:</u> plan · mode = equity_deep_dive | swing_trade · targets = [AAPL, …] (non-empty)</div>"]
    G["<b>Phase 1 — Gather (Haiku, parallel)</b><div style='text-align:left'><u>In:</u> planner task strings<br/><u>Does:</u> market_data · geopolitical_risk call external APIs (conditional — usually skipped here; see activation table)<br/><u>Out:</u> compressed data summaries</div>"]
    S["<b>Symbol-snapshot builder (background thread)</b><div style='text-align:left'><u>In:</u> first 2 target tickers<br/><u>Does:</u> fetch quote · fundamentals + DCF + statements · technicals · analyst ratings · short/liquidity · whales · company news (overlaps Phase 1, joined before Phase 2)<br/><u>Out:</u> snapshots/dashboard_&lt;sym&gt;.md per symbol</div>"]
    B["<b>Backdrops → every Phase 2/3/4 agent</b><div style='text-align:left'><u>In:</u> Market Snapshot (global macro) + Symbol Snapshots<br/><u>Does:</u> assemble context blocks (no Market News — company news is in the symbol snapshot)<br/><u>Out:</u> context injected into every downstream agent</div>"]
    A2["<b>Phase 2 — Sequential Analysis</b><div style='text-align:left'><u>In:</u> all prior output + backdrops<br/><u>Does:</u> analysts run in order, each seeing prior output — equity_deep_dive: equity_analyst → swing_trading_analyst → scenario · swing_trade: swing_trading_analyst<br/><u>Out:</u> analysis summaries</div>"]
    C["<b>Phase 3 — Review</b><div style='text-align:left'><u>In:</u> Phase-2 outputs + both snapshots<br/><u>Does:</u> critic (Opus 5) stress-tests the analysis<br/><u>Out:</u> critique for the writer</div>"]
    W["<b>Phase 4 — Report</b><div style='text-align:left'><u>In:</u> all P1–P3 summaries + both snapshots<br/><u>Does:</u> report_writer synthesises into one narrative<br/><u>Out:</u> final markdown report</div>"]
    R["<b>Output</b><div style='text-align:left'><u>In:</u> finished report<br/><u>Does:</u> persist to disk<br/><u>Out:</u> reports/{tenant_id}/*.md (+ .meta.json, .log)</div>"]

    U --> P
    P --> G
    P -.-> S
    G --> B
    S -.-> B
    B --> A2 --> C --> W --> R

B. Ticker-less — market_brief, macro_outlook, news_digest, data_pull

The request is market-wide, so targets = [] and no per-symbol snapshots are built. For market_brief / macro_outlook / news_digest a background thread aggregates a market-wide Market News block (top-25 deduped headlines). data_pull skips both the news build and the critic; its report is written straight from the Phase-1 data table.

%%{init: {'flowchart': {'wrappingWidth': 420, 'nodeSpacing': 45, 'rankSpacing': 45}, 'themeVariables': {'fontSize': '12px'}}}%%
flowchart TD
    U["<b>User Request</b><div style='text-align:left'><u>In:</u> —<br/><u>Does:</u> user asks a market-wide question (no ticker)<br/><u>Out:</u> raw text (&quot;market brief&quot; · &quot;macro outlook&quot; · &quot;what's happening&quot; · data pull)</div>"]
    P["<b>Planner · Sonnet 5</b><div style='text-align:left'><u>In:</u> raw request<br/><u>Does:</u> pick a ticker-less mode<br/><u>Out:</u> plan · mode = market_brief | macro_outlook | news_digest | data_pull · targets = [] (empty)</div>"]
    G["<b>Phase 1 — Gather (Haiku, parallel)</b><div style='text-align:left'><u>In:</u> planner task strings<br/><u>Does:</u> market_data (brief / data_pull; skipped in macro_outlook / news_digest) · geopolitical_risk (conditional)<br/><u>Out:</u> compressed data summaries</div>"]
    N["<b>Market-News builder (background thread)</b><div style='text-align:left'><u>In:</u> — (SKIPPED for data_pull)<br/><u>Does:</u> _build_market_news() aggregates Benzinga live + Polygon + FMP + NewsAPI, deduped (overlaps Phase 1)<br/><u>Out:</u> top 25 market-wide headlines</div>"]
    B["<b>Backdrops → every Phase 2/3/4 agent</b><div style='text-align:left'><u>In:</u> Market Snapshot (global macro) + Market News<br/><u>Does:</u> assemble context blocks (no Symbol Snapshots — no named targets)<br/><u>Out:</u> context injected into every downstream agent</div>"]
    A2["<b>Phase 2 — Analysis</b><div style='text-align:left'><u>In:</u> backdrops<br/><u>Does:</u> equity_analyst / swing_trading_analyst / scenario — <b>ALL SKIPPED in these modes</b><br/><u>Out:</u> —</div>"]
    C["<b>Phase 3 — Review</b><div style='text-align:left'><u>In:</u> Phase-1 output + Market Snapshot + Market News<br/><u>Does:</u> critic reviews (skipped in data_pull)<br/><u>Out:</u> critique for the writer</div>"]
    W["<b>Phase 4 — Report</b><div style='text-align:left'><u>In:</u> everything + Market Snapshot + Market News<br/><u>Does:</u> report_writer composes the narrative<br/><u>Out:</u> final markdown report</div>"]
    R["<b>Output</b><div style='text-align:left'><u>In:</u> finished report<br/><u>Does:</u> persist to disk<br/><u>Out:</u> reports/{tenant_id}/*.md (+ .meta.json, .log)</div>"]

    U --> P
    P --> G
    P -.-> N
    G --> B
    N -.-> B
    B --> A2 --> C --> W --> R

Both flows share Phase 3 (critic, except data_pull) and Phase 4 (the single report_writer, run in every mode). An optional Phase 5 translation pass runs whenever a tenant has active translation languages, independent of mode.

Skills overview

Every skill in agents/skills/, the view it powers, its runtime LLM, and the context fed into it. This spans the report pipeline plus the Trades, Dashboard, and Today features (which call AgentRunner directly from their routes). Screener / Order Cockpit are not AI-agent-driven.

Skill Phase / caller View LLM Input provided
planner Phase 0 Reports Sonnet 5.5 user request only
market_data Phase 1 Reports Haiku 5.5 planner task string
geopolitical_risk Phase 1 Reports Haiku 5.5 planner task string
equity_analyst Phase 2 Reports Sonnet 5.5 Phase-1 summaries + Market Snapshot + Symbol Snapshot
swing_trading_analyst Phase 2 / Trades propose Reports + Trades Sonnet 5 (reports) / Opus 5 (trades) Two-sided momentum funnel (hard gates + 8-point scored checklist, score-derived probability). Reports: as equity_analyst. Trades: Market Snapshot + runtime prompt (allowed universe + held-ticker exclusion); per-symbol data on demand via get_symbol_snapshot
scenario Phase 2 Reports Sonnet 5.5 Phase-1 summaries + prior Phase-2 output + both snapshots
critic Phase 3 Reports Opus 5.5 Phase-2 outputs + both snapshots
report_writer Phase 4 (all modes) Reports Sonnet 5.5 all P1–P3 summaries + Market Snapshot + Market News + (equity/swing) both snapshots
order_brief routes/trades.py condenser Trades Haiku 5.5 finalized swing_trading_analyst output
news_summary routes/news.py + dashboard_interpret.py News / Dashboard Haiku 5.5 deduped multi-source headline list
dashboard_brief routes/dashboard_interpret.py Dashboard Haiku 5.5 live section/card JSON
dashboard_brief_symbol routes/dashboard_interpret.py Dashboard Sonnet 5.5 per-symbol snapshot JSON
today_brief routes/today.py + scheduler Today Haiku 5.5 global dashboard JSON
today_macro routes/today.py + scheduler Today Haiku 5.5 global dashboard JSON

Conditionally active: the "Market Morning Briefing" pre-baked requests.json plan activates report_writer with a fixed 7-section VIP task string (the planner never picks a distinct VIP writer). swing_trading_analyst is the sole swing analyst skill, used for both reports and the Trades propose flow regardless of a tenant's analyst setting (that setting is retained only as proposition metadata).

Agent activation by mode

For free-form requests the planner emits one mode and sets each agent's activate. Rules are in agents/skills/planner.md (Mode Selection Rules + Guidelines).

Agent equity_deep_dive swing_trade market_brief macro_outlook news_digest data_pull
market_data · Yes Yes — — Yes
geopolitical_risk · · · · — —
equity_analyst Yes — — — — —
swing_trading_analyst Yes Yes — — — —
scenario Yes — — — — —
critic Yes Yes Yes Yes Yes —
report_writer Yes Yes Yes Yes Yes Yes

· = conditional; the planner activates it only for a request-specific need the backdrops can't cover:

Exactly one report writer runs per mode; critic runs in every mode except data_pull. Always-injected backdrops are not agents — see Context backdrops.

Runtime models

The model: in a skill's frontmatter is informational. The authoritative per-agent model is AGENT_MODEL in agents/config.json (loaded by orchestrator.py); anything unlisted inherits the orchestrator default (claude-sonnet-5-5).

agents/config.json no longer holds model IDs — it maps each agent onto a role (LLM_REPORT_DATA_MODEL, LLM_REPORT_ANALYSIS_MODEL, LLM_REPORT_CRITIC_MODEL, LLM_REPORT_GENERATION_MODEL). llm_config.py turns a role into an ID: .env first, then llm_config.DEFAULTS, following one more hop if the value is itself a variable name (LLM_REPORT_CRITIC_MODEL=LLM_CLAUDE_OPUS_MODEL=claude-opus-5-5). Non-report call sites ask for their own roles the same way — TRADE_ANALYZER, TRADE_BRIEF, TRANSLATOR, NEWS, DASHBOARD_BRIEF, TODAY. The table below is the shipped default; .env can move any row without a code change, but a new ID also needs a price in subscriptions.json.

Phase / agent Model Max tokens
Phase 0 — planner Sonnet 5.5 (default) 8192
Phase 1 — market_data Haiku 5.5 12288
Phase 1 — geopolitical_risk Haiku 5.5 8192
Phase 2 — equity_analyst, swing_trading_analyst Sonnet 5.5 8192
Phase 2 — scenario Sonnet 5.5 (default) 8192
Phase 3 — critic Opus 5.5 8192
Phase 4 — report_writer Sonnet 5.5 16384

Model IDs: Haiku 5.5 = claude-haiku-5-5, Sonnet 5.5 = claude-sonnet-5-5, Opus 5.5 = claude-opus-5-5.

Route-invoked callers set the model at the call site:

Caller Skill Model Max tokens
routes/trades.py (propose) swing_trading_analyst Opus 5 16384
routes/trades.py (propose) critic Sonnet 5.5 16384
routes/trades.py (condenser) order_brief Haiku 5.5 512
routes/news.py + dashboard_interpret.py news_summary Haiku 5.5 512
routes/dashboard_interpret.py dashboard_brief Haiku 5.5 400–1024
routes/dashboard_interpret.py dashboard_brief_symbol Sonnet 5.5 1024
routes/today.py + scheduler today_brief Haiku 5.5 2048
routes/today.py + scheduler today_macro Haiku 5.5 2048

The Trades propose flow is a 4-agent pipeline (not a single analyst call) — see Trade suggestion workflow. There is only one swing analyst skill file (swing_trading_analyst.md); the _b / _c variants no longer exist. A tenant's analyst setting (a / b / c) is retained only as proposition metadata and does not select a different skill.

Context backdrops

Injected into agents regardless of mode — data blocks, not agents. Assembled in _build_context and prepended before per-agent outputs.

Data sources per agent

The authoritative mapping for the report pipeline is agent_tools in agents/config.json (exposed as AGENT_TOOLS). An entry is either module (all of that module's tools) or module:tool1,tool2 (a named subset).

Agent Tool modules (subset after :)
market_data fmp, polygon, finra_short, sec, openfigi, finviz:screener,latest_filings,quote_history,group_performance, 13f, uw, alpaca_mktdata:get_market_movers,get_most_active,get_stock_snapshots
geopolitical_risk newsapi_ai, newsdata, benzinga:benzinga_news, gdelt:search_gdelt_news,get_gdelt_geo_news, web_search
equity_analyst fmp:get_batch_quotes,get_key_metrics,get_financial_statements
swing_trading_analyst fmp:get_technical_indicator, uw:ext_get_oi_changes,ext_get_options_screener
planner, scenario, critic, report_writer none (pure reasoning / writing)

Route-invoked agents are not in config.json — tools are passed at the call site. swing_trading_analyst gets a larger toolset in the Trades propose flow than in its Phase-2 report run:

Agent Caller Tool modules
swing_trading_analyst (+ _finalize) routes/trades.py (propose) fmp, fred, finra_short, newsapi_ai, symbol_snapshot, newsdata:search_newsdata, uw:ext_get_options_flow,ext_get_oi_changes,ext_get_options_screener
critic routes/trades.py (propose) none
order_brief routes/trades.py none
news_summary routes/news.py, routes/dashboard_interpret.py none
dashboard_brief, dashboard_brief_symbol routes/dashboard_interpret.py none
today_brief, today_macro routes/today.py none

Tool module registry

Short names resolve to modules via TOOL_MODULES in agents/runner.py:

fmp  fred  imf  newsapi  newsapi_ai  gdelt  newsdata  sec  openfigi
macro_liquidity  finviz  13f  uw  economic_calendar  iborrowdesk  polygon
finra_short  finra_margin  eodhd  sec_ftd  benzinga  alpaca_mktdata
symbol_snapshot

Report writer selection

Phase 4 runs the single report_writer in every mode. It receives the plan's mode and targets in its task. Fallback: if report_writer is not activated, or the writer call raises, the orchestrator concatenates the raw Phase-1/2/3 outputs (\n\n---\n\n) as the report.

Trade suggestion workflow

The Trades view proposes 1–3 day swing trades executed on Alpaca. Unlike the report pipeline (driven by the orchestrator + planner), the propose flow lives in routes/trades.py::generate_proposition and wires the agents by hand — but it reuses the report pipeline's skills, models, tools, and context helpers.

The propose pipeline (4 agents)

generate_proposition(tenant_id) runs discovery-first (no planner, no named target) and ends in a structured proposition rather than a saved report. Each phase sees the prior phases as compressed context, plus the Market Snapshot backdrop throughout and the discovered ticker's Symbol Snapshot from Phase 2 on.

Trimmed on 2026-08-19 for cost, not correctness: equity_analyst and scenario ran as phases 2 and 3 (~$4.4/day) for output the finalize pass barely used, and the critic ran on Opus (~$3/day). A 1–3 day entry needs a level, a stop, a target and a catalyst — reports keep both agents and keep Opus for the critic.

# Agent Model Role
1 swing_trading_analyst Opus 5 Discovery — scans the allowed universe, picks ONE ticker, emits an initial Final Trade Summary. Calls get_symbol_snapshot per candidate.
2 critic Sonnet 5.5 Stress-tests the trade — attacks the load-bearing claim, checks entry/stop/target and the ≥2:1 reward:risk.
3 swing_trading_analyst (_finalize) Sonnet 5.5 Confirms / adjusts / withdraws the SAME ticker per the critique. Outputs the final plain + JSON Final Trade Summary.
4 order_brief Haiku 5.5 Condenses the finalized trade into 5 plain-English thesis bullets.

The result is saved to the propositions table (.data/trades.db) and spend is recorded via record_feature_usage(..., "trade", ...).

Guards (the analyst can disobey the prompt, so each is enforced in code): - Held-ticker exclusion — trade_db.get_active_symbols(tenant_id, mode) feeds both a prompt exclusion clause and hard drops at Phase 1 and Phase 3. - Watchlist universe — if the tenant set watchlist_filter, the candidate universe is restricted to those symbols (minus held); empty → "no offer" note. - Ticker-switch backstop — if the finalize pass switches away from the reviewed ticker, the proposition is discarded (only the vetted ticker ships). - Critic gate — a finalized trade with no ticker/entry is treated as withdrawn. - Cancellation token — _generating[tenant_id] holds a per-generation token; the cancel endpoint or a newer run overwrites it. A stale token means the result is discarded before save/auto-submit (the thread can't be killed, but its side effects are withheld).

Manual vs automatic

Two independent tenant settings gate the two halves — generate and submit:

Setting Controls Off (manual) On (automatic)
auto_pilot proposition generation user clicks Suggest Order to generate on demand the scheduler loop is the only generator; the manual endpoint returns 409
auto_order order submission user clicks Buy/Sell to place the bracket order a freshly-generated proposition is auto-submitted to Alpaca

analyst (a/b/c) and budget / per-account order_limit are also tenant settings; analyst is metadata only (single skill).

Manual generation — POST /api/trades/propose (trades_propose, VIP-gated) spawns generate_proposition in a background thread regardless of proposition age; returns 202 {generating:true}. Manual mode only: with auto_pilot on it returns 409 — the backend owns the rhythm and a manual run on top of it is an extra Sonnet pass plus a proposition racing the loop's. The frontend disables the button in that state anyway (btnDisabled = generating || _tradesAuto). POST /api/trades/propose/cancel pops the tenant from _generating so the UI stops spinning and the in-flight result is discarded on completion.

Automatic generation — exactly one trigger, auto-pilot-only: Scheduler Thread 4 (scheduler._trade_propose_loop) — every 20 min, aligned to :00/:20/:40 UTC, only during NYSE regular hours (Mon–Fri 09:30–16:00 ET, via Alpaca's clock). For each VIP tenant with auto_pilot on and below the position cap, if the latest proposition is missing / submitted / expired / older than the interval, it generates and then notifies (SMS + email via _notify_proposition).

Loading the trades page starts nothing. It once mirrored the same check as a catch-up for a loop that slept boot-relative and could drift; the loop now wakes on the wall clock with _last_propose_at starting at 0, so a restart catches up by itself.

Submission — submit_proposition(tenant_id, proposition, budget, mode) places a bracket limit order on Alpaca (entry + stop + target), sizes quantity from the account's order_limit/budget, enforces the per-account position cap (_position_cap: paper 20 / live 10), and creates the positions row. It's called from the manual Buy/Sell endpoint (source="manual") and, when auto_order is on, from _auto_submit_if_enabled after every generation path (the scheduler loop and manual Suggest Order) — so auto-submit never depends on which path created the proposition. Auto-submit only runs during regular trading hours, requires the mode's Alpaca keys to be configured, and skips tickers already held (skip_if_held=True).

Source of truth: Alpaca is authoritative for open positions; the DB adds context (stop/target/proposition/opened_at). Positions Alpaca holds with no DB record are auto-registered on the next trades load (get_bracket_context reconstructs stop/target/opened_at from Alpaca orders). See CLAUDE.md → Trades.

Pre-baked plans

Catalog / periodic requests in requests.json (≈22 entries) ship a full plan (planner skipped). They all use the generic planner modes — market_brief, macro_outlook, swing_trade, data_pull — so the orchestrator's mode branches behave exactly as for a planner-produced plan. Many performance / screen entries run as data_pull (no critic).

Where a request needs a bespoke layout, that layout lives in the request's report_writer task string, not in a custom mode: report_writer.md gives an explicit task structure precedence over the mode's template. The "Market Morning Briefing" (7-section VIP format, trade ideas with anchors) works this way under market_brief.

Skill definitions

Each agent is a markdown skill (agents/skills/*.md) loaded as the system prompt by AgentRunner. All pipeline agents end their output with a <summary> block (1000–2000 chars) passed as compressed context downstream; the writers and the route-only XML skills do not. Below, LLM = runtime model (see Runtime models).

planner — Sonnet 5

Orchestration brain. No tools. Validates the request (rejects off-topic / inappropriate / PII / prompt-injection with "mode": "rejected") then emits a single JSON plan: mode, targets, and a per-agent {activate, task} map. No prose output. Vague requests default to equity_deep_dive (tickers), market_brief (sectors), or macro_outlook (economy).

market_data — Haiku 4.5

Data-fetch specialist, minimal interpretation. Tools: FMP (quotes, prices, company, analyst, insider, filings, technicals, market-wide, research bundle), SEC EDGAR, OpenFIGI, FINRA short, SEC FTD, plus the config subsets above. Budget ≤ 15 tool calls; prefers batch tools. Output: structured tables (Quote Summary, Financial Summary, Key Metrics, Analyst Consensus, Technical Levels, Sector Landscape) + <summary>.

geopolitical_risk — Haiku 4.5

Geopolitical risk analyst. Tools: NewsAPI.ai, NewsData, GDELT, Benzinga, web_search (≤ 3 uses); budget ≤ 8 tool calls. Output: Geopolitical Risk Register table (probability × impact × timeframe × affected assets), per-risk detail, sentiment, central-bank watch + <summary>.

equity_analyst — Sonnet 5

Senior equity research analyst. Leans on the injected Symbol Snapshot and fetches only missing data (config subset: batch quotes, key metrics, statements). Output: Business Quality, Competitive Position, Financial Analysis, Earnings, Comparable Company table, Valuation (DCF + relative → blended fair value), Investment Thesis (recommendation, fair-value range, conviction), Catalysts, Risks + <summary>.

swing_trading_analyst — Sonnet 5

Institutional swing analyst for 1–3 day trades in single US equities/ETFs (executed on Alpaca). Reads per-ticker data from the Symbol Snapshot; fetches SMA-20 + OI changes. 9-step top-down process (macro → sector → industry → stock → execution). Output: a plain-text trade card plus a machine-readable JSON block (the "Final Trade Summary") parsed by the order system, then <summary>. Single skill file — the former _b / _c variants are gone; the analyst setting (a/b/c) survives only as proposition metadata. Reused twice in the Trades propose pipeline (discovery pass + _finalize pass) — see Trade suggestion workflow.

scenario — Sonnet 5

Scenario analyst. No tools. Output: Scenario Summary table (bull/base/bear — probability, target, return, driver), per-case assumptions/triggers/signposts, Risk/Reward, Investment Thesis Scorecard, Catalyst Calendar + <summary>. Probabilities sum to 100%; base case most probable (40–60%).

critic — Opus 5

Adversarial reviewer — the last check before a user risks money. No tools; consumes prior outputs + both snapshots. Output: Verdict (lead), Assumption Challenges table, Data Quality Concerns, Analytical Gaps, Counter-Arguments, Bias Check, Confidence Assessment table, Recommendations + <summary>.

report_writer — Sonnet 5

The single writer for every mode. No tools. ADHD-friendly, plain-English style with a jargon-ban table, emoji trend/signal indicators, and per-mode templates and word targets. Ends with the required HTML disclaimer/copyright footer. No <summary>. Also serves pre-baked plans whose structure comes entirely from the request's task string (e.g. "Market Morning Briefing", 7-section VIP layout) — an explicit task structure overrides the mode's template and word target.

Route-only skills

Notes