본문으로 건너뛰기
오늘의 흐름
AIdev.to··원문 약 4

LangGraph 및 Nango를 사용하여 피치덱 분류 에이전트를 구축하는 방법

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

이 가이드에서는 Gmail에서 피치덱 이메일을 읽고, LLM을 통해 고정 투자 논문에 대해 각 덱을 판단하고, 덱이 적합할 때 Slack 메시지를 게시하는 AI 에이전트를 구축합니다.

LangGraph 및 Nango를 사용하여 피치덱 분류 에이전트를 구축하는 방법 대표 이미지

핵심 요약

자동 요약
  1. 1이 가이드에서는 Gmail에서 피치덱 이메일을 읽고, LLM을 통해 고정 투자 논문에 대해 각 덱을 판단하고, 덱이 적합할 때 Slack 메시지를 게시하는 AI…
  2. 2LangGraph는 단계를 조율합니다.
  3. 3Nango는 Gmail 및 Slack 연결을 처리하고 MCP를 통해 그래프에 노출합니다.

원문 본문

출처 · dev.to

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 handles the Gmail and Slack connections and exposes them to the graph over MCP.

By the end you will have:

  • Three Nango actions - search Gmail for pitch-deck emails, download an attachment, post to Slack - deployed and callable.
  • A LangGraph pipeline that runs those actions in a fixed order and, in between, asks OpenAI for a { fit, reasoning, evidenceQuote } verdict grounded in a real quote from the deck.
  • A working end-to-end run: email a PDF to yourself, run one command, get a Slack message.

Why is it hard to build a pipeline like this?

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.

Why use Nango for this

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.

Outline

  • Sign up for Nango and get your secret key
  • Add the Gmail and Slack integrations
  • Connect your Gmail and Slack accounts
  • Scaffold the Nango integration folder
  • Write the list-pitch-emails action
  • Write the fetch-attachment action
  • Write the send-slack-message action
  • Deploy the actions to Nango
  • Set up the LangGraph project
  • Add the thesis, MCP client, PDF, and assessment files
  • Build the LangGraph pipeline
  • Write the entry point
  • Generate sample pitch decks
  • Run a Triage Run
  • Common issues
  • Conclusion

Sign up for Nango and get your secret key

Go to app.nango.dev/signup and create a free account.

Nango sign-up page

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.

Nango Environment settings, API Keys tab

Add the Gmail and Slack integrations

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.

  1. Left sidebar → IntegrationsSet up new integration.
  2. Search Gmail and select it.
  3. On the Nango developer app tab, leave the pre-filled Client ID / Secret / Scopes as they are and click Create.

Adding the Gmail integration with Nango's pre-filled developer app

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:

Nango Integrations list with Gmail and Slack

Connect your Gmail and Slack accounts

Each integration now needs one authorized connection.

  1. Open the Gmail integration → click Add test connection.
  2. Click Authorize, and complete the Google popup.

Nango's Add test connection panel

The Gmail OAuth consent popup

Nango confirming the Gmail 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.

Nango confirming the Slack connection

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

Scaffold the Nango integration folder

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 

Write the list-pitch-emails action

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:

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.

Write the fetch-attachment action

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

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

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; 

Write the send-slack-message 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:

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; 

Deploy the actions to Nango

From the integration folder:

npx nango deploy dev 

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

A real npx nango deploy dev run

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

Gmail integration Functions tab with the two deployed actions

Slack shows send-slack-message:

Slack integration Functions tab with the deployed action

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

Set up the LangGraph project

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

cd .. mkdir -p graph/src cd graph 

Create package.json:

{ "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" } } 

Install:

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.

Add the thesis, MCP client, PDF, and assessment files

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.

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

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.

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.

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; } 

Build the LangGraph pipeline

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:

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(); } 

Write the entry point

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

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)); 

Generate sample pitch decks

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

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.

Run a Triage Run

  1. Email decks/quantumleap-fit.pdf to the Gmail account you connected, as a PDF attachment. Any subject line.
  2. From the 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 Slack message the pipeline posted for a fitting deck

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.

Common issues

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.

Conclusion

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.


Built with Nango, LangGraph, and the OpenAI Structured Outputs API.

For further actions, you may consider blocking this person and/or reporting abuse

이 글은 dev.to 의 원문을 정제해 보여드립니다. 저작권은 원저작자에게 있습니다.

#ai#langchain#mcp#nango

전체 내용이 궁금하다면

dev.to 원문에서 이어 읽기

원문 보기

비슷한 글

5유사도 추천