How to build a pitch deck triage agent with LangGraph and Nango 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. 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