Why this option ships first and what it achieves.
Option 2 implements the VC Deep Research Agent using LangGraph's fixed addEdge() branches. All 8 specialist agents are wired at graph-build time. Optional agents always execute — they return status: "no_data" early when public data is unavailable rather than being skipped.
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.
Seven layers from raw input to streamed report.
| 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 first-wave agents run in parallel via fixed addEdge() branches |
| 4 | Barrier Node | Synchronisation point — waits for all 7 first-wave agents before Claims runs |
| 5 | Claims Agent | Cross-source conflict detection using 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 progressively via SSE |
// graph.ts — the ~20 lines that differ from Option 1
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");
addConditionalEdges() and the Send API. No agent files change.
Append-only TypeScript interfaces that flow through the entire graph.
interface ResearchState {
// ── Input ──────────────────────────────────────────────────────────────
runId: string;
fundThesis: FundThesis; // from Stage 01 thesis capture
rawInput: RawInput; // uploaded file, company name, or 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
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;
}
// Every agent returns this — 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>>;
Four input types normalise to the same CompanyProfile + ClaimedMetric[] before agents run.
| 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 returns no_data |
| Company URL | Fetch homepage + /about + /team via Tavily → Claude extracts | Partial — from marketing copy |
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
| 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. No credits consumed. |
| Low extraction confidence (<3 high-confidence fields) | Confidence capped at 0.5 across all dimensions; scorecard shows "Limited source material" banner |
Eight specialist agents, their tool access, and tier costs.
All tools are LangGraph ToolNodes wrapping typed async functions. Every tool uses fetchWithRetry — exponential backoff, 3 attempts, max 30s. Tools never throw; errors return empty DataPoint[].
T0 Free T1 Metered T2 Subscription
ResearchState after Barrier node.claimedMetrics[]. Detects:
no_data if insufficient sources. Early-stage market TAM is highly speculative — result is clearly labelled.
no_data is the expected result at MVP.
How Claims gets complete state from all 7 first-wave agents.
The Claims agent must receive complete outputs from all 7 first-wave agents before it can detect cross-source conflicts and implausible absences. LangGraph does not natively support "wait for N concurrent nodes" — this is resolved with a synchronisation checkpoint node.
// 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).
Four report zones populated progressively as agents complete.
| 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 |
| 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; Scorecard confidence updates |
conflict_found | { conflict: Conflict } | Conflict Panel card added; conflict count increments |
report_ready | { thesisFitScore, totalSources, durationMs } | Scorecard finalised; Sources Footer renders |
Zone 1 renders within approximately 5 seconds of run start. The VC sees live progress rather than a waiting spinner.
Six Azure services and how they connect.
| 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 |
ResearchStateHybridReport generated → persisted to Cosmos DB → HTML served from Static Web AppsEvery failure mode and what the VC sees.
| 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 — no data | Returns status: "no_data", skipped in conflict analysis |
"Insufficient public data at this stage." Absence explicitly acknowledged. |
| External API rate-limited | Exponential backoff, 3 attempts, max 30s. If all fail → tool unavailable | "[Source] — unavailable during this run." Section still renders from remaining tools. |
| Orchestrator / extraction fails | Run aborts. Service Bus DLQ. Alert fired. runId marked failed. |
"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. |
| 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 |
HybridReport generation. Failed runs and runs where fewer than 3 required agents complete charge zero credits.
Three sequential plans covering the full build. Each is independently testable.
All types, 12 tool wrappers, 8 agents, orchestrator, barrier, reducer, SSE server. Zero Azure dependencies — runs entirely locally. All agents tested with real API calls before Plan B begins.
Wire Blob Storage (file upload), Document Intelligence (text extraction), Cosmos DB (run persistence), Service Bus (queue + worker). Core graph and agents are unchanged.
Single public/report.html — no framework, no build step. Reads ?runId from URL, opens EventSource, updates 4 DOM zones progressively as SSE events arrive.
Resolved questions and the path to Option 1.
| # | Question | Status / Resolution |
|---|---|---|
| 1 | Barrier in LangGraph: How is "wait for N nodes" implemented? | Confirmed: LangGraph Annotated reducer holds the barrier node until all incoming edges write. Barrier is a no-op node — no custom wait logic. |
| 2 | SimilarWeb at MVP: T2 subscription ($1,500/yr minimum) — defer? | Defer. Digital agent falls back to Wayback Machine + WHOIS. Documented in §5. |
| 3 | LinkedIn scraping: No public API, actively blocks. | Confirmed limitation at MVP. Founders agent works with Tavily-surfaced prose. Founder history depth is limited — acknowledged in report. |
| 4 | LangSmith vs Azure Monitor: LangSmith is SaaS, not Azure-native. | Confirm team access. If unavailable, Azure Application Insights is the fallback tracing layer. |
| 5 | Thesis fit scoring without Stage 01: No fund thesis saved yet. | Default to a generic early-stage VC rubric until Stage 01 (Thesis Capture) integration is complete. |
When all 8 agents are validated on real deal data, migrate to Option 1 (dynamic Send API) by replacing graph wiring only. No agent files change.
graph.ts + ~10 lines in orchestrator.tsaddEdge() block in graph.ts — no other files need revertingSee option1.html for the complete Option 1 specification and migration checklist.