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
| Connector | Current local behaviour | Work required for live operation |
|---|---|---|
| Gmail | Email-shaped input becomes an interaction and agent trigger; deduplication by message ID within a tenant | OAuth lifecycle, mailbox history, attachment handling, source-account identity |
| Web form | Typed submission, captured identity and consent fields | Signed or authenticated ingestion, abuse controls and durable deduplication |
| CRM | In-memory push/pull projection of lead fields | Provider API, conflict policy, external IDs and replayable sync |
| Google Ads | Records an in-memory campaign projection | Account authorisation, live campaign API, reporting and spend reconciliation |
| Email service provider | Records a simulated send | Live 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.
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:
| Connector | Read or event contract | Governed write | Loop and failure to exercise |
|---|---|---|---|
| Mailbox | New message, attachment reference, conversation ID | Send the approved message to the recorded recipients | MTL / I2R: a timeout after send must become an uncertain outcome |
| Calendar | Availability, existing event version, attendee response | Create or move a reviewed appointment | L2C: availability can change between suggestion and booking |
| Document store | File ID, revision, owner, access scope and content hash | Save a proposal or handover at a known revision | L2C / O2D: reject a write that overwrites a newer approved version |
| Project tracker | Issue status, assignee, blocker and due date | Create or update a delivery task | O2D: a replay must find the same task, not create another |
| Repository / CI | Commit, check result and release evidence | Open a proposed change for review | O2D / O2I: a passing check for an older commit is stale evidence |
| Accounting system | Invoice, credit note and allocation reference | Prepare or post an approved accounting record | R2R: two source events can represent one financial obligation |
| Payment provider | Settlement, failure, reversal and provider payment ID | Submit a separately authorised payment instruction | L2C / P2P: request acceptance and settlement remain distinct |
| Team messaging | Verified message identity and channel context | Post a bounded internal status update | Shared: 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 behaviour | Adapter result | Scheduler response |
|---|---|---|
| Confirmed object returned | confirmed with external ID and version | Persist the receipt |
| Explicit quota response | retryable with documented wait | Reschedule within the task deadline |
| Credentials revoked | rejected or operator intervention | Pause the connection, request reconnection |
| Write accepted but reply lost | unknown | Reconcile the original operation |
| Object absent after a read | Typed absence | Re-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.
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.