Engineering
Engineering

Email Threading, CC Chains, and Calendar Sync: Inside a Zero-API Integration

Ale Moretti · 2026.9.18 · 14 min read

In the first post on this integration, I covered the architecture decision: Howie has no API, so the entire integration is a CC'd email address, a Nylas send, and a calendar matcher. This post goes a level deeper — how the email and calendar plumbing actually works, and the edge cases that only showed up once real threads started flowing.

Threading is the contract

When there is no API, the email thread is the integration contract. Howie's entire view of the world is the thread he is CC'd on, which means the thread has to be well-formed.

Email threading rests on three RFC 5322 headers:

  • Message-ID — a globally unique id every sent message carries
  • In-Reply-To — the Message-ID of the message being replied to
  • References — the accumulated chain of Message-IDs in the conversation

Mail clients and Howie both use these to group messages into a conversation. But the deeper reason we send through Nylas rather than a transactional provider is not header mechanics — it is where the thread lives.

A Nylas send goes out from the user's actual connected mailbox: the message lands in their Sent folder, replies come back to their inbox, and Gmail holds both sides of the conversation. Howie then negotiates on a thread the user genuinely owns. A transactional send from a platform domain would put the outbound message nowhere in the user's mailbox — replies route wherever Reply-To points, the user never sees the conversation Howie is having, and the "CC your assistant on the thread" model falls apart because there is no thread from the user's perspective.

Nylas also handles the In-Reply-To/References chain automatically on replies via reply_to_message_id, so downstream messages thread correctly across clients. That detail matters less for first sends (which start a new thread) and more for future work: if we ever build a "follow up on this intro" feature, the reply-threading has to be correct or Howie sees an orphaned message.

This is also why the Loops fallback path cannot carry Howie. When Nylas is unavailable, our email path falls back to Loops transactional templates — which cannot take arbitrary CCs, and which send from a platform domain rather than the user's mailbox. The job payload makes this explicit: NylasEmailSendJobData has an optional loopsFallback field, and the worker skips howieInvite persistence when it takes that path. Howie rides the Nylas path only. Known gap, deliberate trade.

CC injection: server-side, always re-verified

When a user sends a referral intro (or hits Schedule with Howie on a contact), the send path resolves the org's Howie installation and appends the assistant email to the CC list. The implementation is in resolveHowieIntroCc:

resolveHowieIntroCc({ organizationId, sendEmail, includeHowie, existingCc })
  → bail early if sendEmail=false or includeHowie=false
  → findOrgHowieInstallationForOrg(organizationId)
     → Prisma unique lookup on organizationId
     → returns null if row missing, revoked, or needs_reauth
  → appendEmails(existingCc, [howieEmail])
  → return { cc, howieInvite: { organizationId, howieEmail } }

Two design choices worth calling out:

The server re-checks the install. The client sends includeHowie: true, but the server never trusts it — resolveHowieIntroCc loads the OrgHowieInstallation row and confirms status === 'active'. If the admin disconnected Howie between the user loading the form and hitting send, the CC is silently dropped. This matters because the alternative — a stale CC that sends the scheduling ask to a revoked Howie address — wastes everyone's time.

Dedupe before send, every time. Users forward threads, reply-all, and paste addresses manually. appendEmails normalizes to lowercase and dedupes before injecting. If the assistant email is already on the thread, injecting it again produces a double-CC that some mail clients render as two recipients — and worse, could make Howie's own dedup logic treat the thread as new.

The schedule ask: a server-owned sentence

CC alone is not enough. During the pilot soak (INT-1970), we discovered that if the body does not contain an explicit scheduling request, Howie classifies the thread as informational and stays silent. So the server owns the ask:

export const HOWIE_SCHEDULE_ASK =
  "I'd like to find a time to meet. Howie, can you propose some times that work?"

buildHowieScheduleEmail appends this after an optional user-supplied context string. The ask is never user-editable. An integration that silently does nothing when the magic sentence is missing is worse than no integration — and surfacing a "Howie didn't engage" error after the email is sent provides no actionable recourse. So we make it structurally impossible.

The Schedule with Howie BFF route (/api/people/[id]/schedule-with-howie) validates the request body with Zod (extraEmails capped at 5, context at 2,000 chars), verifies the user has a Nylas grant, and confirms the contact exists in the caller's visible scope — all before touching the queue:

POST /api/people/:id/schedule-with-howie
  → Zod-validate body (extraEmails, context)
  → getActiveOrganizationContext() — auth + org scope
  → isHowieIntegrationPlatformEnabled() — feature gate
  → currentUser.nylasGrantId check — 409 if no email connected
  → prisma.user.findFirst with buildVisibleContactsWhere — 404 if not in scope
  → resolveHowieIntroCc — 409 if Howie not connected
  → buildHowieScheduleEmail — construct subject + body
  → addNylasEmailSendJob — BullMQ enqueue on the nylas-email-send queue

The entire flow is a single BFF route, not an API endpoint. No webhook, no callback, no polling. Fire and forget into BullMQ.

Schema: two tables, one email address

The data model is intentionally minimal:

OrgHowieInstallation — one row per org. Stores the Howie email (VARCHAR(320), normalized lowercase), status (active/revoked/needs_reauth), installer user id, and health tracking fields (lastInviteAt, lastErrorAt, lastErrorMessage). No tokens, no ciphertext, no connection ids. Connect is an upsert. Disconnect is a soft-revoke that preserves the email for easy reconnect.

The health fields let the Settings card show a heartbeat without polling Howie (which we cannot — no status endpoint). recordOrgHowieInviteSuccess stamps lastInviteAt and clears the error; recordOrgHowieError stamps one of three catalog codes: invite_persist, calendar_match, or connect.

HowieSchedulingInvite — one row per org+deal, created after a successful Nylas send on the referral-intro path. Stores the Howie email, the Nylas message and thread ids, and an optional nylasCalendarEventId that the matcher fills later. The compound unique on (organizationId, pipelineDealId) means retries overwrite — idempotent by design. The nylasCalendarEventId column is @unique to prevent the same calendar event from being linked to multiple invites.

The Schedule with Howie slideout path (person → schedule, no deal context) does not create a HowieSchedulingInvite. No deal, no invite row, no attribution match attempted. We decided that matching standalone scheduling threads was not worth the ambiguity cost.

The return path: calendar webhook, not a callback

Howie has no webhooks, so there is no "Howie booked the meeting" event. The return path is entirely infrastructure we already had:

Howie writes a Google Calendar event
  → Google Calendar updates
  → Nylas calendar webhook fires (existing NylasCalendarEvent upsert)
  → nylas-calendar-webhook-worker calls linkHowieSchedulingInviteAfterCalendarUpsert
  → event appears on the contact timeline with attribution

We never call events.create. Howie is the scheduling engine; we just watch the calendar.

Attribution: fail-closed matching

The interesting part is linkHowieSchedulingInviteAfterCalendarUpsert — connecting a synced calendar event back to the intro that caused it.

There is no Howie event id in the calendar event metadata, so the matcher is best-effort with a hard bias toward false negatives:

linkHowieSchedulingInviteAfterCalendarUpsert(input)
  → already linked? → return 'already_linked'
  → findOrgHowieInstallationForOrg → return 'inactive_install' if null
  → normalize install howieEmail, check it's in the event's attendees
     → return 'no_howie_attendee' if missing
  → query HowieSchedulingInvite where:
     - same org
     - nylasCalendarEventId IS NULL (unmatched)
     - createdAt <= event startTime
     - same howieEmail
  → 0 candidates → 'no_candidates'
  → >1 candidates → 'ambiguous' (fail-closed)
  → exactly 1 → updateMany with nylasCalendarEventId (optimistic lock via WHERE nylasCalendarEventId IS NULL)
     → count=0 → 'already_linked' (race)
     → count=1 → { linked: true, inviteId }

The take: 3 on the candidate query is a deliberate optimization: we only need to know if there are zero, one, or more-than-one. There is a ponytail: comment in the code noting the upgrade path — scoring by seeker/provider/connector email overlap — if INT-1970 soak data shows meaningful collision rates.

A CRM that guesses wrong about which meeting belongs to which referral is worse than one that says "unlinked." Same philosophy as our Zoom calendar matcher.

Rate limiting: human-initiated sends bypass Kai caps

The Nylas send path is shared with Kai intake, which has a per-org daily mutation cap (default 20, env-configurable). Schedule with Howie sends bypass that cap because they are explicit user actions, not agent-initiated mutations. The distinction is in the job metadata — the worker checks whether the send originated from a Kai pipeline or from a human-triggered route.

Edge cases from the field

Plus-addressing breaks pickup. Threads sent from user+tag@gmail.com aliases were silently ignored by Howie during QA. Same send from the canonical address worked. If your test rig uses plus-address aliases (ours did), you will spend an afternoon learning this. The Howie team confirmed this is expected behavior — their classifier strips plus-tags from recipients but not from senders.

Booking latency is minutes, not seconds. Howie runs a hybrid AI + human reviewer model — ambiguous scheduling decisions escalate to a human before anything sends. The first "Howie didn't book" report resolved itself 15 minutes later. Monitoring that alerts on a 10-minute send-to-event gap will page you for nothing. Our health tracking (lastInviteAtlastErrorAt) starts caring at a much longer horizon.

The Howie dashboard is the debugger. No API also means no status endpoint. howie.ai/dashboard → Currently Scheduling shows what Howie ingested. Thread not there? Check for the schedule ask, check for plus-addressing, check the account's Google Calendar connection. That checklist resolves nearly every "it didn't work" report.

Reconnect is a re-upsert, not a fresh install. Because disconnect is a soft-revoke (status: 'revoked', revokedAt stamped, howieEmail preserved), reconnecting the same email is a single upsert that flips status back to active and nulls out revokedAt. No data loss, no orphaned invite rows.

The shape of a zero-API integration

Strip it down and the whole integration is three independent mechanisms, none of which knows about the others:

MechanismOwnerJob
CC injection + schedule askBFF send path + BullMQ workerGet Howie onto a well-formed thread from the user's own mailbox
Scheduling conversationHowiePropose, follow up, book on Google Calendar
Calendar webhook sync + attribution matchExisting Nylas infra + linkHowieSchedulingInviteAfterCalendarUpsertSurface the booked meeting on the timeline and link it to the referral

No shared state between mechanisms, no sync jobs, no token refresh. Each piece is testable alone, and a failure in one degrades gracefully:

  • Nylas send fails → Loops fallback sends the email but without Howie CC. The referral still goes out.
  • Calendar webhook delayed → invite stays unmatched until the next webhook delivery. No data loss.
  • Matcher returns ambiguous → invite stays unlinked. Operator sees the meeting on the timeline via normal calendar sync, just without the referral attribution line.

When Howie ships an API someday, the honest answer is we would probably keep most of this — the thread is a better integration surface than it looks.

For the business case this plumbing serves, The Intro-to-Meeting Gap covers why referral intros die in scheduling limbo. If you want to set this up yourself, the 60-second setup guide has you covered.

See the result, not the plumbing: connect Howie in Settings, CC him on an intro, and the meeting lands on the contact timeline. Get started free →

We ship in public. Come build with us.

Introzy is free to start. No card required.