Chapter 24 / 40
What the AI can access
Every AI surface at Sodimo — a Claude.ai session, a Paperclip scheduled agent, a skill invoked from a terminal — reaches one endpoint: a Cloudflare Worker called sodimo-core, served at mcp.sodimo.eu. That endpoint is the only place the AI can touch Sodimo’s systems. One URL, fronted by Cloudflare Access with Google Workspace as the identity provider. The Worker decides what each caller may do and writes every call to the run ledger. See chapter Design principles, Principle 3, for why there is no second MCP endpoint on the Framework Desktop.
Status: Blocked — tools are designed. They come online once the Framework Desktop is live, Sodiwin data is flowing to the warehouse, the Twenty CRM quadlet is up, and the email server is cut over.
Humans reach on-prem services a different way: each service’s own web interface over Cloudflare Tunnel + Cloudflare Access — OpenWebUI for local-model chat, Piler for email search, Twenty for CRM, Cockpit for system admin. No MCP indirection for humans. The split is load-bearing — every AI-caused side-effect is recorded in one ledger under one schema.
MCP (the common protocol Claude uses to call tools) always responds in English, by design. Existing sodimo.eu email aliases are preserved as they are; the team adds and removes aliases on the mail server.
One endpoint, one audit trail
Every AI-caused side-effect — every email sent, every CRM note written, every draft queued — is recorded in one table with one schema. “Show me every email the AI sent last week” is one query against run_ledger, not a stitch across per-surface logs. “Show me every CRM write the AI made on behalf of Paul on Tuesday” is another. This falls out of the single-MCP-surface invariant; it is also the basis for CNIL compliance and for the savings claim (Principle 2, chapter Design principles).
Tool manifest — v1
The v1 surface is 13 tools across four backends: D1 (Sodiwin-sourced reads), the Twenty API (CRM reads + writes), a Cloudflare Queue (email send), and Anthropic (doc-search). Every write tool emits a row in run_ledger on the Worker side before the side-effecting call. Read tools emit a row too — token-accounting is a surface-wide invariant, not a write-only one (Principle 2, chapter Design principles).
| Tool | Purpose | Inputs (schema) | Backend | Who can call | Ledger |
|---|---|---|---|---|---|
erp_read_accounts | List or look up Sodiwin customer accounts | {client_code?, name_like?, limit?: 50} | D1 | All | yes |
erp_read_orders | Order history for an account or window | {client_code?, since?, until?, limit?: 100} | D1 | All | yes |
crm_list_deals | List Twenty Opportunities, filtered | {stage?, owner?, company_id?, updated_since?, limit?: 50} | Twenty API (GET /rest/opportunities) | All | yes |
crm_get_contact | One Twenty Person + linked Company + custom fields | {person_id} or {email | phone} | Twenty API (GET /rest/people/:id) | All | yes |
crm_upsert_contact | Create or update a Twenty Person | {person: {...}, company?: {...} | company_id?, merge_policy} | Twenty API (POST /rest/people) | Writers | yes |
crm_advance_stage | Move a Twenty Opportunity between stages | {opportunity_id, to_stage, note?} | Twenty API (PATCH /rest/opportunities/:id) | Writers | yes |
crm_add_activity | Log a visit, call, note, email, or photo | {target_type, target_id, type, body, attachments?, at?} | Twenty API (POST /rest/activities) | Writers | yes |
crm_search | Free-form search over Twenty’s schema | {query, scope?, limit?: 20} | Twenty GraphQL (search query) | All | yes |
email_send | Send mail from a Sodimo address | {from, to[], cc?, bcc?, subject, body, attachments?} | CF Queue email_outbox → harness drain | Writers | yes |
email_status | Status of a prior email_send call | {message_id} | D1 (drain service writes acks) | Writers | yes |
ledger_write | Explicit run_ledger row from a Twenty Workflow or other caller | {surface, run_id?, tokens_in?, tokens_out?, cost_eur?, ...} | D1 run_ledger | Service principals | yes (self) |
doc_search_piler | Search the Piler email archive | {query, sender?, since?, until?, limit?: 20} | Piler REST via CF Tunnel | All | yes |
whatsapp_send | Outbound WhatsApp from the Sodimo business number | {to, template_id, params?} | External (Meta Cloud API) | Writers | yes |
“Writers” = rani@, paul@, michel@, jack@, ralph@. “All” includes readers too. Role membership is enforced by CF Access group + the Worker’s own allowlist. Service principals are the Twenty Workflow engine and the harness drain service.
Tool output is capped at 20k tokens per call (Claude’s 25k hard cap minus framing). Over that, the tool returns a summary plus a pagination token; the caller re-invokes with cursor: <token>. crm_list_deals, erp_read_orders, doc_search_piler, and crm_search are the usual suspects.
CRM tools — Twenty API direct
The six crm_* tools call the Twenty API directly — REST for reads and writes, GraphQL for crm_search, webhooks for inbound notifications. The Worker holds a Twenty API token (from Vaultwarden) and attaches it per call; the Cf-Access-Authenticated-User-Email header drives ownership attribution on Twenty-side writes. The earlier hybrid proposal (Postgres-direct via turbular) is superseded — see annex D-170. Tool signatures stay stable across upstream Twenty minor-version bumps because Twenty’s REST API is versioned independently of its schema. See chapter CRM for the Twenty deployment shape and the field model.
How email_send actually works
email_send does not reach Postfix directly. The Worker writes the message to a Cloudflare Queue (email_outbox); a small systemd service on the Framework Desktop polls the queue every 5–10 s, validates the from against the sender allowlist, and pipes the message to Postfix for SMTP delivery. The harness opens no inbound port for this path — the pull-queue is the entire wire.
Four properties depend on this shape: (1) no inbound port on Fedora, (2) offline-tolerant (messages queue up to four days if the harness is down), (3) rate-limited at the queue with a dead-letter queue on retry-exhaustion, (4) one sender-authorization policy regardless of caller. See chapter The harness — email drain service for the drain code and chapter Mail for the Postfix side.
What the Worker does on every call
A single request flows through five steps, in order:
- Cloudflare Access terminates. If the identity is absent or not in
sodimo-*groups, the request is refused at the edge — the Worker never sees it. - Bearer check for Claude.ai clients (see Auth below). Fails closed.
- Authorization against the per-tool allowlist (Writers vs All vs Service principals). Fails closed.
- Ledger pre-write: a
run_ledgerrow lands in D1 before any side-effecting call.run_id,ts,surface,user_id,tool,params_hash. - Dispatch to the backend (D1, Twenty API, CF Queue, external), then ledger post-write updates the same row with
tokens_in,tokens_out,latency_ms,outcome,cost_eur,cost_eur_if_cloud.
The pre-write matters: if step 5 crashes, the attempt is still in the ledger. “Show me every call the AI made last week, including the ones that failed” is one SQL query against run_ledger.
Auth
All sodimo-core traffic terminates at Cloudflare Access. The Access policy requires a Google Workspace identity from sodimo.eu, plus membership in the relevant group (sodimo-writers, sodimo-all, or sodimo-service). The Worker receives the authenticated email in the Cf-Access-Authenticated-User-Email header on every request — that header is the sender attribution used on every ledger row and on email_send’s from resolution.
A separate bearer token is required on top of CF Access for Claude.ai clients, because Claude.ai currently drops the CF Access cookie on subsequent requests after the OAuth handshake (bug tracked upstream — see annex D-171). The bearer token is issued per-user, stored in Vaultwarden, rotated per D-062, and checked by the Worker alongside the Cf-Access-Authenticated-User-Email header. Paperclip and server-side callers use CF Access alone; no bearer token.
Not yet a tool
Open items, ordered by when they are likely to activate:
erp_write_commande— push a confirmed deal back to Sodiwin. Blocked on Christian Semat confirming whether Sodiwin exposes a REST/GraphQL endpoint (route 1) or the FTP+CSV fallback (route 2). See chapter CRM — Sodiwin integration vector.ledger_read— queryrun_ledgeras a tool. Deferred until someone asks; the dashboard (chapter Dashboard) covers the headline queries for now.voice_transcribe— out of scope for the engagement. Flagged in Principle-3 inventory walk and dropped.skill_dispatch— explicit skill-invocation tool for cross-agent composition. Deferred pending Paperclip’s scheduled-skill shape.delta_alert_write— create aDeltaAlertrow on a Twenty Company. Blocked on D-126 (delta definition from Rani).- Tiered recouvrement send — which tier is a Twenty-Workflow send vs a Worker-side Claude-drafted send. See chapter Open decisions.
Where this lives
- Worker source:
sodimo/mcp, TypeScript on Cloudflare Workers, one file per tool undersrc/tools/. - Deploy:
wrangler deployfrom CI on tag push; pinned bindings inwrangler.toml. - Access policy:
cloudflare-accessappsodimo-core, Google Workspace IdP, group-gated. - Run ledger: D1 database
sodimo-ledger, tablerun_ledger(schema in chapter Design principles, Principle 2).