A multi-agent equity research tool for wealth advisors. Combines official primary-source data (SEC + market quotes) with unverified social signal (Reddit, Stocktwits, news), in clearly separated sections.
Type a US-listed ticker, get a one-screen briefing in ~60 seconds: live quote, business snapshot, recent 8-K material events, top 10-K risks (all cited to the filing), plus a separately-labeled "social signal" section showing what people are saying on Reddit, Stocktwits, and recent news — with explicit unverified tags, pump-bot detection, and an explicit divergences section when the two sources disagree.
Built for compliance: never gives buy/sell recommendations, scopes to US public equities only, and shows the advisor the agent's work (every tool call) for audit.
"What does this thing actually do for me before my 9:30am client call?"
wealth-advisor.pages.dev in a browser.F, AAPL, NVDA) and hit Send.| Section | Source | What it tells you |
|---|---|---|
| Snapshot | Yahoo Finance | Price, change, day range, market cap, sector — live. |
| Business snapshot | Latest 10-K Item 1 | 1–2 sentence company description, cited. |
| Material events | Last 5 filings (10-K + 8-K) | What was announced and when, cited per filing. |
| Top risks | Latest 10-K Item 1A | The risk factors the company itself discloses, cited. |
| Social signal | Reddit + Stocktwits + News + volume anomaly | What's being said, with an unverified tag and pump-bot detection. |
| Divergences | Synthesis | Cases where social claims contradict the filings — surfaced, not suppressed. |
"What's the shape of the system and why?"
One Coordinator agent exposes two macro-tools: research_official and research_social. Each macro-tool internally runs its own focused sub-agent with its own system prompt and its own tool set. The coordinator sees clean structured Reports back, never raw tool output, and synthesizes a single briefing.
┌─────────────────────────────────────────────────┐
│ Coordinator Agent (Sonnet 4.6) │
│ tools: research_official, research_social │
│ │ │
│ ├──► Official sub-agent (Sonnet 4.6) │
│ │ prompt: strict primary-source │
│ │ tools: resolve_ticker, quote, │
│ │ filings, 10-K sections, │
│ │ 8-K summary (6 tools) │
│ │ │
│ └──► Social sub-agent (Sonnet 4.6) │
│ prompt: skeptical forum analyst │
│ tools: reddit, stocktwits, news, │
│ volume anomaly (4 tools) │
└─────────────────────────────────────────────────┘
| Layer | Choice |
|---|---|
| Runtime | Cloudflare Pages + Pages Functions (Workers V8 isolates at the edge) |
| Language | TypeScript (strict, noUncheckedIndexedAccess) |
| LLM | @anthropic-ai/sdk, Claude Sonnet 4.6 across all three agents |
| Tool-use loop | Hand-rolled (the Agent SDK doesn't run in Workers — no subprocess) |
| HTML parsing | node-html-parser (Workers-compatible) |
| Front end | Single static index.html, vanilla JS, no build step |
| Server state | None. Browser holds conversation history, posts it back each turn. |
| Tests | vitest — unit, integration, golden behavior, contract |
"What's the threat surface and what does each layer defend?"
| Layer | Defense | Threat addressed |
|---|---|---|
| Input | 2,000-char cap on user messages; JSON.parse validation | Prompt-injection payload size; malformed bodies |
| Input | resolve_ticker rejects non-US tickers (containing .) before any other call | Scope abuse; non-US listings |
| Prompt | System prompt forbids stating facts without a tool result | Hallucinated "facts" |
| Prompt | Hard refusal for investment advice questions | Regulatory exposure (SEC/FINRA "investment advice" rules) |
| Tool | Discriminated-union returns; errors-as-data, not exceptions | Silent failures; partial data passing as complete |
| Output | marked + DOMPurify sanitization before innerHTML | XSS from filing content or LLM output |
| Output | Tool-trace UI built with textContent only | XSS from tool names / parameters |
| Loop | Max 10 iterations per sub-agent; max 4k tokens/call | Runaway cost; infinite tool loops |
ANTHROPIC_API_KEY, optionally REDDIT_CLIENT_*, NEWSAPI_KEY). Never committed..env + .dev.vars are in .gitignore."An advisor has 60 seconds before a client call. The UI must earn trust fast."
"Each commit followed test → red → implement → green → commit. The spec is encoded in the tests."
| Layer | Files | Count | What it covers |
|---|---|---|---|
| Unit (tools) | tests/tools/*.test.ts ×7 | ~25 | Each external API integration mocked at fetch |
| Prompts | tests/prompts.test.ts | 14 | Sanity assertions that key behavior clauses survive edits |
| Agent loop | tests/agent/loop.test.ts | 2 | Generic runSubAgent with a mock Anthropic client |
| Sub-agents | tests/agent/{official,social}.test.ts | 4 | JSON parsing, graceful failure when output is non-JSON |
| Coordinator | tests/agent/coordinator.test.ts | 2 | Parallel sub-agent dispatch + event nesting |
| API | tests/api/chat.test.ts | 3 | SSE stream shape; 400 on malformed input |
| Golden behavior | tests/golden.test.ts | 10 | End-to-end behavior contract (no advice, citations, scope, divergence handling, anomaly handling, graceful degradation) |
| Contract | tests/contract/contract.test.ts | 5 | API↔UI event registry sync; SSE wire format |
It catches drift in both directions:
[DONE] sentinel and silently ignoring the done event.)Final smoke driven via Playwright MCP against the deployed URL with ticker F (Ford). Verified: tables rendered, no JSON leakage, 3 [UNVERIFIED] tags including a flagged pump-promotion bot, explicit divergence section.
"What happens when an upstream is down? Define it explicitly."
| System | Auth | Used for | Failure mode |
|---|---|---|---|
SEC EDGAR data.sec.gov | none (User-Agent required) | Filings list, 10-K sections, 8-K items | Returns sec_unavailable → official report carries a warning, briefing continues |
Yahoo Finance query1/v8/chart | none (undocumented) | Live quote, 30-day volume baseline | Returns quote_unavailable → snapshot table replaced by "Live quote unavailable" |
| Reddit OAuth | client-credentials | r/wallstreetbets, r/stocks, r/investing (whitelisted) | Missing keys → missing_credentials warning; rate-limited → reddit_unavailable |
Stocktwits /streams/symbol | none | Aggregate Bullish/Bearish counts | stocktwits_unavailable → row shows "unavailable" |
NewsAPI /v2/everything | API key | Recent headlines (14-day window) | Missing key → missing_credentials; over-quota → news_unavailable |
| Anthropic Messages API | API key | All three agents | Network error → SSE emits {type:"error"} event; UI surfaces it |
{ ok: false, error, reason? } on failure.warnings: string[]; coordinator surfaces them."Where does the time actually go, and what's the headroom?"
| Phase | Time | Notes |
|---|---|---|
| Cold start (Workers V8 isolate) | < 100 ms | Edge isolates; no container spin-up |
| Coordinator turn 1 (LLM) | ~3 s | Decides to call both macro-tools in parallel |
| Official sub-agent (full loop) | ~40–60 s | 5–6 internal iterations; tool calls dominate |
| Social sub-agent (full loop) | ~15–25 s | 4 tools in parallel within one assistant turn |
| Coordinator turn 2 (synthesis) | ~10–20 s | Generates markdown brief from two Reports |
| End-to-end | ~60–90 s | Sub-agents run sequentially in v1; could be in parallel via Promise.all |
tool_use blocks in one assistant turn (e.g., "fetch quote + list filings + parse 10-K simultaneously"). The runner dispatches them sequentially but the LLM doesn't have to wait between thoughts.research_official + research_social as a tool_use pair. The current macro-tool dispatcher runs them sequentially; the v1.1 optimization is to Promise.all them, cutting ~25 s."Investment advice triggers SEC / FINRA regulation. Stay on the right side."
(10-K Item 1A, FY24, filed 2024-11-01). Asserted by golden test #1.[UNVERIFIED]. Live-verified on the Ford demo where 3 social claims were so tagged, including one auto-promotion bot.Every tool call emits a structured SSE event with: tool name, input, latency, ok/error status, and result summary. The same stream that drives the UI can be teed to a log sink for retention. Audit is a free byproduct of inspectability.
"Why these models, why this topology, how do you know it's stable?"
From Anthropic's "Building effective agents" taxonomy. Coordinator dispatches; workers execute; each worker has a focused tool surface. We chose this over:
| Model | Considered for | Why not chosen |
|---|---|---|
| Haiku 4.5 | Sub-agents (cost) | Misreads financial document language under tool-chain pressure; risk too high for a wealth-advisory product. |
| Sonnet 4.6 ✓ | All three agents | Production-grade reasoning, ~3× faster and ~5× cheaper than Opus. |
| Opus 4.7 | — | Overkill — the bottleneck is tool latency, not reasoning depth. |
"Where does data come from, what shape is it, and who can touch it?"
| Regime | Sources | Provenance | UI treatment |
|---|---|---|---|
| Official | SEC EDGAR, Yahoo Finance | Filed, signed, audit-required | Cited inline; treated as fact |
| Social | Reddit, Stocktwits, NewsAPI, volume stats | Anonymous, may include bots / pumps | Always tagged unverified; in its own section with a disclaimer |
Every tool returns a discriminated union: T | { ok: false; error: string; reason?: string }. Callers must branch on the discriminator. This makes "I forgot to handle the failure" a TypeScript error at compile time.
OfficialReport = {
findings: { claim: string; citation: string }[]
quote: Quote | null
warnings: string[]
}
SocialReport = {
sentiment: { reddit_breakdown, stocktwits, news_tone }
themes: { topic, supporting_posts[] }[]
claimed_facts: { claim, source_url, tag: "[UNVERIFIED]" }[]
anomalies: { kind, evidence }[]
warnings: string[]
}
None. Server is stateless per request. Browser holds the conversation array. There is no PII storage problem because there is no storage. Production hooks: Cloudflare KV for 15-min cache on Reddit/Stocktwits/News (cost + rate-limit relief).
"Every tool call leaves a trace. The same stream that renders the UI is the audit log."
The agent loop emits an AgentEvent for every meaningful action:
type AgentEvent =
| { type: "token"; text: string }
| { type: "tool_call"; name; input; iteration; ts; parent? }
| { type: "tool_result"; name; ok; latency_ms; result_summary; ts; parent? }
| { type: "sub_agent_start"; agent; ts }
| { type: "sub_agent_end"; agent; report_summary; ts }
| { type: "done" }
| { type: "error"; reason }
latency_ms; trivially aggregated into p50/p95 per tool.tool_result with ok: false + result_summary.error; count by error code per upstream.wrangler pages deployment list for deploy history.console.log in Workers ships to Workers Logs (1 hr retention free tier; can tee to Datadog/Logflare via Logpush)."The LLM call is the cost. Everything else is rounding."
| Component | Tokens (in / out) | Est. cost / brief |
|---|---|---|
| Official sub-agent (5–6 iterations) | ~25k in / ~3k out | ~$0.12 |
| Social sub-agent (1–2 iterations) | ~8k in / ~2k out | ~$0.05 |
| Coordinator (2 iterations) | ~10k in / ~1.5k out | ~$0.05 |
| Total | ~$0.20–$0.30 per ticker |
| Service | Plan | Cost |
|---|---|---|
| Cloudflare Pages + Functions | Free tier | $0 (well under 100k requests/day) |
| SEC EDGAR | Free | $0 |
| Yahoo Finance | Undocumented (rate-limited) | $0 |
| Stocktwits public API | Free | $0 |
| Reddit OAuth | Free (rate-limited) | $0 |
| NewsAPI | Free tier (100 req/day) | $0 — caps at 100 briefs/day for the social section |