# How to build a pitch deck triage agent with LangGraph and Nango

> Source: <https://dev.to/emmakodes_/how-to-build-a-pitch-deck-triage-agent-with-langgraph-and-nango-1c9d>
> Published: 2026-09-07 12:51:36+00:00

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.

By the end you will have:

`{ fit, reasoning, evidenceQuote }` verdict grounded in a real quote from the deck.
You 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.

Gmail'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.

Then 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.

Nango 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.

You 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.

`list-pitch-emails` action`fetch-attachment` action`send-slack-message` action
Go to [app.nango.dev/signup](https://app.nango.dev/signup) and create a free account.

Once 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.

An 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.

Repeat 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:

Each integration now needs one authorized connection.

Do 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.

Copy both **connection IDs** from the **Connections** tab. You now have: a Nango secret key, a Gmail connection ID, and a Slack connection ID.

Create the project and an `integration` folder for the Nango actions:

```
mkdir -p pitch-deck-triage-agent/integration/.nango
cd pitch-deck-triage-agent/integration
```

Create `package.json`:

```
{
  "name": "nango-integrations",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22.22.2" },
  "scripts": { "compile": "nango compile", "dev": "nango dev" },
  "devDependencies": { "nango": "0.71.6", "zod": "4.3.6" }
}
```

Install:

```
npm install
```

Create `tsconfig.json` (Nango uses this to type-check your actions):

```
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "include": ["index.ts", "**/*.ts"],
  "exclude": ["node_modules", "dist", "build", ".nango"],
  "compilerOptions": {
    "module": "node16",
    "target": "esnext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "moduleResolution": "node16",
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noEmit": true
  }
}
```

Create `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):

```
import './google-mail/actions/list-pitch-emails.js';
import './google-mail/actions/fetch-attachment.js';
import './slack/actions/send-slack-message.js';
```

Create `.env` with the dev secret key you copied earlier:

```
NANGO_SECRET_KEY_DEV=your-nango-dev-secret-key
```

This 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).

Create `google-mail/actions/list-pitch-emails.ts`:

``` js
import { createAction } from 'nango';
import * as z from 'zod';

// Input is z.object({}).strict(), not z.void(): Nango compiles `void` to a
// {"type":"null"} schema, but an MCP tools/call sends `arguments` as an
// object ({} for a no-input tool), which fails that schema. See Common issues.

const QUERY = 'has:attachment filename:pdf newer_than:30d';
const MAX_RESULTS = 5;

const outputSchema = z.object({
    candidates: z.array(
        z.object({
            messageId: z.string(),
            threadId: z.string(),
            from: z.string(),
            subject: z.string(),
            attachmentId: z.string(),
            filename: z.string()
        })
    )
});

interface GmailHeader {
    name: string;
    value: string;
}

interface GmailPart {
    mimeType?: string;
    filename?: string;
    body?: { attachmentId?: string; size?: number };
    parts?: GmailPart[];
}

interface GmailMessage {
    id: string;
    threadId: string;
    payload?: GmailPart & { headers?: GmailHeader[] };
}

interface GmailListResponse {
    messages?: { id: string; threadId: string }[];
}

function findPdfAttachment(part: GmailPart | undefined): { attachmentId: string; filename: string } | undefined {
    if (!part) {
        return undefined;
    }
    if (part.mimeType === 'application/pdf' && part.body?.attachmentId) {
        return { attachmentId: part.body.attachmentId, filename: part.filename || 'deck.pdf' };
    }
    for (const child of part.parts ?? []) {
        const found = findPdfAttachment(child);
        if (found) {
            return found;
        }
    }
    return undefined;
}

function header(headers: GmailHeader[] | undefined, name: string): string {
    return headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? '';
}

const action = createAction({
    description: 'List recent Gmail messages that have a PDF attachment, one candidate Pitch Deck per message.',
    version: '1.0.0',
    endpoint: { method: 'GET', path: '/gmail/pitch-emails', group: 'Triage' },
    input: z.object({}).strict(),
    output: outputSchema,

    exec: async (nango): Promise<z.infer<typeof outputSchema>> => {
        const listRes = await nango.get<GmailListResponse>({
            endpoint: '/gmail/v1/users/me/messages',
            params: { q: QUERY, maxResults: String(MAX_RESULTS) }
        });

        const candidates: z.infer<typeof outputSchema>['candidates'] = [];
        for (const { id } of listRes.data.messages ?? []) {
            const msgRes = await nango.get<GmailMessage>({
                endpoint: `/gmail/v1/users/me/messages/${id}`,
                params: { format: 'full' }
            });

            const attachment = findPdfAttachment(msgRes.data.payload);
            if (!attachment) {
                continue;
            }

            candidates.push({
                messageId: msgRes.data.id,
                threadId: msgRes.data.threadId,
                from: header(msgRes.data.payload?.headers, 'From'),
                subject: header(msgRes.data.payload?.headers, 'Subject'),
                attachmentId: attachment.attachmentId,
                filename: attachment.filename
            });
        }

        return { candidates };
    }
});

export type NangoActionLocal = Parameters<(typeof action)['exec']>[0];
export default action;
```

Inside `exec`, `nango.get` calls the real Gmail API with the connection's token attached for you. You never see a token.

Given the `messageId` and `attachmentId` from the first action, this one downloads the attachment's bytes.

Create `google-mail/actions/fetch-attachment.ts`:

``` js
import { createAction } from 'nango';
import * as z from 'zod';

const inputSchema = z.object({
    messageId: z.string().min(1),
    attachmentId: z.string().min(1)
});

const outputSchema = z.object({
    // Gmail returns this base64url-encoded (- and _ instead of + and /),
    // not standard base64. The caller converts before decoding.
    data: z.string(),
    size: z.number()
});

interface GmailAttachmentResponse {
    data: string;
    size: number;
}

const action = createAction({
    description: "Fetch the raw content of one Gmail attachment (base64url-encoded, as Gmail's API returns it).",
    version: '1.0.0',
    endpoint: { method: 'GET', path: '/gmail/attachment', group: 'Triage' },
    input: inputSchema,
    output: outputSchema,

    exec: async (nango, input): Promise<z.infer<typeof outputSchema>> => {
        const res = await nango.get<GmailAttachmentResponse>({
            endpoint: `/gmail/v1/users/me/messages/${input.messageId}/attachments/${input.attachmentId}`
        });

        return { data: res.data.data, size: res.data.size };
    }
});

export type NangoActionLocal = Parameters<(typeof action)['exec']>[0];
export default action;
```

This 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.

Create `slack/actions/send-slack-message.ts`:

``` js
import { createAction } from 'nango';
import * as z from 'zod';

const inputSchema = z.object({
    channel: z.string().min(1).describe('Slack channel ID or name (e.g. "#pitch-triage")'),
    text: z.string().min(1)
});

const outputSchema = z.object({
    channel: z.string(),
    ts: z.string()
});

interface SlackPostMessageResponse {
    ok: boolean;
    error?: string;
    channel?: string;
    ts?: string;
}

const action = createAction({
    description: 'Post a message to a Slack channel.',
    version: '1.0.0',
    endpoint: { method: 'POST', path: '/slack/messages', group: 'Triage' },
    input: inputSchema,
    output: outputSchema,

    exec: async (nango, input): Promise<z.infer<typeof outputSchema>> => {
        const res = await nango.post<SlackPostMessageResponse>({
            endpoint: '/chat.postMessage',
            retries: 0,
            data: { channel: input.channel, text: input.text }
        });

        if (!res.data.ok || !res.data.ts || !res.data.channel) {
            throw new nango.ActionError({ message: `Slack chat.postMessage failed: ${res.data.error ?? 'unknown error'}` });
        }

        return { channel: res.data.channel, ts: res.data.ts };
    }
});

export type NangoActionLocal = Parameters<(typeof action)['exec']>[0];
export default action;
```

From the `integration` folder:

```
npx nango deploy dev
```

Nango type-checks and uploads all three actions in one go:

Check the **Functions** tab on each integration in the dashboard. Gmail shows `list-pitch-emails` and `fetch-attachment`:

Slack shows `send-slack-message`:

Each deployed action is now also a tool on Nango's MCP server. That's what the graph calls next.

Back at the project root, create a `graph` folder for the pipeline:

```
cd ..
mkdir -p graph/src
cd graph
{
  "name": "pitch-deck-triage-graph",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22" },
  "scripts": {
    "triage": "tsx src/run.ts",
    "generate-decks": "tsx src/generate-decks.ts"
  },
  "dependencies": {
    "@langchain/langgraph": "^0.2.57",
    "dotenv": "^16.4.5",
    "openai": "^4.104.0",
    "pdfjs-dist": "^6.3.289"
  },
  "devDependencies": {
    "@types/node": "^22.9.0",
    "@types/pdfkit": "^0.13.4",
    "pdfkit": "^0.15.1",
    "tsx": "^4.19.2",
    "typescript": "^5.6.3"
  }
}
npm install
```

Create `tsconfig.json`:

```
{
  "compilerOptions": {
    "target": "es2022",
    "module": "node16",
    "moduleResolution": "node16",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}
```

Create `.env` with the values you gathered earlier (`NANGO_SECRET_KEY` is the same dev key as `NANGO_SECRET_KEY_DEV`):

```
OPENAI_API_KEY=your-openai-key
NANGO_SECRET_KEY=your-nango-dev-secret-key
NANGO_GMAIL_CONNECTION_ID=your-gmail-connection-id
NANGO_SLACK_CONNECTION_ID=your-slack-connection-id
SLACK_CHANNEL="#pitch-triage"
```

Quote `SLACK_CHANNEL` if it starts with `#` - `dotenv` treats an unquoted `#` as a comment and drops the rest of the line.

Four small `src/` files the pipeline depends on.

**`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.

``` js
export const THESIS = `Acme Ventures invests in seed-stage, developer-focused B2B software companies.

We look for:
- US or EU-incorporated companies
- A product built for a technical buyer (developers, DevOps, IT, or security teams)
- $0-$2M in current ARR
- At least one technical co-founder who still writes code
- A believable path to $1M ARR within 12 months of this round

We do not invest in consumer apps, hardware, life sciences, gaming, or crypto/token-based businesses, regardless of traction.`;
```

**`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`).

``` js
const NANGO_MCP_URL = 'https://api.nango.dev/mcp';

export interface McpScope {
    connectionId: string;
    providerConfigKey: string;
}

interface JsonRpcResponse {
    jsonrpc: '2.0';
    id?: number;
    result?: unknown;
    error?: { code: number; message: string; data?: unknown };
}

interface McpToolResult {
    isError?: boolean;
    content?: { type: string; text?: string }[];
}

function headersFor(secretKey: string, scope: McpScope): Record<string, string> {
    return {
        'Content-Type': 'application/json',
        Accept: 'application/json, text/event-stream',
        Authorization: `Bearer ${secretKey}`,
        'connection-id': scope.connectionId,
        'provider-config-key': scope.providerConfigKey
    };
}

async function parseBody(res: Response): Promise<JsonRpcResponse | undefined> {
    const contentType = res.headers.get('content-type') ?? '';
    const body = await res.text();
    if (!body) {
        return undefined;
    }

    if (contentType.includes('text/event-stream')) {
        // SSE framing: one or more "data: <json>" lines; take the last.
        const dataLines = body
            .split('\n')
            .filter((line) => line.startsWith('data:'))
            .map((line) => line.slice('data:'.length).trim());
        const last = dataLines.at(-1);
        return last ? (JSON.parse(last) as JsonRpcResponse) : undefined;
    }

    return JSON.parse(body) as JsonRpcResponse;
}

let nextId = 1;

async function rpc(headers: Record<string, string>, method: string, params?: unknown): Promise<unknown> {
    const res = await fetch(NANGO_MCP_URL, {
        method: 'POST',
        headers,
        body: JSON.stringify({ jsonrpc: '2.0', id: nextId++, method, params })
    });
    if (!res.ok) {
        throw new Error(`MCP ${method} failed: HTTP ${res.status} ${await res.text()}`);
    }
    const parsed = await parseBody(res);
    if (parsed?.error) {
        throw new Error(`MCP ${method} error: ${JSON.stringify(parsed.error)}`);
    }
    return parsed?.result;
}

export async function callNangoTool<T = unknown>(secretKey: string, scope: McpScope, toolName: string, args: Record<string, unknown>): Promise<T> {
    const headers = headersFor(secretKey, scope);

    await rpc(headers, 'initialize', {
        protocolVersion: '2025-03-26',
        capabilities: {},
        clientInfo: { name: 'pitch-deck-triage-graph', version: '1.0.0' }
    });

    // Required notification - no response expected, fire and ignore.
    await fetch(NANGO_MCP_URL, {
        method: 'POST',
        headers,
        body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })
    }).catch(() => undefined);

    const result = (await rpc(headers, 'tools/call', { name: toolName, arguments: args })) as McpToolResult | undefined;

    if (result?.isError) {
        throw new Error(`Nango tool "${toolName}" failed: ${JSON.stringify(result.content)}`);
    }

    const textBlock = result?.content?.find((c) => c.type === 'text' && typeof c.text === 'string');
    if (!textBlock?.text) {
        throw new Error(`Nango tool "${toolName}" returned no text content: ${JSON.stringify(result)}`);
    }
    return JSON.parse(textBlock.text) as T;
}
```

**`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.

``` js
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';

interface TextItem {
    str?: string;
}

type GetDocumentParams = Parameters<typeof getDocument>[0];

export async function extractPdfText(base64UrlData: string): Promise<string> {
    const base64 = base64UrlData.replace(/-/g, '+').replace(/_/g, '/');
    const buffer = Buffer.from(base64, 'base64');

    // disableWorker isn't in this version's types but works at runtime.
    const params = {
        data: new Uint8Array(buffer),
        disableWorker: true
    } as unknown as GetDocumentParams;
    const doc = await getDocument(params).promise;
    const pages: string[] = [];
    for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
        const page = await doc.getPage(pageNum);
        const content = await page.getTextContent();
        const text = content.items.map((item) => (item as TextItem).str ?? '').join(' ');
        pages.push(text);
    }

    return pages.join('\n\n').trim();
}
```

**`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.

``` python
import type OpenAI from 'openai';

export interface FitAssessment {
    fit: boolean;
    reasoning: string;
    evidenceQuote: string;
}

const SCHEMA = {
    type: 'object',
    properties: {
        fit: { type: 'boolean', description: 'True if the deck matches the thesis, false otherwise.' },
        reasoning: { type: 'string', description: 'One or two sentences explaining the fit/no-fit call.' },
        evidenceQuote: {
            type: 'string',
            description:
                "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."
        }
    },
    required: ['fit', 'reasoning', 'evidenceQuote'],
    additionalProperties: false
} as const;

export async function assessFit(openai: OpenAI, thesis: string, deckText: string): Promise<FitAssessment> {
    const response = await openai.responses.create({
        model: 'gpt-4.1', // pin the current model at build time
        input: [
            {
                role: 'system',
                content:
                    "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 '...'."
            },
            {
                role: 'user',
                content: `Thesis:\n${thesis}\n\nPitch deck text:\n${deckText}`
            }
        ],
        text: {
            format: {
                type: 'json_schema',
                name: 'fit_assessment',
                schema: SCHEMA,
                strict: true
            }
        }
    });

    return JSON.parse(response.output_text) as FitAssessment;
}
```

Now 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.

Create `src/pipeline.ts`:

``` js
import { StateGraph, Annotation, START, END } from '@langchain/langgraph';
import type OpenAI from 'openai';
import { callNangoTool } from './mcp-client.ts';
import { extractPdfText } from './pdf.ts';
import { assessFit, type FitAssessment } from './assess.ts';
import { THESIS } from './thesis.ts';

interface EmailCandidate {
    messageId: string;
    threadId: string;
    from: string;
    subject: string;
    attachmentId: string;
    filename: string;
}

const TriageState = Annotation.Root({
    email: Annotation<EmailCandidate | null>({ reducer: (_prev, next) => next, default: () => null }),
    deckText: Annotation<string | null>({ reducer: (_prev, next) => next, default: () => null }),
    assessment: Annotation<FitAssessment | null>({ reducer: (_prev, next) => next, default: () => null }),
    notified: Annotation<boolean>({ reducer: (_prev, next) => next, default: () => false })
});

export interface GraphConfig {
    nangoSecretKey: string;
    gmailConnectionId: string;
    slackConnectionId: string;
    slackChannel: string;
    openai: OpenAI;
}

export function buildTriageGraph(config: GraphConfig) {
    const gmailScope = { connectionId: config.gmailConnectionId, providerConfigKey: 'google-mail' };
    const slackScope = { connectionId: config.slackConnectionId, providerConfigKey: 'slack' };

    const graph = new StateGraph(TriageState)
        .addNode('fetchEmail', async () => {
            const { candidates } = await callNangoTool<{ candidates: EmailCandidate[] }>(config.nangoSecretKey, gmailScope, 'list-pitch-emails', {});
            const email = candidates[0] ?? null;
            if (!email) {
                console.log('No candidate Pitch Deck email found - nothing to triage this run.');
            }
            return { email };
        })
        .addNode('fetchDeck', async (state) => {
            if (!state.email) {
                return {};
            }
            const { data } = await callNangoTool<{ data: string; size: number }>(config.nangoSecretKey, gmailScope, 'fetch-attachment', {
                messageId: state.email.messageId,
                attachmentId: state.email.attachmentId
            });
            const deckText = await extractPdfText(data);
            return { deckText };
        })
        .addNode('assess', async (state) => {
            if (!state.deckText) {
                return {};
            }
            const assessment = await assessFit(config.openai, THESIS, state.deckText);
            return { assessment };
        })
        .addNode('notify', async (state) => {
            if (!state.email || !state.assessment) {
                return {};
            }
            const text =
                `*Pitch deck fit* — ${state.email.subject} (from ${state.email.from})\n` +
                `Fit: ${state.assessment.fit ? '✅ yes' : '❌ no'}\n` +
                `Reasoning: ${state.assessment.reasoning}\n` +
                `> ${state.assessment.evidenceQuote}`;
            await callNangoTool(config.nangoSecretKey, slackScope, 'send-slack-message', { channel: config.slackChannel, text });
            return { notified: true };
        })
        .addEdge(START, 'fetchEmail')
        .addConditionalEdges('fetchEmail', (state) => (state.email ? 'fetchDeck' : END), { fetchDeck: 'fetchDeck', [END]: END })
        .addEdge('fetchDeck', 'assess')
        .addConditionalEdges('assess', (state) => (state.assessment?.fit ? 'notify' : END), { notify: 'notify', [END]: END })
        .addEdge('notify', END);

    return graph.compile();
}
```

Create `src/run.ts` - it reads the `.env`, builds the graph, and runs it once:

``` python
import 'dotenv/config';
import OpenAI from 'openai';
import { buildTriageGraph } from './pipeline.ts';

function requireEnv(name: string): string {
    const v = process.env[name];
    if (!v) {
        throw new Error(`Missing env var ${name} - see .env.example`);
    }
    return v;
}

const openai = new OpenAI({ apiKey: requireEnv('OPENAI_API_KEY') });

const graph = buildTriageGraph({
    nangoSecretKey: requireEnv('NANGO_SECRET_KEY'),
    gmailConnectionId: requireEnv('NANGO_GMAIL_CONNECTION_ID'),
    slackConnectionId: requireEnv('NANGO_SLACK_CONNECTION_ID'),
    slackChannel: requireEnv('SLACK_CHANNEL'),
    openai
});

const started = Date.now();
const result = await graph.invoke({});
const elapsed = Date.now() - started;

console.log(`\n--- Triage Run (${elapsed} ms) ---`);
console.log(JSON.stringify(result, null, 2));
```

You need PDFs to test with. Create `src/generate-decks.ts` - it writes three fabricated decks (one that fits the thesis, two that don't):

``` python
import PDFDocument from 'pdfkit';
import { createWriteStream, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = join(__dirname, '..', '..', 'decks');

interface Deck {
    filename: string;
    lines: string[];
}

const DECKS: Deck[] = [
    {
        filename: 'quantumleap-fit.pdf',
        lines: [
            'QuantumLeap Analytics',
            'Observability for backend engineers, not SREs on-call at 3am.',
            '',
            'Problem: mid-size engineering teams drown in dashboards but still get paged for issues nobody can explain.',
            'Solution: a query-log-first observability tool built directly into the deploy pipeline, no agent to babysit.',
            '',
            'Team: two co-founders, both ex-Datadog engineers, both still write the core query engine.',
            'Traction: $380K ARR across 14 mid-market engineering teams, up from $90K six months ago.',
            '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.',
            'Incorporated: Delaware C-corp, HQ in Austin, TX.'
        ]
    },
    {
        filename: 'fieldnote-no-fit-consumer.pdf',
        lines: [
            'Fieldnote',
            'A daily journaling app that turns your mood into a photo memory.',
            '',
            'Problem: people want to reflect on their day but journaling apps feel like homework.',
            'Solution: a 10-second voice note becomes a journal entry with an AI-generated photo of your mood.',
            '',
            'Team: one founder, background in product design at a consumer social app.',
            'Traction: 40,000 downloads, 3,200 weekly active users, freemium with a $4.99/mo tier.',
            'Ask: raising a $1.5M pre-seed to grow to 250,000 downloads via TikTok creator partnerships.',
            'Incorporated: Delaware C-corp, HQ in Los Angeles, CA.'
        ]
    },
    {
        filename: 'brightforge-no-fit-hardware.pdf',
        lines: [
            'BrightForge Robotics',
            'Autonomous forklifts for mid-size warehouses.',
            '',
            "Problem: mid-size warehouses can't justify a full automation retrofit, so they stay manual and understaffed.",
            'Solution: a retrofit kit that turns an existing forklift into an autonomous unit in under a day.',
            '',
            'Team: two mechanical engineers, one ex-Boston Dynamics, one ex-Zoox.',
            'Traction: 3 warehouse pilots running, $210K in signed pilot revenue, hardware gross margin 38%.',
            'Ask: raising a $4M seed to build the next hardware revision and open a small assembly line.',
            'Incorporated: Delaware C-corp, HQ in Pittsburgh, PA.'
        ]
    }
];

mkdirSync(OUT_DIR, { recursive: true });

async function writeDeck(deck: Deck): Promise<void> {
    const outPath = join(OUT_DIR, deck.filename);
    const doc = new PDFDocument({ margin: 60 });
    const stream = createWriteStream(outPath);
    doc.pipe(stream);

    const [title, ...rest] = deck.lines;
    doc.fontSize(20).text(title ?? '', { underline: true });
    doc.moveDown();
    doc.fontSize(12);
    for (const line of rest) {
        if (line === '') {
            doc.moveDown();
        } else {
            doc.text(line);
        }
    }
    doc.end();

    // pdfkit flushes the trailer asynchronously - wait for the stream to
    // finish or the file is truncated.
    await new Promise<void>((resolve, reject) => {
        stream.on('finish', resolve);
        stream.on('error', reject);
    });
    console.log(`Wrote ${outPath}`);
}

for (const deck of DECKS) {
    await writeDeck(deck);
}
```

Run it:

```
npm run generate-decks
```

Three PDFs land in `decks/` at the project root.

`decks/quantumleap-fit.pdf` to the Gmail account you connected, as a PDF attachment. Any subject line.`graph` folder:

```
npm run triage
```

The 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:

The console prints the full state, including the assessment:

```
{
  "email": { "subject": "pitch deck", "from": "you@gmail.com", "filename": "quantumleap-fit.pdf", ... },
  "assessment": {
    "fit": true,
    "reasoning": "US incorporation, developer-focused B2B software, ARR in range, technical co-founders coding, clear path to $1M+ ARR in 12 months.",
    "evidenceQuote": "Team: two co-founders, both ex-Datadog engineers, both still write the core query engine."
  },
  "notified": true
}
```

Now 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.

| Issue | Cause and fix | 
|---|---|
| `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()` . | 
| `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"` . | 
| `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. | 
| 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. | 
| 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. | 
| 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. | 

The 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.

The 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.

Full code: [github.com/emmakodes/pitch-deck-triage-agent](https://github.com/emmakodes/pitch-deck-triage-agent).

*Built with [Nango](https://nango.dev), [LangGraph](https://langchain-ai.github.io/langgraphjs/), and the OpenAI Structured Outputs API.*
