{"slug": "how-to-build-a-pitch-deck-triage-agent-with-langgraph-and-nango", "title": "How to build a pitch deck triage agent with LangGraph and Nango", "summary": "A developer created an AI agent that triages pitch-deck emails using LangGraph and Nango. The agent reads Gmail attachments, evaluates them against an investment thesis with an LLM, and posts matches to Slack. Nango handles OAuth and exposes Gmail and Slack actions as MCP tools for the graph.", "body_md": "In this guide you will build an AI agent that reads pitch-deck emails from Gmail, judges each deck against a fixed investment thesis with an LLM, and posts a Slack message when a deck is a fit. LangGraph orchestrates the steps; [Nango](https://nango.dev) handles the Gmail and Slack connections and exposes them to the graph over MCP.\n\nBy the end you will have:\n\n`{ fit, reasoning, evidenceQuote }` verdict grounded in a real quote from the deck.\nYou need two separate OAuth integrations - Gmail and Slack - each with its own token lifecycle, scopes, and refresh flow. Get either wrong and the pipeline fails days later when a token expires, not on your first test.\n\nGmail's API does not hand you a pitch deck in one call. Searching an inbox returns message metadata; getting an attachment's bytes is a second request keyed off an `attachmentId` from the first. And Gmail returns those bytes base64url-encoded, not standard base64, so a naive decode produces a broken PDF.\n\nThen there's the LLM. It's easy to get a model to say \"yes, this fits\". It's harder to make it say *why*, and prove the why by quoting the actual document rather than paraphrasing something half-remembered from the prompt.\n\nNango gives you the OAuth flow, token storage, and refresh logic for Gmail and Slack out of the box. You connect an account once in a hosted popup; every call after that carries a valid token without your code touching it.\n\nYou write the provider logic as small server-side functions called **actions** - input schema, output schema, and an `exec` body. Deploy one and it's a versioned endpoint, and Nango automatically exposes it as a tool on its hosted MCP server at `https://api.nango.dev/mcp`. Your graph calls those tools by name; no model ever picks them.\n\n`list-pitch-emails` action`fetch-attachment` action`send-slack-message` action\nGo to [app.nango.dev/signup](https://app.nango.dev/signup) and create a free account.\n\nOnce you're in, open **Environment settings** in the left sidebar, select the **API Keys** tab, and copy the key for the **dev** environment. You'll use this same value in two places later: `NANGO_SECRET_KEY_DEV` for the CLI, and `NANGO_SECRET_KEY` for the graph.\n\nAn integration is a provider (Gmail, Slack) plus its OAuth app. Nango ships a shared dev OAuth app for each, so you don't have to register your own.\n\nRepeat for **Slack**. When both exist, your Integrations list looks like this - note the IDs `google-mail` and `slack`, which you'll pass to the graph later:\n\nEach integration now needs one authorized connection.\n\nDo the same on the **Slack** integration. When you authorize Slack, pick (or create) the channel you want notifications in - for example `#pitch-triage` - and make sure the Nango app is a member of it.\n\nCopy both **connection IDs** from the **Connections** tab. You now have: a Nango secret key, a Gmail connection ID, and a Slack connection ID.\n\nCreate the project and an `integration` folder for the Nango actions:\n\n```\nmkdir -p pitch-deck-triage-agent/integration/.nango\ncd pitch-deck-triage-agent/integration\n```\n\nCreate `package.json`:\n\n```\n{\n  \"name\": \"nango-integrations\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"engines\": { \"node\": \">=22.22.2\" },\n  \"scripts\": { \"compile\": \"nango compile\", \"dev\": \"nango dev\" },\n  \"devDependencies\": { \"nango\": \"0.71.6\", \"zod\": \"4.3.6\" }\n}\n```\n\nInstall:\n\n```\nnpm install\n```\n\nCreate `tsconfig.json` (Nango uses this to type-check your actions):\n\n```\n{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"include\": [\"index.ts\", \"**/*.ts\"],\n  \"exclude\": [\"node_modules\", \"dist\", \"build\", \".nango\"],\n  \"compilerOptions\": {\n    \"module\": \"node16\",\n    \"target\": \"esnext\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"node16\",\n    \"exactOptionalPropertyTypes\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noEmit\": true\n  }\n}\n```\n\nCreate `index.ts` - it just imports every action so Nango picks them up (note the `.js` extension even though the files are `.ts`; that's the Node 16 module convention Nango uses):\n\n```\nimport './google-mail/actions/list-pitch-emails.js';\nimport './google-mail/actions/fetch-attachment.js';\nimport './slack/actions/send-slack-message.js';\n```\n\nCreate `.env` with the dev secret key you copied earlier:\n\n```\nNANGO_SECRET_KEY_DEV=your-nango-dev-secret-key\n```\n\nThis action searches Gmail for recent messages with a PDF attachment and returns one candidate per message - metadata plus an `attachmentId`, **not** the file itself (Gmail won't give you both in one call).\n\nCreate `google-mail/actions/list-pitch-emails.ts`:\n\n``` js\nimport { createAction } from 'nango';\nimport * as z from 'zod';\n\n// Input is z.object({}).strict(), not z.void(): Nango compiles `void` to a\n// {\"type\":\"null\"} schema, but an MCP tools/call sends `arguments` as an\n// object ({} for a no-input tool), which fails that schema. See Common issues.\n\nconst QUERY = 'has:attachment filename:pdf newer_than:30d';\nconst MAX_RESULTS = 5;\n\nconst outputSchema = z.object({\n    candidates: z.array(\n        z.object({\n            messageId: z.string(),\n            threadId: z.string(),\n            from: z.string(),\n            subject: z.string(),\n            attachmentId: z.string(),\n            filename: z.string()\n        })\n    )\n});\n\ninterface GmailHeader {\n    name: string;\n    value: string;\n}\n\ninterface GmailPart {\n    mimeType?: string;\n    filename?: string;\n    body?: { attachmentId?: string; size?: number };\n    parts?: GmailPart[];\n}\n\ninterface GmailMessage {\n    id: string;\n    threadId: string;\n    payload?: GmailPart & { headers?: GmailHeader[] };\n}\n\ninterface GmailListResponse {\n    messages?: { id: string; threadId: string }[];\n}\n\nfunction findPdfAttachment(part: GmailPart | undefined): { attachmentId: string; filename: string } | undefined {\n    if (!part) {\n        return undefined;\n    }\n    if (part.mimeType === 'application/pdf' && part.body?.attachmentId) {\n        return { attachmentId: part.body.attachmentId, filename: part.filename || 'deck.pdf' };\n    }\n    for (const child of part.parts ?? []) {\n        const found = findPdfAttachment(child);\n        if (found) {\n            return found;\n        }\n    }\n    return undefined;\n}\n\nfunction header(headers: GmailHeader[] | undefined, name: string): string {\n    return headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? '';\n}\n\nconst action = createAction({\n    description: 'List recent Gmail messages that have a PDF attachment, one candidate Pitch Deck per message.',\n    version: '1.0.0',\n    endpoint: { method: 'GET', path: '/gmail/pitch-emails', group: 'Triage' },\n    input: z.object({}).strict(),\n    output: outputSchema,\n\n    exec: async (nango): Promise<z.infer<typeof outputSchema>> => {\n        const listRes = await nango.get<GmailListResponse>({\n            endpoint: '/gmail/v1/users/me/messages',\n            params: { q: QUERY, maxResults: String(MAX_RESULTS) }\n        });\n\n        const candidates: z.infer<typeof outputSchema>['candidates'] = [];\n        for (const { id } of listRes.data.messages ?? []) {\n            const msgRes = await nango.get<GmailMessage>({\n                endpoint: `/gmail/v1/users/me/messages/${id}`,\n                params: { format: 'full' }\n            });\n\n            const attachment = findPdfAttachment(msgRes.data.payload);\n            if (!attachment) {\n                continue;\n            }\n\n            candidates.push({\n                messageId: msgRes.data.id,\n                threadId: msgRes.data.threadId,\n                from: header(msgRes.data.payload?.headers, 'From'),\n                subject: header(msgRes.data.payload?.headers, 'Subject'),\n                attachmentId: attachment.attachmentId,\n                filename: attachment.filename\n            });\n        }\n\n        return { candidates };\n    }\n});\n\nexport type NangoActionLocal = Parameters<(typeof action)['exec']>[0];\nexport default action;\n```\n\nInside `exec`, `nango.get` calls the real Gmail API with the connection's token attached for you. You never see a token.\n\nGiven the `messageId` and `attachmentId` from the first action, this one downloads the attachment's bytes.\n\nCreate `google-mail/actions/fetch-attachment.ts`:\n\n``` js\nimport { createAction } from 'nango';\nimport * as z from 'zod';\n\nconst inputSchema = z.object({\n    messageId: z.string().min(1),\n    attachmentId: z.string().min(1)\n});\n\nconst outputSchema = z.object({\n    // Gmail returns this base64url-encoded (- and _ instead of + and /),\n    // not standard base64. The caller converts before decoding.\n    data: z.string(),\n    size: z.number()\n});\n\ninterface GmailAttachmentResponse {\n    data: string;\n    size: number;\n}\n\nconst action = createAction({\n    description: \"Fetch the raw content of one Gmail attachment (base64url-encoded, as Gmail's API returns it).\",\n    version: '1.0.0',\n    endpoint: { method: 'GET', path: '/gmail/attachment', group: 'Triage' },\n    input: inputSchema,\n    output: outputSchema,\n\n    exec: async (nango, input): Promise<z.infer<typeof outputSchema>> => {\n        const res = await nango.get<GmailAttachmentResponse>({\n            endpoint: `/gmail/v1/users/me/messages/${input.messageId}/attachments/${input.attachmentId}`\n        });\n\n        return { data: res.data.data, size: res.data.size };\n    }\n});\n\nexport type NangoActionLocal = Parameters<(typeof action)['exec']>[0];\nexport default action;\n```\n\nThis one posts to Slack's `chat.postMessage`. Slack answers `HTTP 200` even when a post fails, with the real error in `{ ok: false, error }` - Nango's retry logic keys off status codes and never sees that, so the action checks `ok` itself.\n\nCreate `slack/actions/send-slack-message.ts`:\n\n``` js\nimport { createAction } from 'nango';\nimport * as z from 'zod';\n\nconst inputSchema = z.object({\n    channel: z.string().min(1).describe('Slack channel ID or name (e.g. \"#pitch-triage\")'),\n    text: z.string().min(1)\n});\n\nconst outputSchema = z.object({\n    channel: z.string(),\n    ts: z.string()\n});\n\ninterface SlackPostMessageResponse {\n    ok: boolean;\n    error?: string;\n    channel?: string;\n    ts?: string;\n}\n\nconst action = createAction({\n    description: 'Post a message to a Slack channel.',\n    version: '1.0.0',\n    endpoint: { method: 'POST', path: '/slack/messages', group: 'Triage' },\n    input: inputSchema,\n    output: outputSchema,\n\n    exec: async (nango, input): Promise<z.infer<typeof outputSchema>> => {\n        const res = await nango.post<SlackPostMessageResponse>({\n            endpoint: '/chat.postMessage',\n            retries: 0,\n            data: { channel: input.channel, text: input.text }\n        });\n\n        if (!res.data.ok || !res.data.ts || !res.data.channel) {\n            throw new nango.ActionError({ message: `Slack chat.postMessage failed: ${res.data.error ?? 'unknown error'}` });\n        }\n\n        return { channel: res.data.channel, ts: res.data.ts };\n    }\n});\n\nexport type NangoActionLocal = Parameters<(typeof action)['exec']>[0];\nexport default action;\n```\n\nFrom the `integration` folder:\n\n```\nnpx nango deploy dev\n```\n\nNango type-checks and uploads all three actions in one go:\n\nCheck the **Functions** tab on each integration in the dashboard. Gmail shows `list-pitch-emails` and `fetch-attachment`:\n\nSlack shows `send-slack-message`:\n\nEach deployed action is now also a tool on Nango's MCP server. That's what the graph calls next.\n\nBack at the project root, create a `graph` folder for the pipeline:\n\n```\ncd ..\nmkdir -p graph/src\ncd graph\n{\n  \"name\": \"pitch-deck-triage-graph\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"engines\": { \"node\": \">=22\" },\n  \"scripts\": {\n    \"triage\": \"tsx src/run.ts\",\n    \"generate-decks\": \"tsx src/generate-decks.ts\"\n  },\n  \"dependencies\": {\n    \"@langchain/langgraph\": \"^0.2.57\",\n    \"dotenv\": \"^16.4.5\",\n    \"openai\": \"^4.104.0\",\n    \"pdfjs-dist\": \"^6.3.289\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^22.9.0\",\n    \"@types/pdfkit\": \"^0.13.4\",\n    \"pdfkit\": \"^0.15.1\",\n    \"tsx\": \"^4.19.2\",\n    \"typescript\": \"^5.6.3\"\n  }\n}\nnpm install\n```\n\nCreate `tsconfig.json`:\n\n```\n{\n  \"compilerOptions\": {\n    \"target\": \"es2022\",\n    \"module\": \"node16\",\n    \"moduleResolution\": \"node16\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"noEmit\": true,\n    \"allowImportingTsExtensions\": true,\n    \"types\": [\"node\"]\n  },\n  \"include\": [\"src/**/*.ts\"]\n}\n```\n\nCreate `.env` with the values you gathered earlier (`NANGO_SECRET_KEY` is the same dev key as `NANGO_SECRET_KEY_DEV`):\n\n```\nOPENAI_API_KEY=your-openai-key\nNANGO_SECRET_KEY=your-nango-dev-secret-key\nNANGO_GMAIL_CONNECTION_ID=your-gmail-connection-id\nNANGO_SLACK_CONNECTION_ID=your-slack-connection-id\nSLACK_CHANNEL=\"#pitch-triage\"\n```\n\nQuote `SLACK_CHANNEL` if it starts with `#` - `dotenv` treats an unquoted `#` as a comment and drops the rest of the line.\n\nFour small `src/` files the pipeline depends on.\n\n**`src/thesis.ts`** - the fixed rule every deck is judged against. Edit it to match a real thesis if you like; this one is fabricated.\n\n``` js\nexport const THESIS = `Acme Ventures invests in seed-stage, developer-focused B2B software companies.\n\nWe look for:\n- US or EU-incorporated companies\n- A product built for a technical buyer (developers, DevOps, IT, or security teams)\n- $0-$2M in current ARR\n- At least one technical co-founder who still writes code\n- A believable path to $1M ARR within 12 months of this round\n\nWe do not invest in consumer apps, hardware, life sciences, gaming, or crypto/token-based businesses, regardless of traction.`;\n```\n\n**`src/mcp-client.ts`** - a ~100-line client (no SDK) that calls one named tool on Nango's MCP server. It does the MCP handshake (`initialize`, then `notifications/initialized`, then `tools/call`) and unwraps the result. Each call carries three headers so Nango knows which account to use: your secret key, the `connection-id`, and the `provider-config-key` (`google-mail` or `slack`).\n\n``` js\nconst NANGO_MCP_URL = 'https://api.nango.dev/mcp';\n\nexport interface McpScope {\n    connectionId: string;\n    providerConfigKey: string;\n}\n\ninterface JsonRpcResponse {\n    jsonrpc: '2.0';\n    id?: number;\n    result?: unknown;\n    error?: { code: number; message: string; data?: unknown };\n}\n\ninterface McpToolResult {\n    isError?: boolean;\n    content?: { type: string; text?: string }[];\n}\n\nfunction headersFor(secretKey: string, scope: McpScope): Record<string, string> {\n    return {\n        'Content-Type': 'application/json',\n        Accept: 'application/json, text/event-stream',\n        Authorization: `Bearer ${secretKey}`,\n        'connection-id': scope.connectionId,\n        'provider-config-key': scope.providerConfigKey\n    };\n}\n\nasync function parseBody(res: Response): Promise<JsonRpcResponse | undefined> {\n    const contentType = res.headers.get('content-type') ?? '';\n    const body = await res.text();\n    if (!body) {\n        return undefined;\n    }\n\n    if (contentType.includes('text/event-stream')) {\n        // SSE framing: one or more \"data: <json>\" lines; take the last.\n        const dataLines = body\n            .split('\\n')\n            .filter((line) => line.startsWith('data:'))\n            .map((line) => line.slice('data:'.length).trim());\n        const last = dataLines.at(-1);\n        return last ? (JSON.parse(last) as JsonRpcResponse) : undefined;\n    }\n\n    return JSON.parse(body) as JsonRpcResponse;\n}\n\nlet nextId = 1;\n\nasync function rpc(headers: Record<string, string>, method: string, params?: unknown): Promise<unknown> {\n    const res = await fetch(NANGO_MCP_URL, {\n        method: 'POST',\n        headers,\n        body: JSON.stringify({ jsonrpc: '2.0', id: nextId++, method, params })\n    });\n    if (!res.ok) {\n        throw new Error(`MCP ${method} failed: HTTP ${res.status} ${await res.text()}`);\n    }\n    const parsed = await parseBody(res);\n    if (parsed?.error) {\n        throw new Error(`MCP ${method} error: ${JSON.stringify(parsed.error)}`);\n    }\n    return parsed?.result;\n}\n\nexport async function callNangoTool<T = unknown>(secretKey: string, scope: McpScope, toolName: string, args: Record<string, unknown>): Promise<T> {\n    const headers = headersFor(secretKey, scope);\n\n    await rpc(headers, 'initialize', {\n        protocolVersion: '2025-03-26',\n        capabilities: {},\n        clientInfo: { name: 'pitch-deck-triage-graph', version: '1.0.0' }\n    });\n\n    // Required notification - no response expected, fire and ignore.\n    await fetch(NANGO_MCP_URL, {\n        method: 'POST',\n        headers,\n        body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })\n    }).catch(() => undefined);\n\n    const result = (await rpc(headers, 'tools/call', { name: toolName, arguments: args })) as McpToolResult | undefined;\n\n    if (result?.isError) {\n        throw new Error(`Nango tool \"${toolName}\" failed: ${JSON.stringify(result.content)}`);\n    }\n\n    const textBlock = result?.content?.find((c) => c.type === 'text' && typeof c.text === 'string');\n    if (!textBlock?.text) {\n        throw new Error(`Nango tool \"${toolName}\" returned no text content: ${JSON.stringify(result)}`);\n    }\n    return JSON.parse(textBlock.text) as T;\n}\n```\n\n**`src/pdf.ts`** - turns the base64url bytes from `fetch-attachment` into plain text. It uses `pdfjs-dist` directly (the older `pdf-parse` package chokes on PDFs from a current `pdfkit`). Text only, no OCR - an image-only deck comes back empty.\n\n``` js\nimport { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';\n\ninterface TextItem {\n    str?: string;\n}\n\ntype GetDocumentParams = Parameters<typeof getDocument>[0];\n\nexport async function extractPdfText(base64UrlData: string): Promise<string> {\n    const base64 = base64UrlData.replace(/-/g, '+').replace(/_/g, '/');\n    const buffer = Buffer.from(base64, 'base64');\n\n    // disableWorker isn't in this version's types but works at runtime.\n    const params = {\n        data: new Uint8Array(buffer),\n        disableWorker: true\n    } as unknown as GetDocumentParams;\n    const doc = await getDocument(params).promise;\n    const pages: string[] = [];\n    for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {\n        const page = await doc.getPage(pageNum);\n        const content = await page.getTextContent();\n        const text = content.items.map((item) => (item as TextItem).str ?? '').join(' ');\n        pages.push(text);\n    }\n\n    return pages.join('\\n\\n').trim();\n}\n```\n\n**`src/assess.ts`** - the only LLM call in the whole project. One OpenAI Structured Outputs call returns `{ fit, reasoning, evidenceQuote }`. The schema forces `evidenceQuote` to be one contiguous span from the deck - without that, the model stitched two non-adjacent sentences together with \"...\" and called it a quote.\n\n``` python\nimport type OpenAI from 'openai';\n\nexport interface FitAssessment {\n    fit: boolean;\n    reasoning: string;\n    evidenceQuote: string;\n}\n\nconst SCHEMA = {\n    type: 'object',\n    properties: {\n        fit: { type: 'boolean', description: 'True if the deck matches the thesis, false otherwise.' },\n        reasoning: { type: 'string', description: 'One or two sentences explaining the fit/no-fit call.' },\n        evidenceQuote: {\n            type: 'string',\n            description:\n                \"A single contiguous verbatim span copied from the deck's text (a sentence or clause, not several stitched together with '...') that the reasoning is grounded in.\"\n        }\n    },\n    required: ['fit', 'reasoning', 'evidenceQuote'],\n    additionalProperties: false\n} as const;\n\nexport async function assessFit(openai: OpenAI, thesis: string, deckText: string): Promise<FitAssessment> {\n    const response = await openai.responses.create({\n        model: 'gpt-4.1', // pin the current model at build time\n        input: [\n            {\n                role: 'system',\n                content:\n                    \"You triage pitch decks against a fixed investment thesis. Judge only what the deck's text actually says - don't assume anything it doesn't state. Ground your reasoning in one short, verbatim quote from the deck: a single contiguous span copied exactly as written, never several sentences stitched together with '...'.\"\n            },\n            {\n                role: 'user',\n                content: `Thesis:\\n${thesis}\\n\\nPitch deck text:\\n${deckText}`\n            }\n        ],\n        text: {\n            format: {\n                type: 'json_schema',\n                name: 'fit_assessment',\n                schema: SCHEMA,\n                strict: true\n            }\n        }\n    });\n\n    return JSON.parse(response.output_text) as FitAssessment;\n}\n```\n\nNow the graph itself. Four nodes in a line, with two exits: stop if no candidate email, stop if the deck doesn't fit. No model chooses tools - each node calls one named Nango tool.\n\nCreate `src/pipeline.ts`:\n\n``` js\nimport { StateGraph, Annotation, START, END } from '@langchain/langgraph';\nimport type OpenAI from 'openai';\nimport { callNangoTool } from './mcp-client.ts';\nimport { extractPdfText } from './pdf.ts';\nimport { assessFit, type FitAssessment } from './assess.ts';\nimport { THESIS } from './thesis.ts';\n\ninterface EmailCandidate {\n    messageId: string;\n    threadId: string;\n    from: string;\n    subject: string;\n    attachmentId: string;\n    filename: string;\n}\n\nconst TriageState = Annotation.Root({\n    email: Annotation<EmailCandidate | null>({ reducer: (_prev, next) => next, default: () => null }),\n    deckText: Annotation<string | null>({ reducer: (_prev, next) => next, default: () => null }),\n    assessment: Annotation<FitAssessment | null>({ reducer: (_prev, next) => next, default: () => null }),\n    notified: Annotation<boolean>({ reducer: (_prev, next) => next, default: () => false })\n});\n\nexport interface GraphConfig {\n    nangoSecretKey: string;\n    gmailConnectionId: string;\n    slackConnectionId: string;\n    slackChannel: string;\n    openai: OpenAI;\n}\n\nexport function buildTriageGraph(config: GraphConfig) {\n    const gmailScope = { connectionId: config.gmailConnectionId, providerConfigKey: 'google-mail' };\n    const slackScope = { connectionId: config.slackConnectionId, providerConfigKey: 'slack' };\n\n    const graph = new StateGraph(TriageState)\n        .addNode('fetchEmail', async () => {\n            const { candidates } = await callNangoTool<{ candidates: EmailCandidate[] }>(config.nangoSecretKey, gmailScope, 'list-pitch-emails', {});\n            const email = candidates[0] ?? null;\n            if (!email) {\n                console.log('No candidate Pitch Deck email found - nothing to triage this run.');\n            }\n            return { email };\n        })\n        .addNode('fetchDeck', async (state) => {\n            if (!state.email) {\n                return {};\n            }\n            const { data } = await callNangoTool<{ data: string; size: number }>(config.nangoSecretKey, gmailScope, 'fetch-attachment', {\n                messageId: state.email.messageId,\n                attachmentId: state.email.attachmentId\n            });\n            const deckText = await extractPdfText(data);\n            return { deckText };\n        })\n        .addNode('assess', async (state) => {\n            if (!state.deckText) {\n                return {};\n            }\n            const assessment = await assessFit(config.openai, THESIS, state.deckText);\n            return { assessment };\n        })\n        .addNode('notify', async (state) => {\n            if (!state.email || !state.assessment) {\n                return {};\n            }\n            const text =\n                `*Pitch deck fit* — ${state.email.subject} (from ${state.email.from})\\n` +\n                `Fit: ${state.assessment.fit ? '✅ yes' : '❌ no'}\\n` +\n                `Reasoning: ${state.assessment.reasoning}\\n` +\n                `> ${state.assessment.evidenceQuote}`;\n            await callNangoTool(config.nangoSecretKey, slackScope, 'send-slack-message', { channel: config.slackChannel, text });\n            return { notified: true };\n        })\n        .addEdge(START, 'fetchEmail')\n        .addConditionalEdges('fetchEmail', (state) => (state.email ? 'fetchDeck' : END), { fetchDeck: 'fetchDeck', [END]: END })\n        .addEdge('fetchDeck', 'assess')\n        .addConditionalEdges('assess', (state) => (state.assessment?.fit ? 'notify' : END), { notify: 'notify', [END]: END })\n        .addEdge('notify', END);\n\n    return graph.compile();\n}\n```\n\nCreate `src/run.ts` - it reads the `.env`, builds the graph, and runs it once:\n\n``` python\nimport 'dotenv/config';\nimport OpenAI from 'openai';\nimport { buildTriageGraph } from './pipeline.ts';\n\nfunction requireEnv(name: string): string {\n    const v = process.env[name];\n    if (!v) {\n        throw new Error(`Missing env var ${name} - see .env.example`);\n    }\n    return v;\n}\n\nconst openai = new OpenAI({ apiKey: requireEnv('OPENAI_API_KEY') });\n\nconst graph = buildTriageGraph({\n    nangoSecretKey: requireEnv('NANGO_SECRET_KEY'),\n    gmailConnectionId: requireEnv('NANGO_GMAIL_CONNECTION_ID'),\n    slackConnectionId: requireEnv('NANGO_SLACK_CONNECTION_ID'),\n    slackChannel: requireEnv('SLACK_CHANNEL'),\n    openai\n});\n\nconst started = Date.now();\nconst result = await graph.invoke({});\nconst elapsed = Date.now() - started;\n\nconsole.log(`\\n--- Triage Run (${elapsed} ms) ---`);\nconsole.log(JSON.stringify(result, null, 2));\n```\n\nYou need PDFs to test with. Create `src/generate-decks.ts` - it writes three fabricated decks (one that fits the thesis, two that don't):\n\n``` python\nimport PDFDocument from 'pdfkit';\nimport { createWriteStream, mkdirSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst OUT_DIR = join(__dirname, '..', '..', 'decks');\n\ninterface Deck {\n    filename: string;\n    lines: string[];\n}\n\nconst DECKS: Deck[] = [\n    {\n        filename: 'quantumleap-fit.pdf',\n        lines: [\n            'QuantumLeap Analytics',\n            'Observability for backend engineers, not SREs on-call at 3am.',\n            '',\n            'Problem: mid-size engineering teams drown in dashboards but still get paged for issues nobody can explain.',\n            'Solution: a query-log-first observability tool built directly into the deploy pipeline, no agent to babysit.',\n            '',\n            'Team: two co-founders, both ex-Datadog engineers, both still write the core query engine.',\n            'Traction: $380K ARR across 14 mid-market engineering teams, up from $90K six months ago.',\n            'Ask: raising a $2.5M seed to hire 2 engineers and close a pipeline of 6 enterprise pilots we believe gets us to $1.1M ARR within 12 months.',\n            'Incorporated: Delaware C-corp, HQ in Austin, TX.'\n        ]\n    },\n    {\n        filename: 'fieldnote-no-fit-consumer.pdf',\n        lines: [\n            'Fieldnote',\n            'A daily journaling app that turns your mood into a photo memory.',\n            '',\n            'Problem: people want to reflect on their day but journaling apps feel like homework.',\n            'Solution: a 10-second voice note becomes a journal entry with an AI-generated photo of your mood.',\n            '',\n            'Team: one founder, background in product design at a consumer social app.',\n            'Traction: 40,000 downloads, 3,200 weekly active users, freemium with a $4.99/mo tier.',\n            'Ask: raising a $1.5M pre-seed to grow to 250,000 downloads via TikTok creator partnerships.',\n            'Incorporated: Delaware C-corp, HQ in Los Angeles, CA.'\n        ]\n    },\n    {\n        filename: 'brightforge-no-fit-hardware.pdf',\n        lines: [\n            'BrightForge Robotics',\n            'Autonomous forklifts for mid-size warehouses.',\n            '',\n            \"Problem: mid-size warehouses can't justify a full automation retrofit, so they stay manual and understaffed.\",\n            'Solution: a retrofit kit that turns an existing forklift into an autonomous unit in under a day.',\n            '',\n            'Team: two mechanical engineers, one ex-Boston Dynamics, one ex-Zoox.',\n            'Traction: 3 warehouse pilots running, $210K in signed pilot revenue, hardware gross margin 38%.',\n            'Ask: raising a $4M seed to build the next hardware revision and open a small assembly line.',\n            'Incorporated: Delaware C-corp, HQ in Pittsburgh, PA.'\n        ]\n    }\n];\n\nmkdirSync(OUT_DIR, { recursive: true });\n\nasync function writeDeck(deck: Deck): Promise<void> {\n    const outPath = join(OUT_DIR, deck.filename);\n    const doc = new PDFDocument({ margin: 60 });\n    const stream = createWriteStream(outPath);\n    doc.pipe(stream);\n\n    const [title, ...rest] = deck.lines;\n    doc.fontSize(20).text(title ?? '', { underline: true });\n    doc.moveDown();\n    doc.fontSize(12);\n    for (const line of rest) {\n        if (line === '') {\n            doc.moveDown();\n        } else {\n            doc.text(line);\n        }\n    }\n    doc.end();\n\n    // pdfkit flushes the trailer asynchronously - wait for the stream to\n    // finish or the file is truncated.\n    await new Promise<void>((resolve, reject) => {\n        stream.on('finish', resolve);\n        stream.on('error', reject);\n    });\n    console.log(`Wrote ${outPath}`);\n}\n\nfor (const deck of DECKS) {\n    await writeDeck(deck);\n}\n```\n\nRun it:\n\n```\nnpm run generate-decks\n```\n\nThree PDFs land in `decks/` at the project root.\n\n`decks/quantumleap-fit.pdf` to the Gmail account you connected, as a PDF attachment. Any subject line.`graph` folder:\n\n```\nnpm run triage\n```\n\nThe pipeline searches your inbox, downloads the PDF, extracts its text, asks OpenAI for a verdict, and - because this deck fits the thesis - posts to Slack:\n\nThe console prints the full state, including the assessment:\n\n```\n{\n  \"email\": { \"subject\": \"pitch deck\", \"from\": \"you@gmail.com\", \"filename\": \"quantumleap-fit.pdf\", ... },\n  \"assessment\": {\n    \"fit\": true,\n    \"reasoning\": \"US incorporation, developer-focused B2B software, ARR in range, technical co-founders coding, clear path to $1M+ ARR in 12 months.\",\n    \"evidenceQuote\": \"Team: two co-founders, both ex-Datadog engineers, both still write the core query engine.\"\n  },\n  \"notified\": true\n}\n```\n\nNow email `fieldnote-no-fit-consumer.pdf` and run it again: same fetch and extract, but the assessment comes back `fit: false` (\"a consumer journaling app, not B2B software for technical buyers\"), the graph routes past `notify`, and no Slack message is sent.\n\n| Issue | Cause and fix | \n|---|---|\n| `invalid_action_input: must be null` when a no-input action is called over MCP | Nango compiles a `z.void()` input schema to`{\"type\":\"null\"}` , but an MCP`tools/call` always sends`arguments` as an object (`{}` for a no-input tool), which fails that schema. Use`z.object({}).strict()` instead of`z.void()` . | \n| `SLACK_CHANNEL` reads as empty despite being set in`.env` | `dotenv` treats an unquoted`#` as a comment marker and drops everything after it, so`SLACK_CHANNEL=#pitch-triage` becomes empty. Quote it:`SLACK_CHANNEL=\"#pitch-triage\"` . | \n| `nango deploy dev` removes actions from another project | `nango deploy` makes the environment match the folder you deploy from - it's scoped to the environment, not the folder. Deploying a second project against the same secret key can wipe the first project's actions. Use a separate Nango environment per project. | \n| A run picks the wrong email when several are waiting | Gmail's `messages.list` order is not reliably newest-first. Code that takes`candidates[0]` can act on an older message while a newer one waits behind it. Sort or filter on`internalDate` rather than trusting order. | \n| Running the same pipeline twice on one email sends two Slack messages | Nothing remembers what was already processed. A search-based trigger needs its own idempotency key, checked and stored somewhere durable, if re-runs shouldn't double-notify. | \n| The extracted deck text is empty | The PDF has no text layer (it's a slide export of images). This pipeline is text-only, no OCR. | \n\nThe pipeline is short because Nango absorbs the parts that usually aren't: two OAuth flows, token refresh, the base URL and auth header on every provider call, and a tool interface the graph can call without an SDK. What's left in your code is the actual triage logic - search, extract, judge, notify - and a fixed graph wiring it together.\n\nThe same shape works for any \"read an inbox, judge it against a rule, tell a channel\" task: support triage, lead routing, compliance review. Swap the thesis, the Gmail query, and the Slack text.\n\nFull code: [github.com/emmakodes/pitch-deck-triage-agent](https://github.com/emmakodes/pitch-deck-triage-agent).\n\n*Built with [Nango](https://nango.dev), [LangGraph](https://langchain-ai.github.io/langgraphjs/), and the OpenAI Structured Outputs API.*", "url": "https://wpnews.pro/news/how-to-build-a-pitch-deck-triage-agent-with-langgraph-and-nango", "canonical_source": "https://dev.to/emmakodes_/how-to-build-a-pitch-deck-triage-agent-with-langgraph-and-nango-1c9d", "published_at": "2026-09-07 12:51:36+00:00", "updated_at": "2026-09-07 12:57:35.686468+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "machine-learning", "natural-language-processing"], "entities": ["LangGraph", "Nango", "Gmail", "Slack", "MCP"], "alternates": {"html": "https://wpnews.pro/news/how-to-build-a-pitch-deck-triage-agent-with-langgraph-and-nango", "markdown": "https://wpnews.pro/news/how-to-build-a-pitch-deck-triage-agent-with-langgraph-and-nango.md", "text": "https://wpnews.pro/news/how-to-build-a-pitch-deck-triage-agent-with-langgraph-and-nango.txt", "jsonld": "https://wpnews.pro/news/how-to-build-a-pitch-deck-triage-agent-with-langgraph-and-nango.jsonld"}}