Problem
Small service teams (real-estate offices, agencies, dispatch desks) have their operational reality scattered across a phone system, an inbox, a CRM and someone’s notes app. They want AI to organise that. They do not want AI speaking for them: sending a confirmation the customer never agreed to, or attaching a call to the wrong client. Most “AI ops automation” demos are an LLM with a webhook. The interesting parts of this one are the three places it refuses to guess.
Constraints
- Call and email platforms redeliver events on retry, often with a fresh delivery timestamp.
- A model that sounds confident is not evidence. A phone number recited in a transcript can be wrong; the same number in the platform’s structured metadata cannot.
- Two contacts with the same surname exist in every CRM.
- Every integration (Slack, Notion, Airtable, the model itself) has to degrade independently, so a missing credential fails at the point of use with a clear message, never silently at boot.
System
n8n orchestrates: webhooks, cron, retries, error routing. A FastAPI service decides: idempotency claim, persist the raw event before any model call, a three-hop LangGraph pipeline (classify → extract → assess), fact adjudication, contact matching, task creation, and then the Slack gate. Supabase is the record; Notion is the surface the team works from; Airtable stands in for the client’s real CRM.
Slack interactivity points directly at the service rather than through n8n, because signature verification and the approval state transition belong in the same place. An approval that can be replayed or forged is worse than no gate at all.
Decisions
The model cannot certify its own output. The extraction graph proposes a provenance for each claim, and the fact layer throws that proposal away and re-derives it from whether the value matches the event’s structured block. A note, which has no structured block, can never produce a verified fact. This is what makes the approval gate load-bearing instead of decorative.
Dedupe keyed on source + external_id, not the timestamp. The original spec included the delivery timestamp. Folding it in would mean the same logical event redelivered five seconds later hashes differently and sails through, reopening the exact hole dedupe exists to close. Typed notes, which have no upstream id, fall back to source + occurred_at + digest(content), normalised so reformatting a note isn’t a new event.
Matching returns a decision, not a best guess. Three passes (exact identifier, lexical name similarity, optional embeddings), and anything below the accept threshold creates the task flagged needs_contact_link with candidates attached. Two Castellanos siblings both scoring 1.00 against “this is Castellanos” is a flag, not a coin flip. Company alone never links.
Three hops instead of one prompt. Cheap triage terminates non-actionable events (thanks, FYIs) before the expensive hops. Extraction carries the verbatim span for every item: no quote, no claim. Assessment sees the extraction as data rather than its own prior output, which makes it far more willing to mark claims low-confidence.
Outcome
117 tests drive the real pipeline end to end on scripted model responses with no network. The replay script fires each fixture twice so you can watch the second delivery get dropped. /healthz reports exactly which integrations are live and which are running dry, and outside development the service refuses to serve the Slack interactions endpoint without a signing secret, because it can approve outbound messages.