Stuart LabsProductAug–Oct 2026, ongoing
Outreach CRM
A multi-tenant CRM for student networking outreach. AI drafts each message from facts on file, the student sends it, and the whole pipeline doubles as an OAuth-protected MCP server that their own agent can work.
- Role
- Solo founder and engineer
- Status
- Private pilot
- Stack
- TypeScript, Next.js (App Router), PostgreSQL 16 with Row-Level Security, Vercel AI SDK and AI Gateway, Claude Sonnet, Model Context Protocol, OAuth 2.1 (PKCE), Clerk, Vercel Workflow DevKit, Claude Code (headless), launchd, Cloudflare Tunnel and R2, GitHub Actions

For a lot of students, networking outreach is how internships and jobs actually happen. Cold messages turn into coffee chats, and coffee chats turn into referrals. Managing it usually means a spreadsheet and a LinkedIn inbox, so follow-ups slip and nobody remembers what they told whom three weeks ago.
I founded Outreach CRM and built it alone under Stuart Labs, with coding agents implementing against my specs. In August 2026 I pitched it to Cornell Generative AI as the first product in the club's student-products track, and the club became its first organization. The target user runs 50 to 300 contacts and needs to see at a glance who they contacted, who replied, who is owed a follow-up and what to say next. The early adopters I had in mind were students in investment banking and consulting clubs, who can run more than 100 coffee chats a semester.
I have worked on it for about twelve weeks. The founding docs are dated August 2 and the first commit landed August 6. As of late October the repo has 1,217 commits, 63 merged pull requests and 9 architecture decision records, and the app is live in a private pilot.
What it does
The core surface is a fast, keyboard-driven table that works like a spreadsheet, with three registers for people, companies and job applications. You never set a contact's stage by hand. It derives from the interactions you log, so a contact moves from "Outreach #1" to "Reply #1" to "Call scheduled" to "Referral" as the thread moves, and the tiles across the top flag stale, positive and negative threads.

Companies are a dataset of their own, with tiers, aliases and rollups of the people and alumni you know there. Applications track postings with status, deadlines and materials (résumé variants, cover letters and screening answers). Each contact keeps the verbatim conversation thread, and that thread feeds the next draft.
Drafts that only say what is on file
The rule from the first design doc is "AI drafts; the human sends." By default, every draft (a LinkedIn connection note capped at 300 characters, a LinkedIn message or an email) waits in a queue for the person to read, edit and send themselves.
The risk I designed against hardest was fabrication. A model that invents a detail about the recipient can embarrass the student in the first minute of a call. So the drafting pipeline sorts everything the model sees into three classes by who wrote it and how far it can be trusted:
- System context is computed by the platform: the sender's profile, the recipient's record, the stage history, the conversation thread and the suggested next step. The model may state these as fact.
- Knowledge context is content from uploaded documents. Uploads are untrusted, so it stays in the data channel, may be used only with a citation, and is gated by a confidence level.
- User context is the student's instructions and message template. It shapes tone but is never a source of facts about the recipient.
Format limits like the 300-character connection note are enforced after generation, with one corrective retry. Every saved draft freezes the exact three-block context it was written from, along with the model and prompt version, so any draft can be reproduced and explained later. When the CRM knows little about someone, the draft comes out shorter instead of padded with guesses.

In-app drafting runs on Claude Sonnet through the Vercel AI Gateway. Short-form prose doesn't need Opus-tier reasoning, and at current pricing Sonnet costs roughly 40% as much on an interactive path. Splitting models per format is a one-line config change that I deferred until there is eval data to justify it.
The CRM is also an MCP server
The biggest product bet is that the student's own AI agent should do the heavy work, not the CRM. The services behind the web UI are also exposed as a Model Context Protocol server, so Claude Code, Claude Desktop, ChatGPT or Codex can connect and work the pipeline. The server registers 52 tools:
| Area | What the tools cover |
|---|---|
| Workspace | List workspaces, setup status, sender profile, message templates |
| Contacts and companies | Read, create and update records, log interactions, manage folders |
| Prospecting | Check whether people are already tracked, claim them before research, release claims |
| Drafting | Read the draft queue, fetch a draft's full context, save a draft, mark it sent |
| Applications and résumé | Postings, materials, a structured résumé master and tailored variants |
| Import | Templates, a mapping-review step, and the import run itself |
Prospect claims let parallel agent sessions reserve a person before researching them, so two sessions don't do the same work. Agent drafting has its own guard. Fetching a draft's context returns a SHA-256 fingerprint of the full authoring package, and the save is rejected if the server's rebuilt package no longer matches, for example because the sender's availability changed.
On top of the tools sit 19 recipes in a prompt gallery, such as finding people at a company, clearing the draft queue, prepping for a call and turning call notes into to-dos.

The MCP server is built fresh for each request and holds no session state, so it ships as one route of the web app (POST /mcp) instead of a separate service. The app runs its own OAuth 2.1 authorization server with discovery documents, dynamic client registration and PKCE (S256), and every token resolves to a real account:
MCP bearer token -> app OAuth server -> user_id -> membership + role -> row-level securityBrowser sign-in (Clerk) and agent tokens are separate chains that meet at the same user and membership. An advisor over MCP is exactly as read-only as one in the browser, and changing browser auth never invalidates agent tokens. The hosted endpoint needs no AI key because the connecting agent brings its own model, so students use the agent they already pay for. Local development runs the same toolset over stdio.
agentlink: letting the app start the work
MCP has one limit. The agent only acts when the user opens it and asks. I wanted the reverse too: when a student logs a scheduled call, a one-page brief on that person should be waiting before the call.
So I built agentlink, a small macOS background agent whose source is public on GitHub. It runs as a launchd daemon, pairs with a web app through a one-time code, and polls a per-workspace task queue every few seconds; the poll doubles as heartbeat and claim. For each task it runs Claude Code headless with a strict MCP config, a tool allow-list, a turn limit and a budget cap, and streams progress back. The app owns the queue, the policy and the audit log but never runs Claude Code itself.
The first version was a custom URL scheme opened by a button. Only a click could start it, the app couldn't tell whether an agent was installed, and first use took two permission prompts. The paired daemon fixed all three. With the automation on, logging a call queues the brief, and the task expires at the call time, so the brief is ready before the call or doesn't run.

agentlink is generic on purpose. It speaks a small HTTP contract (claim, status, events, complete) and never names the CRM, so any web app can adopt it.
How it is put together
The repo is a monorepo of 24 packages deployed as one Next.js service plus Postgres. Each domain module sits behind a types-only contract, and provider SDKs may be imported only inside the module that implements them (Clerk only in the auth adapter, model access only in the AI gateway), so swapping a provider is a config change. The main modules:
- Identity and access, wrapping Clerk behind an access policy
- Tenancy: workspaces, memberships and invitations
- Contact graph: contacts, companies, aliases, custom fields and prospect claims
- Pipeline: the interaction log, derived stages, threads, to-dos and follow-up timers
- Knowledge and document parsing (PDF, DOCX and text, behind a parser interface)
- The AI engine: drafting and recipient-profile synthesis
- Import (CSV, XLSX and TSV with mapping review, dedupe and provenance)
- Applications, and a résumé module that renders a one-page PDF in the user's original layout
The web UI and the MCP server are thin adapters over the same services and own no domain logic.
Tenancy was in the design from day one, even with one user. Every domain row carries a workspace_id, each request resolves user, workspace and role once at the boundary, and every repository method takes that context as its first argument, so there is no unscoped query path. Postgres row-level security repeats the scoping on every domain table, so a bug in one layer doesn't become a data leak. Roles are owner, member, read-only advisor, and contributor, whose additions wait for the owner's approval.
That paid off on October 25, when organization workspaces shipped. A personal workspace is one student's job search. An organization workspace holds a club's own outreach: members sign drafts with their own name and title, and both the repository and row-level security refuse job applications there. Organizations had sat in the data model since the start, so turning them on took a feature release, and the club's production workspace moved over in one migration.
Shipping and hosting
The deploy contract is host-agnostic: environment variables, npm start, a /healthz endpoint and a workflow backend chosen by config. For the pilot, production runs on a self-hosted machine behind a tunnel, with nightly off-site backups, instead of Railway, which I estimated at roughly $30 to $50 a month. I accepted the weaknesses of self-hosting because the public origin is permanent. It is the OAuth issuer, so a later move to Railway or Vercel is a database restore and a DNS change, not a new origin, and every MCP client registration and refresh token survives.
Releases are one command. npm run ship pushes the dev branch, opens a pull request, waits for CI and merges, then a self-hosted GitHub Actions runner builds, migrates and restarts the app. Merge commits instead of squashes keep dev an ancestor of main, so it never needs a force-push.
I made every decision recorded in the charter, the architecture doc and the nine ADRs, and each module had a written spec before any code. Claude Code implemented most of it against those specs. The codebase has 26 migrations, about 50 tables and 317 test files.
What I chose not to build
No provider batch APIs. The first AI design used direct provider SDKs so it could reach the discounted batch APIs. That one requirement carried two adapters, a durable job store, a reconciliation workflow, stall detection and an outbox. When I asked whether v1 needed provider batch at all, the answer was no, because every consumer is interactive. Dropping it removed most of the module, and every model call now goes through the gateway.
No vector store. Version 1.0 of the knowledge module was built with chunking, embeddings and pgvector. Two days later I took them out. A contact's linked documents are a handful of small files, so ranking chunks was selection machinery for a corpus that doesn't need selecting. Version 1.1 reads linked documents whole, newest first, into per-scope budgets. It is simpler and more faithful, and ranked retrieval can come back behind the same interface if corpora grow.
Where it stands
Outreach CRM is in a private pilot. I use it for my own search, and the Cornell Generative AI Club runs its own outreach from an organization workspace. The next step is a pilot cohort of up to about 50 students, which will need a cloud host. The deploy contract makes that move small.
Next
Running Cornell Generative AI like a startup →