Zac Sheffer · 2026.9.21 · 8 min read
We built email intake first: forward a referral introduction to Kai and it turns the thread into tracked records. Then Slack intake: mention @Kai and describe the intro. Both worked. Then we maintained them for a while and noticed what we had actually built: two brains with the same job, drifting apart one bugfix at a time.
A parsing improvement would land in email and not Slack. A business rule would tighten in Slack and not email. The email agent's contact resolution had a fix for hyphenated last names that the Slack agent did not, because the fix was applied to the file that was broken at the time.
The two agents shared about 70 percent of their logic (contact resolution, company matching, referral creation, duplicate detection) and diverged on the remaining 30 percent (parsing, reply formatting, error messaging). The shared logic was not shared code. It was copied code that had drifted.
Linear solved the same structural problem for customer support and internal requests. Their Asks feature turns Slack conversations into structured Linear issues. The design separates cleanly:
The key insight from Linear's CX workflow: separate the channel-specific parsing from the classification, routing, and action. The channel adapter's job is to produce a normalized input. Everything downstream operates on that normalized form.
We applied the same principle to referral intake. Different domain, same architecture.
The current shape: a channel adapter normalizes whatever arrived (email thread, Slack mention, web form submission) into a common conversation format. The same agent handles it from there. Channel differences that matter (tone, reply formatting, what a "message" even is) live in a per-channel system-prompt suffix, not in duplicated logic.
Adding a channel means writing an adapter and a suffix, not standing up a second pipeline.
The agent's tools are the same MCP tool handlers external clients use. When Kai searches contacts or creates a referral, it calls the identical typed handlers an IDE client invokes over HTTP. There is no internal-vs-external drift because there is no internal version. CRM semantics live in one place, and every business rule enforced on the tool surface is automatically enforced on the agent.
That leads to the design rule we lean on hardest: business rules live in the tools, not the prompt. A connection requires an existing, qualified provider. The create tool enforces that, so the agent cannot be talked past it by a persuasive email thread. Anthropic's guidance on building effective agents makes the same recommendation: separate model reasoning from deterministic application logic so constraints are enforced by code rather than requested by text.
Email is the messiest. A forwarded introduction arrives as a nested structure: the outer message is the forward, the inner message is the original introduction, and the parties on the outer message are not the parties on the inner one. The adapter unwraps the forward, extracts the original thread, identifies which email addresses belong to which role (introducer vs. introduced parties), and handles commentary the forwarder added above the quoted content. Signature stripping is heuristic and imperfect. We err on keeping content that might be a signature rather than stripping content that might be relevant. (The email format is defined by RFC 5322, with attachments following MIME standards in RFCs 2045-2047. The gap between the clean spec and real-world forwarded emails is where most adapter complexity lives.)
Slack is structurally cleaner but semantically noisier. A mention can be a referral ("@Kai, I'd like to introduce Alice at Novaform to Bob at Greenline"), a status question ("@Kai, what happened with the Novaform deal?"), or noise ("@Kai lol"). The adapter normalizes Slack markup (user mentions become names, channel mentions become channel names, emoji reactions are stripped) and extracts thread context if the mention is a reply rather than a top-level message. The Slack Events API delivers these as JSON payloads. The adapter transforms them into the common conversation format.
Web forms are simplest because the structure is explicit: fields for introducer, introduced parties, context, and notes. The adapter mostly validates and normalizes formats. Web forms have their own quirk: users sometimes submit the same introduction twice because the first submission did not produce an immediate visible result. Exact duplicates are caught at the form level. Near-duplicates (same parties, slightly different context) are left for the agent's duplicate-detection tooling.
The intake agent receives input from channels where anyone can write anything. Slack members might mention @Kai as a joke. The email forwarding address can receive spam. Web forms are open to the internet.
The agent's tool surface is the filter. The agent attempts to resolve the parties mentioned in the input: find the introducer, find the introduced contacts, match them to companies. If the input is noise ("@Kai lol"), the agent finds no parties to resolve, no companies to match, no referral to create. It responds with "I could not identify a referral in this message" and the interaction ends. No records created, no state changed.
For more sophisticated noise (messages mentioning real-sounding names with no matching contacts or companies), the agent's search tools return empty results. The agent can still create new contacts if the input provides enough information (name, email, company), but the create tools enforce minimum field requirements. A vague mention without an email address or company name does not clear the bar.
This replaced a dedicated LLM classifier we built for Slack intent routing. Version one: deterministic phrase-match for obvious openers, LLM classifier for ambiguous input. Then we watched it run. Ambiguous text went classifier, then agent. The agent, reading with tools in hand, re-derived intent from the full thread on its own. The classifier's label made no difference to what happened next. It was pure cost and latency. We removed it: deterministic phrase-match for cheap certainty, straight to the agent for everything else.
The refined principle: do not LLM what a downstream LLM with better context already handles. And structured input needs no model at all. Slack modal submissions bypass the agent entirely because a filled-in form has no ambiguity to interpret.
If your per-channel logic is more than 40 percent of the total, specialized agents may win. If the shared logic dominates, one agent with adapters will save you from the drift that is inevitable when two codepaths do the same job.
Our 70/30 split made it clear. The adapter layer is real work. It has to understand each channel's quirks well enough to produce a clean normalization. But the agent itself, its tools, its policies, its business rules, exists once.
Mandated by the prompt but guaranteed by the tools: read the whole thread, resolve every party through read tools, mutate only when required fields are known. Duplicate handling is designed into the tool contract: create tools return isNew: false when the record already existed. The agent narrates "this contact was already in your system" instead of failing. The two-partners-referred-the-same-prospect collision surfaces at intake, the moment both facts can still be recorded, instead of in a dispute months later.
Everything above is machinery under a simple contract: the agent proposes structure, with the evidence it read, and a human approves what becomes real. That contract is the product-side story. The user-facing feature is Kai intake.
The tool surface itself, one set of handlers serving IDE clients, API keys, voice agents, and this in-process agent, is the next post.
Introzy is free to start. No card required.