# VC Deep Research Agent — Technical Design: Option 2
## Fixed Parallel Branches (Ship First)

**Stack:** TypeScript 5 · LangGraph · Azure  
**Status:** Selected for initial implementation  
**Author:** Mohamed El-Deeb (m.eldeeb.92@gmail.com)  
**Date:** May 2026  
**Source spec:** `Foundation/technical_draft/source/design-spec.md`

---

## 1. Overview

Option 2 implements the VC Deep Research Agent using LangGraph's fixed `addEdge()` branches. This is the architecture to build and ship first. All 8 specialist agents are wired at graph-build time; optional agents run on every invocation and return `status: "no_data"` early when public data is unavailable.

The pipeline accepts a pitch deck (PDF/PPT), company name, or URL and produces a structured hybrid report for VC analysts in under 20 minutes. It maps to **Stages 02–04** of the One Agentic six-stage interaction loop: Deal Intake → Enrichment + Thesis Evaluation → Conflict Surfacing.

**Why Option 2 first:**
- Graph topology is fully visible at definition time — easier to reason about, debug, and review
- No LangGraph `Send` API required — works with any LangGraph version
- Optional agents run but return `no_data` cleanly — graceful degradation with no special wiring
- Agent code is identical in both options — migration to Option 1 is ~20 lines of graph wiring only
- Lower complexity for initial build-and-validate cycle

---

## 2. System Architecture

### 2.1 Layer Overview

| Layer | Name | Responsibility |
|---|---|---|
| 1 | Input Ingestion | Accept file or name → Azure Blob → Document Intelligence → structured text |
| 2 | Orchestrator Node | Normalise to `CompanyProfile`, extract `ClaimedMetric[]`, dispatch all 8 agents |
| 3 | Specialist Agents | 7 agents run in parallel via fixed `addEdge()` branches |
| 4 | Barrier Node | Synchronisation point — waits for all 7 non-claims agents before Claims runs |
| 5 | Claims Agent | Cross-source conflict detection; requires complete state from all other agents |
| 6 | Reducer | Merge all `AgentResult` outputs, detect conflicts, score thesis fit |
| 7 | Hybrid Report | Render scorecard + conflict panel + 8 sections; stream via SSE |

### 2.2 Graph Topology

```
                      ┌─────────────────┐
                      │   Orchestrator   │
                      └────────┬────────┘
                               │  addEdge × 7
           ┌───────────────────┼─────────────────────────────┐
           │           │       │       │        │    │        │
     ┌─────▼──┐  ┌─────▼──┐  ┌▼────┐ ┌▼──────┐ ┌▼────┐ ┌▼──────┐  ┌▼────────┐
     │Company │  │Founders│  │Mkt. │ │Digital│ │News │ │Legal  │  │Financial│
     └─────┬──┘  └─────┬──┘  └┬────┘ └┬──────┘ └┬────┘ └┬──────┘  └┬────────┘
           │            │      │       │          │       │           │
           └────────────┴──────┴───────┴──────────┴───────┴───────────┘
                                        │
                               ┌────────▼────────┐
                               │   Barrier Node   │  ← await all 7
                               └────────┬────────┘
                                        │
                               ┌────────▼────────┐
                               │  Claims Agent    │
                               └────────┬────────┘
                                        │
                               ┌────────▼────────┐
                               │    Reducer       │
                               └────────┬────────┘
                                        │
                               ┌────────▼────────┐
                               │   Report / SSE   │
                               └─────────────────┘
```

### 2.3 Graph Wiring (~20 lines)

```typescript
// graph.ts
const firstWave: AgentName[] = [
  "company", "founders", "market", "digital", "news", "legal", "financial"
];

// Orchestrator fans out to all 7 non-claims agents simultaneously
firstWave.forEach(a => graph.addEdge("orchestrator", a));

// All 7 converge at barrier before Claims runs
firstWave.forEach(a => graph.addEdge(a, "barrier"));

// Sequential tail: Claims → Reducer → Report
graph.addEdge("barrier", "claims");
graph.addEdge("claims", "reducer");
graph.addEdge("reducer", "report");
```

**Migration note:** When migrating to Option 1, this entire block is replaced by ~5 lines using `addConditionalEdges()` and the `Send` API. No agent files change.

---

## 3. State Schema

### 3.1 ResearchState

```typescript
// types.ts
interface ResearchState {
  // ── Input ──────────────────────────────────────────────────────────────────
  runId:           string;
  fundThesis:      FundThesis;        // from Stage 01 thesis capture
  rawInput:        RawInput;          // uploaded file or company name/URL

  // ── Extracted by Orchestrator ─────────────────────────────────────────────
  companyProfile:  CompanyProfile;    // normalised entity
  claimedMetrics:  ClaimedMetric[];   // what the deck says
  enabledAgents:   AgentName[];       // all 8 in Option 2

  // ── Agent outputs (Annotated reducers — parallel-safe) ────────────────────
  companyFindings:   AgentResult;
  founderFindings:   AgentResult;
  marketFindings:    AgentResult | null;   // optional — returns no_data if insufficient
  claimsFindings:    AgentResult;
  digitalFindings:   AgentResult;
  newsFindings:      AgentResult;
  legalFindings:     AgentResult | null;   // optional
  financialFindings: AgentResult | null;   // optional

  // ── Reducer outputs ───────────────────────────────────────────────────────
  conflicts:       Conflict[];
  thesisFitScore:  number;            // 0–100
  report:          HybridReport | null;
}
```

### 3.2 Supporting Types

```typescript
// Every agent returns this shape — agents never throw
interface AgentResult {
  status:     "ok" | "partial" | "no_data";
  confidence: number;        // 0–1
  summary:    string;        // prose for the report section
  dataPoints: DataPoint[];
  sources:    Source[];
  flags:      Flag[];        // agent-local red flags
  durationMs: number;
}

// Conflict surfaced by Reducer
interface Conflict {
  type:           "implausible_absence" | "manufactured_signal" | "cross_source_conflict";
  severity:       "high" | "medium" | "low";
  description:    string;
  sourceA:        string;
  sourceB:        string;
  agentsInvolved: AgentName[];
}

// Normalised company entity
interface CompanyProfile {
  name:         string;
  domain?:      string;
  founderNames: string[];
  sector?:      string;
  stage?:       string;     // "pre-seed" | "seed" | "series-a"
  hq?:          string;
  linkedinUrl?: string;
  githubOrg?:   string;
}

// A claim extracted from the pitch deck
interface ClaimedMetric {
  field:          string;        // e.g. "ARR", "MAU"
  value:          string;        // e.g. "$2.4M"
  sourceSlide:    number | null;
  validated?:     boolean;       // filled by Claims agent
  contradiction?: string;
}

// All specialist agents conform to this signature
type SpecialistAgent = (state: ResearchState) => Promise<Partial<ResearchState>>;
```

### 3.3 State Flow

```
RawInput
  → +CompanyProfile +ClaimedMetrics     [Orchestrator — Layer 2]
  → +7×AgentResult (parallel)           [Specialist Agents — Layer 3]
  → [barrier]
  → +ClaimsFindings                     [Claims Agent — Layer 5]
  → +Conflicts +thesisFitScore          [Reducer — Layer 6]
  → +HybridReport                       [Report — Layer 7]
```

State is **append-only** — each stage adds fields, never overwrites prior data. Full state persisted to Cosmos DB under `runId` at each checkpoint. Complete audit trail for every run.

---

## 4. Input Parsing

### 4.1 Input Type Handling

| Input | Extraction Path | Claims Available? |
|---|---|---|
| Pitch deck PDF | Azure Blob → Document Intelligence (layout model) → Claude extracts profile + claims with slide provenance | Yes — `sourceSlide` populated |
| PPT / generic PDF | Azure Blob → Document Intelligence → Claude extracts with lower confidence | Yes — lower reliability |
| Company name | Entity resolution via GLEIF + Tavily; no document | No — `claimedMetrics: []`, Claims agent returns `no_data` |
| Company URL | Orchestrator fetches homepage + /about + /team via Tavily → Claude extracts | Partial — from marketing copy |

### 4.2 Extraction Prompt Contract

```typescript
const EXTRACTION_SYSTEM_PROMPT = `
You are extracting structured data from a startup document.
Return ONLY valid JSON matching the CompanyProfile + ClaimedMetric[] schema.
For each metric, record: field name, stated value, and slide/page number if visible.
If a field is not present in the document, omit it — do not infer or hallucinate.
Mark any metric you are uncertain about with "confidence": "low".
`;
// Enforced via Claude tool_use / structured output — no free-text response
```

### 4.3 Ambiguous Input Handling

| Condition | Behaviour |
|---|---|
| Name collision (two companies, same name) | Orchestrator prompts VC to confirm entity (by domain or country) before dispatching |
| Unreadable file (no text extracted) | Fall back to company-name-only mode if a name was provided; else return error |
| Low extraction confidence (< 3 high-confidence fields) | Confidence capped at 0.5 across all dimensions; scorecard shows "Limited source material" banner |

---

## 5. Agent Tool Registry

All tools are LangGraph `ToolNode`s wrapping typed async functions. Every tool uses `fetchWithRetry` (exponential backoff, 3 attempts, max 30s) and never throws — errors return an empty `DataPoint[]`.

**Tier key:** T0 = free · T1 = per-call metered · T2 = subscription

### 5.1 Required Agents

**Company Overview** (`company.ts`)  
GLEIF API (T0) · Companies House API (T0) · SEC EDGAR (T0) · Wayback Machine CDX (T0) · WHOIS (T0) · Tavily search (T1 — $0.008/search)

**Founders & Team** (`founders.ts`)  
Tavily search (T1) · GitHub API (T0) · Wayback Machine CDX (T0) · OFAC SDN (T0) · OpenSanctions (T1 — €0.10/call)

**Web & Digital Presence** (`digital.ts`)  
SimilarWeb API (T2 — $1,500/yr minimum) · Wayback Machine CDX (T0) · Tavily search (T1)  
_MVP fallback if no SimilarWeb subscription: Wayback + WHOIS only_

**News & Media** (`news.ts`)  
GDELT API (T0) · Google News RSS (T0) · Tavily search (T1)

**Claim Validation** (`claims.ts`) — runs after Barrier node, no external APIs  
Receives full `ResearchState` after all 7 first-wave agents have written. Uses Claude to reason across outputs and `claimedMetrics[]`.

Detects three conflict types:
- **Implausible Absence** — claimed metric with no corroborating data point found anywhere
- **Cross-Source Conflict** — two agents report incompatible facts about the same entity
- **Manufactured Signal** — positive signal appearing in only one source, unusually strong

### 5.2 Optional Agents (return `no_data` gracefully)

**Market Analysis** (`market.ts`)  
Tavily search (T1) · GDELT themes (T0)  
Returns best-effort prose summary; `no_data` if sources are insufficient. Early-stage market TAM is highly speculative — result is clearly labelled as such.

**Legal & Regulatory** (`legal.ts`)  
SEC EDGAR full-text (T0) · USPTO API (T0) · OFAC SDN (T0) · PACER (T1 — $0.10/page) · OpenSanctions (T1)

**Financial Signals** (`financial.ts`)  
SEC EDGAR filings (T0) · Tavily/Crunchbase web search (T1)  
Pre-seed companies rarely have public financial data — `no_data` is the expected result at MVP.

---

## 6. Barrier Node Pattern

The Claims agent must receive complete outputs from all 7 first-wave agents before it can detect cross-source conflicts. LangGraph does not natively support "wait for N concurrent nodes" — this is resolved with an accumulator checkpoint node.

```typescript
// barrier.ts
function barrierNode(state: ResearchState): Partial<ResearchState> {
  // LangGraph's Annotated reducer holds this node until all 7 incoming
  // edges have written to state. Timed-out agents fill their field with
  // a "partial" stub before the barrier receives control.
  return {}; // synchronisation point only — no state mutation
}
```

**Timeout handling:** Required agents that exceed 3 minutes return a `status: "partial"` stub result. The barrier never waits indefinitely — it proceeds once all branches have written (stub or real result).

---

## 7. Hybrid Report & SSE Streaming

### 7.1 Report Structure

| Zone | Content | Streams When |
|---|---|---|
| 1 — Scorecard | Thesis Fit Score (0–100), overall confidence, conflict count, source count, research time | `run_started` + progressive updates as agents complete |
| 2 — Conflict Panel | One card per conflict: type badge, severity, description, source A vs B, agents involved | Each `conflict_found` event from Reducer |
| 3 — 8 Expandable Sections | Confidence badge, prose summary, data points table, sources list per agent | Each `agent_done` event |
| 4 — Sources Footer | Deduplicated, sorted source list across all agents; unavailable sources noted | `report_ready` |

### 7.2 SSE Event Schema

| Event | Payload | Effect on UI |
|---|---|---|
| `run_started` | `{ runId, company, estimatedAgents }` | Progress bar appears, Scorecard skeleton renders |
| `agent_started` | `{ agent }` | Agent row shows spinner |
| `agent_done` | `{ agent, result: AgentResult }` | Section populates immediately; Scorecard updates confidence |
| `conflict_found` | `{ conflict: Conflict }` | Conflict Panel card added; Scorecard conflict count increments |
| `report_ready` | `{ thesisFitScore, totalSources, durationMs }` | Scorecard finalised; Sources Footer renders |

The VC sees live progress rather than a waiting spinner. Scorecard values update progressively as agents complete. Zone 1 renders within ~5s of run start.

---

## 8. Azure Deployment

| Service | Role | Notes |
|---|---|---|
| Container Apps | Runs LangGraph agent service | Scales to 0 between runs; SSE streaming endpoint |
| Blob Storage | Stores uploaded files | SAS URL passed to Document Intelligence; 30-day retention; AES-256 at rest |
| Document Intelligence | Extracts text + layout from PDFs/PPTs | Layout model preserves slide structure and page numbers |
| Cosmos DB (NoSQL) | Persists full `ResearchState` per `runId` | Report HTML cached after first render; TTL configurable per tier |
| Static Web Apps | Serves report HTML | Global CDN; auth via Static Web Apps auth |
| Service Bus | Queues run requests | Dead-letter queue fires alert + retry on failure |
| Application Insights | Observability | Run duration, agent latency, error rates per agent |
| LangSmith | Trace-level debugging | Per-run LangGraph trace; confirm team access or use App Insights as fallback |

### 8.1 Request Flow

1. VC uploads file or enters company name → Blob Storage + Service Bus enqueues run
2. Container Apps picks up message → Document Intelligence extracts text → Orchestrator initialises `ResearchState`
3. 7 first-wave agents run in parallel → SSE stream sends live progress events to browser
4. Barrier waits for all 7 → Claims agent runs → Reducer merges results + detects conflicts
5. `HybridReport` generated → persisted to Cosmos DB → HTML served from Static Web Apps
6. Full report available within target of **< 20 minutes** from upload

---

## 9. Error Handling & Graceful Degradation

### 9.1 Failure Hierarchy

| Failure | Behaviour | VC Sees |
|---|---|---|
| Required agent timeout (> 3 min) | Returns `status: "partial"`, data points marked low confidence, run continues | "Research timed out — partial data only." Confidence capped at 0.4. |
| Optional agent returns no data | Returns `status: "no_data"`, skipped in conflict analysis | "Insufficient public data at this stage." Absence acknowledged explicitly, no score shown. |
| External API rate-limited | Exponential backoff, 3 attempts, max 30s. If all fail → tool contribution unavailable | "[Source] — unavailable during this run" in Sources Footer. Section still renders from remaining tools. |
| Orchestrator / extraction fails | Run aborts. Service Bus DLQ. Alert fired. `runId` marked `failed` in Cosmos DB. | "Research could not start — [reason]. No credits consumed. Retry available." |
| < 3 required agents complete | Run aborts after Reducer detects insufficient data | Same as above — zero credits consumed. |

### 9.2 Confidence Score Rules

| Level | Range | Conditions |
|---|---|---|
| High | 0.7 – 1.0 | 2+ independent sources agree · No contradictions · All key fields populated |
| Medium | 0.4 – 0.69 | Single source · Or 2 sources with minor discrepancy · Some fields missing |
| Low | 0 – 0.39 | Single source with conflict · Agent timed out · Low extraction confidence |
| Not shown | — | Optional agent returned `no_data` — absence acknowledged, no score shown |

### 9.3 Credit Deduction Policy

Credits are deducted **only on successful `HybridReport` generation** (Reducer completes and report is persisted to Cosmos DB). Failed runs consume zero credits. Partial runs where at least 3 required agents completed successfully count as a full run.

---

## 10. Implementation Plans

Three sequential plans cover the full build. Each plan is independently testable without the next.

| Plan | Scope | Prerequisites |
|---|---|---|
| **Plan A — Core Pipeline** | All types, 8 agents, 12 tool wrappers, orchestrator, barrier, reducer, SSE server. Zero Azure dependencies — runs locally. | None |
| **Plan B — Azure Integration** | Blob upload, Document Intelligence extraction, Cosmos DB run persistence, Service Bus queue + worker process | Plan A complete and all agents passing tests |
| **Plan C — Report Frontend** | Vanilla HTML/CSS/JS report page. Connects to SSE stream. Progressive rendering of all 4 zones. | Plan A server running on port 3000 |

**Build order is strict:** validate Plan A before touching Plan B; connect Plan C only after Plan B's SSE endpoint is live.

### 10.1 Plan A: Core Pipeline Tech Stack

```
TypeScript 5
@langchain/langgraph
@langchain/anthropic
@langchain/core
Hono           — HTTP server + SSE
Zod            — runtime validation
Vitest         — test runner
```

**File structure:**
```
research-agent/src/
  types.ts                    ← All interfaces (ResearchState, AgentResult, Conflict, …)
  tools/
    http.ts                   ← fetchWithRetry (exponential backoff, never throws)
    gleif.ts · companies-house.ts · sec-edgar.ts · wayback.ts
    whois.ts · github.ts · ofac.ts · opensanctions.ts
    gdelt.ts · google-news.ts · tavily.ts
  agents/
    company.ts · founders.ts · digital.ts · news.ts
    claims.ts · market.ts · legal.ts · financial.ts
  orchestrator.ts             ← normalises RawInput → CompanyProfile + ClaimedMetric[]
  barrier.ts                  ← synchronisation checkpoint
  reducer.ts                  ← merges findings, detects conflicts, scores thesis fit
  graph.ts                    ← buildGraph() — wires all nodes
  sse.ts                      ← SseEmitter — typed SSE event dispatch
  server.ts                   ← Hono: POST /runs, GET /runs/:runId/events
```

### 10.2 Plan B: Azure Integration Tech Stack

```
@azure/storage-blob
@azure/ai-form-recognizer
@azure/cosmos
@azure/service-bus
```

**Additions to file structure:**
```
research-agent/src/azure/
  config.ts                   ← Zod-validated env config (throws on startup if missing)
  blob.ts                     ← uploadFile(), generateSasUrl()
  document-intel.ts           ← extractText() — Document Intelligence layout model
  cosmos.ts                   ← saveRunState(), getRunState(), markRunFailed()
  service-bus.ts              ← enqueueRun(), startConsumer()
research-agent/src/
  worker.ts                   ← Service Bus consumer → graph → Cosmos
```

### 10.3 Plan C: Report Frontend

Single `public/report.html` — no framework, no build step:
- Reads `?runId` from URL
- Opens `EventSource` to `GET /runs/:runId/events`
- Updates 4 DOM zones incrementally as SSE events arrive
- Zone 1 (scorecard) renders within ~5s; Zones 2–4 fill progressively

---

## 11. Open Questions

| # | Question | Status |
|---|---|---|
| 1 | **Claims barrier in LangGraph:** How is "wait for N nodes" implemented? | Confirmed: LangGraph Annotated reducer holds the barrier node until all incoming edges write. No custom wait logic — barrier is a no-op node. |
| 2 | **SimilarWeb at MVP:** T2 subscription ($1,500/yr minimum) — in scope at launch? | Recommendation: defer. Digital agent falls back to Wayback Machine + WHOIS only until subscription is active. Fallback documented in §5.1. |
| 3 | **LinkedIn scraping:** No public API; active blocking. | Confirmed limitation: Tavily surfaces public LinkedIn URLs but not structured data. Founders agent works with prose summaries. Founder history depth is limited at MVP. |
| 4 | **LangSmith vs Azure Monitor:** LangSmith is SaaS (not Azure-native). | Confirm team has LangSmith access. If not, Azure Application Insights is the fallback tracing layer. |
| 5 | **Thesis fit scoring without Stage 01:** `thesisFitScore` requires a fund thesis. | Default to a generic early-stage VC rubric until Stage 01 (Thesis Capture) integration is complete. |

---

## 12. Migration to Option 1

When all 8 agents are validated on real deal data, migrate to Option 1 (dynamic `Send` API) by replacing graph wiring only. Agent files are unchanged.

**What changes (~20 lines in `graph.ts`):**
- `addEdge()` block → `addConditionalEdges()` with `orchestrate()` returning `Send[]`
- `barrierNode` → `checkpointNode` (re-dispatches Claims after first wave completes)
- Orchestrator logic: `enabledAgents` becomes a dynamic subset, not always all 8

**What does NOT change:**
- All 8 agent files
- All 12 tool wrappers
- State schema
- Reducer
- SSE server
- Azure deployment
- Report frontend

See `option1.md` for the complete Option 1 specification.
