← Building a solo company with AI agents

Part 5 of 40 · 10 min read

Connectors translate the outside world into facts and receipts

Experimental · Work in progress · Internal R&D

Email, forms, CRM, advertising and outbound messaging: existing simulations, live adapter contracts, receipts and reconciliation.

Arunank Sharan · Kuzushi Labs ·

In this article

A connector is often presented as a logo on an integrations page. For this project I care about a different question: if Gmail redelivers an event, a CRM changes a field, or a payment API times out, can the founder still tell what happened?

The current connector package contains useful local adapters for email, web forms, CRM projection, advertising and email delivery. The external transport is deliberately incomplete. I am using those seams to separate business logic from provider-specific authentication, pagination and delivery semantics.

What exists today

ConnectorCurrent local behaviourWork required for live operation
GmailEmail-shaped input becomes an interaction and agent trigger; deduplication by message ID within a tenantOAuth lifecycle, mailbox history, attachment handling, source-account identity
Web formTyped submission, captured identity and consent fieldsSigned or authenticated ingestion, abuse controls and durable deduplication
CRMIn-memory push/pull projection of lead fieldsProvider API, conflict policy, external IDs and replayable sync
Google AdsRecords an in-memory campaign projectionAccount authorisation, live campaign API, reporting and spend reconciliation
Email service providerRecords a simulated sendLive transport, delivery/bounce events, suppression and uncertain-outcome handling

Calendar, chat, document storage, project trackers, accounting systems and payment providers belong on the connector roadmap. They are not additional implemented integrations merely because a workflow would benefit from them.

Inbound and outbound are different contracts

The existing inbound interface is small:

class Connector(Protocol):
    def ingest(self, raw: Any, *, tenant_id: str) -> Trigger: ...

An inbound adapter normalises an external fact. An outbound adapter fulfils an already-authorised action and returns a receipt. The first must not smuggle an instruction into the control plane; the second must not decide business policy on its own.

For example, the body of an email can say “ignore the approval policy.” It remains untrusted customer content. It should not alter the dispatcher configuration. Similarly, an email adapter receives approved recipient and message arguments; it does not decide whether the customer may be contacted.

Inbound facts and outbound actions meet at an operation record, not at a raw tool call.

Gmail is a cursor problem as well as a parsing problem

A production Gmail connection needs mailbox change tracking. Push notifications carry a mailbox history reference rather than the full message. The receiver fetches changes since its stored cursor, persists the resulting interactions and advances the cursor after durable ingestion. Watches need renewal, and a catch-up path is needed when notifications are missed. Google's push-notification guide.

My proposed ingestion record is:

{
  "tenant_id": "studio-demo",
  "connector": "gmail",
  "source_account": "support-mailbox",
  "external_event_id": "history-change-017",
  "external_object_id": "message-031",
  "received_at": "2026-09-20T02:10:00Z",
  "payload_hash": "sha256:example",
  "mapping_version": "email-v2",
  "status": "PERSISTED"
}

The payload hash detects a surprising replay with changed content. The mapping version lets me reprocess older material deliberately when extraction logic changes. Neither field is a substitute for verifying the webhook or authenticating the source.

Do not promote a default into a fact

The prototype assigns a legitimate-interest consent label to some missing-consent cases. That is a local shortcut to make the pipeline run, not an established permission to market to someone. The live mapping should retain “unknown,” the source of permission and the permitted purpose. A business-specific review determines which action policy applies.

This also illustrates why I inspect adapter code instead of copying comments into product claims. “Consent checked” is meaningful only if there is evidence to check.

Outbound actions need receipts and reconciliation

For a new live integration, I want the adapter to return a typed result:

@dataclass(frozen=True)
class ExternalReceipt:
    operation_id: str
    provider: str
    status: str  # accepted | confirmed | rejected | unknown
    external_id: str | None
    request_hash: str

If the network connection fails after a provider accepted a request, the status is unknown. The next step is to query or reconcile the operation, not create a fresh business action. For webhook-based providers, duplicate events and out-of-order delivery also need explicit handling. Stripe's webhook delivery guidance.

Build connectors around the exact action they enable

A connector roadmap becomes more useful when each entry names an inbound fact and an outbound effect. “Integrate Google Workspace” is too broad to estimate or evaluate. “Read a new customer message and prepare a response against a specific thread” is a bounded slice.

This is the next connector catalogue for the studio example. These are proposed adapters, beyond the existing local simulations:

ConnectorRead or event contractGoverned writeLoop and failure to exercise
MailboxNew message, attachment reference, conversation IDSend the approved message to the recorded recipientsMTL / I2R: a timeout after send must become an uncertain outcome
CalendarAvailability, existing event version, attendee responseCreate or move a reviewed appointmentL2C: availability can change between suggestion and booking
Document storeFile ID, revision, owner, access scope and content hashSave a proposal or handover at a known revisionL2C / O2D: reject a write that overwrites a newer approved version
Project trackerIssue status, assignee, blocker and due dateCreate or update a delivery taskO2D: a replay must find the same task, not create another
Repository / CICommit, check result and release evidenceOpen a proposed change for reviewO2D / O2I: a passing check for an older commit is stale evidence
Accounting systemInvoice, credit note and allocation referencePrepare or post an approved accounting recordR2R: two source events can represent one financial obligation
Payment providerSettlement, failure, reversal and provider payment IDSubmit a separately authorised payment instructionL2C / P2P: request acceptance and settlement remain distinct
Team messagingVerified message identity and channel contextPost a bounded internal status updateShared: a chat reply is not automatically an authenticated approval

The action names in the catalogue are business verbs. A vendor adapter can map them to one or several API calls. Changing email providers should not change what counts as an approved customer message.

Put backpressure in the adapter, not in a prompt

Suppose a mailbox webhook causes 200 new interactions to arrive at once. I would persist those interactions quickly, then let a bounded worker pool fetch and transform them. The external API quota, the model budget and the founder's review capacity are three separate limits. Increasing all three at once would hide which one actually constrains the workflow.

A proposed connector attempt records enough information to decide what happens next:

from dataclasses import dataclass
from typing import Literal

@dataclass(frozen=True)
class AttemptResult:
    state: Literal["confirmed", "retryable", "unknown", "rejected"]
    provider_id: str | None = None
    retry_after_seconds: int | None = None
    reason: str | None = None

A read rejected because of a documented rate limit can wait for the provider's retry interval. A write that timed out after transmission may need reconciliation. An expired credential should open an operator task; repeatedly asking a language model to retry it adds cost without fixing access. The specific classification depends on the provider's response contract, so it belongs in the adapter and its tests.

For two-way CRM sync, I also need a field-ownership rule. The source system might own the legal account name while this platform owns a draft lead summary. An update should carry the external record version and mapping version. If both systems change a shared field, retain the conflict for review instead of silently using whichever update arrived last.

A connector should be replaceable without rewriting the loop

The lead loop should read a normalised lead and source interaction. It should not contain HubSpot field IDs. A quote action should describe a business document, not a Gmail MIME boundary. Provider-specific transformation belongs in the adapter, while the source-system identity remains available for audit.

The practical connector test suite covers redelivery, malformed payloads, expired credentials, rate limits, partial pages, conflicting updates, tenant mix-ups and ambiguous send outcomes. I would enable one read integration first, then one tightly bounded write. That creates a usable learning path without granting every agent access to every external system on day one.

An adapter is a stateful integration, not one HTTP call

Take an email provider that delivers the same message twice. A transport retry should not create another lead, but a follow-up in the same thread should create a new interaction. The ingress record therefore needs both the provider message identity and the conversation identity. A stable source-account identifier keeps two connected mailboxes from colliding.

The reference store demonstrates event identity and changed-payload detection:

from company_lab.store import Store, Conflict
from company_lab.connectors import digest

store = Store()
message = {"message_id": "m17", "thread_id": "t4", "body": "Please quote"}
assert store.ingest_once("studio", "mailbox-support", "m17", digest(message))
assert not store.ingest_once("studio", "mailbox-support", "m17", digest(message))
try:
    store.ingest_once("studio", "mailbox-support", "m17",
                      digest({**message, "body": "Different content"}))
except Conflict:
    pass  # quarantine conflicting reuse; do not silently overwrite the first event

In the full ingestion service, persisting the normalised interaction and claiming its external identity belong in the same transaction. The small ingest_once method only demonstrates the identity claim. Do not call it, acknowledge the provider, and then hope a later in-memory step creates the business record; a crash would lose the work while making its retry look processed.

Give every adapter a conformance suite

I would require a provider-shaped fixture for successful read, empty page, next page, rate limit, expired authorisation, deletion, duplicate event, conflicting update and ambiguous write. The adapter returns the same internal result types regardless of the vendor.

Provider behaviourAdapter resultScheduler response
Confirmed object returnedconfirmed with external ID and versionPersist the receipt
Explicit quota responseretryable with documented waitReschedule within the task deadline
Credentials revokedrejected or operator interventionPause the connection, request reconnection
Write accepted but reply lostunknownReconcile the original operation
Object absent after a readTyped absenceRe-evaluate the business prerequisite

A retry budget should be shared by the business task. Otherwise three wrapper layers can each retry five times and produce 125 downstream attempts. Record which layer owns transport retry and which owns business recovery. A rate-limited read can usually be retried; a changed commercial decision should be re-evaluated instead.

Persist the provider event, normalise it, schedule work, and reconcile outbound effects using the original operation ID.

Build a provider sandbox before a live connection

The kit's FakeProvider deliberately simulates a lost acknowledgement after accepting a request. Its effect map retains the provider receipt while the platform store is closed and reopened. The recovered worker looks up that same operation and confirms it without adding another effect.

This is more informative than mocking every API call to return 200. It lets the test distinguish a timeout before delivery from a timeout after delivery. A second fixture should represent a provider that offers no lookup or idempotency. The expected behaviour there is an unresolved exception for review, not a fabricated guarantee that retrying is safe.

When adding a real provider, document its idempotency scope and retention, account identity, object-version semantics and webhook authentication. Those details determine what the connector can promise. Provider SDKs can help construct requests, but they do not supply the business meaning of an operation.

Keep credentials out of the agent context

The model gets a connection alias such as customer-mailbox, never the credential. A backend connection registry resolves the alias to a tenant-owned account and scoped credential. It should reject an action if the connection belongs to another tenant or has lost the required scope since the run began.

For a founder managing multiple ventures, this prevents a particularly unpleasant error: sending the right message from the wrong company's mailbox. The approval preview should therefore include the actual sending account and intended recipients, and the action digest must bind those values. More connector depth, including mailbox cursors and CRM field ownership, is developed in part 27.