“the adapter is the wire-format layer; the chat behavior doesn’t know which platform it’s talking through.”

Day 8 broadened the surface area in four directions; Day 9 picks one direction and goes deep. werbos can already chat over CLI, REPL, the React SPA, and werbos serve’s HTTP/MCP surface. Sprint Soc.1 (Phase 12 — external integrations) extends that reachability to where people actually are: Telegram, Slack, WhatsApp via Twilio — three of four platforms shipped today, with the fourth (Twitter/X) gated on a paid API tier and queued for next session.

The architecture mirrors how the orchestration plane shipped a week ago: protocol first, runtime second. One SocialAdapter Protocol in werbos/integrations/base.py; three platform implementations plug in with no shared state beyond what the protocol mandates. The core insight: all existing werbos surfaces work over any adapter for free — the adapter only translates the wire format. Skills auto-fire on the user’s platform message just like in the SPA. /see works (well — DM us a photo and the vision pipeline describes it). The mirror slider, persona ladder, /private mode, tool dispatch, audit chain, recorder events — all of it. The adapter doesn’t re-implement chat behavior; it just translates “this message arrived from Slack channel D012345” into “run a chat turn for this operator and tell me the reply text.”

Day 1 — Foundation (3802e3e)

Seven hours of architecture before any platform-specific code shipped. The deliverables:

  • SocialAdapter Protocol (runtime-checkable, structural). Six methods: name, parse_inbound, send_outbound, register_routes, start, stop. Strict + narrow on purpose — anything platform- specific (OAuth flows, signature verification, attachment download URLs, retry policy) lives inside the adapter, not in the protocol.
  • InboundMessage dataclass — platform / sender_id / sender_name / text / attachments / received_at / raw. The normalized shape every adapter produces. Attachment carries image_b64 as a sidecar so the existing UserMessage.images vision pipeline drops in unchanged.
  • SocialIdentityStore SQLite at ~/werbos/social.db. One row per (platform, sender_id) tuple carrying chat_id, paired_at, paired_secret_hash, blocked, last_msg_at. Pairing codes live in a sister table with TTL + one-shot semantics. Multi-tenant tenant_id/user_id columns mirror tasks/skills/budgets — the hosted SKU is a default-value change, not a refactor.
  • SocialMessageRouter glues adapters + store + SessionRegistry. Decision tree: blocked → silent drop, unknown sender → placeholder
    • pairing prompt, /pair <code> → redeem, paired → lazy session- create + step. Errors during step return a friendly “I hit an error” reply rather than raising.
  • KIND_SOCIAL_INBOUND audit event emitted on paired inbound. METADATA ONLY: {platform, sender_id_prefix (6-char), sender_name, chars_in, attach_count, received_at}. Same compliance shape as KIND_USER_PROFILE_LOADED and KIND_TOM_ATTESTATION — envelope verifiable, contents private.

53 tests pinning every branch.

Day 2 — Telegram (6eacaeb)

The reference implementation. Bot API is the simplest of the four: free token from @BotFather, no business verification, no payment. Two delivery shapes:

  • Webhook (production): Telegram POSTs Update objects to POST /api/social/telegram/webhook. Authenticated via the X-Telegram-Bot-Api-Secret-Token header set at setWebhook registration time.
  • Polling (dev): when no public URL is configured, a background task long-polls getUpdates and feeds the same parser. Lets you develop without ngrok.

Image attachments work end-to-end: Telegram sends a list of size variants; the adapter picks the largest, calls getFile, downloads the bytes, base64-encodes, and drops them into Attachment.image_b64. Pair with --vision-model llama3.2-vision:11b on werbos serve and the bot can describe photos you send it. 28 tests; HTTP I/O via stdlib urllib.request + asyncio.to_thread, no httpx dependency added.

Day 3 — Slack (d265b9b)

A different security shape. Slack signs every event POST with the operator’s signing secret over v0:<timestamp>:<body> (HMAC-SHA256 hex). The adapter verifies in constant time and rejects timestamps older than five minutes — replay defense. Plus the URL-verification handshake quirk: Slack pings the events endpoint once at app config time with {type: "url_verification", challenge: "..."}; the route handler echoes the challenge as plain text.

Scope at v1 is DMs (message.im) and @-mentions in channels (app_mention). Bot self-loops are filtered three ways: event.bot_id, event.user == cached bot_user_id, event.subtype == "bot_message". Bot-mention tags get stripped from text before routing — both <@U_BOT> and <@U_BOT|werbos> forms — so the model sees the operator’s actual question, not a mention envelope it would have to ignore. 37 tests.

A slack-app-manifest.yml ships in the repo so a fresh install is “paste this YAML into api.slack.com/apps → Create New App → From a manifest” then copy the bot token + signing secret out and feed them to werbos serve. End-to-end setup is ~10 minutes including manifest paste.

Day 4 — WhatsApp via Twilio (f4f66fb)

Form-encoded webhook (application/x-www-form-urlencoded, not JSON), HMAC-SHA1 signature (base64-encoded) over URL + sorted(key+value).... Different security envelope from both prior adapters. The URL itself is part of the signed string, so the configured webhook URL has to match exactly what Twilio calls — down to https:// and trailing slash.

The adapter strips Twilio’s whatsapp: prefix from the inbound From field so the identity row’s sender_id is the bare E.164 phone number (+15551234567). The adapter re-adds the prefix when calling Twilio for outbound. Cleaner identity rows + lets a future SMS adapter share the same phone number under a different platform slug.

Sandbox-ready in five minutes (free) — operator texts join <code> to Twilio’s sandbox number once, then conversations work. Production WhatsApp Business API is a Twilio Senders flow that takes weeks of Meta WABA approval, but the runtime contract is identical to sandbox: only the from_number env var changes. 30 tests; new docs/integrations/whatsapp-twilio-setup.md walks the operator end-to-end.

How they all fit

werbos chat / chat REPL / SPA
                  │
                  ▼
          ChatSession.step
                  ▲
                  │
        SocialMessageRouter
        ├─ SocialIdentityStore (~/werbos/social.db)
        └─ adapters: { telegram: …, slack: …, whatsapp: … }
                  ▲
                  │
   webhook routes mounted on werbos serve:
   /api/social/telegram/webhook    (X-Telegram-Bot-Api-Secret-Token)
   /api/social/slack/events        (HMAC-SHA256, v0:ts:body)
   /api/social/whatsapp/webhook    (HMAC-SHA1, URL + sorted params)

The adapters never import each other. Each one is roughly 300-470 lines of self-contained translation: parse the wire format, run the router, send the reply. The router doesn’t know which platform’s message it’s handling beyond the platform slug on the InboundMessage. Every werbos chat surface — character + voice + user.md + skills + history + vision — composes the same way it does for CLI users.

AHAs

  • The protocol absorbs the differences. Three adapters, three signature schemes (static header / HMAC-SHA256 / HMAC-SHA1), three body formats (JSON / JSON / form-encoded), three auth schemes (no auth / Bearer / Basic). Yet SocialAdapter is six methods. The wire-format heterogeneity goes inside the adapter where it belongs; the rest of werbos sees a uniform pipe.
  • Pairing-on-first-contact is the security floor, not the ceiling. Anyone who finds your bot can DM it, but they get the pairing prompt instead of access. Codes are TTL’d (15 min), one-shot, and stored as SHA-256 hashes. Plus per-(platform, sender) rate limits via warden Alerts in Day 6. The default fails closed.
  • The audit envelope generalizes across surfaces. KIND_SOCIAL_INBOUND carries the same shape (path + sha256 + size, never the body) as Tom’s KIND_TOM_ATTESTATION and the user.md’s KIND_USER_PROFILE_LOADED. Auditors get a chain they can verify without reading content. The compliance story is one shape; every new surface plugs into it.
  • Form-encoded vs JSON is a real architectural decision. Twilio’s choice of form-encoded webhooks isn’t legacy cruft — it fits how their platform handles voice (TwiML XML responses, form-encoded callbacks). Building one adapter that has to handle BOTH wire formats would have been ugly; building three adapters with one each is clean. The protocol let us defer the choice per-adapter.
  • stdlib urllib.request is enough. Three adapters, zero httpx / aiohttp dependency. asyncio.to_thread wraps blocking urlopen calls to keep the event loop free. Same pattern the search tool, watchtower dispatch consumer, and dogfood replay agent use. The dependency footprint stayed at zero.

Numbers worth keeping

  • 4 commits: 3802e3e, 6eacaeb, d265b9b, f4f66fb.
  • +148 integration tests: 53 (Day 1) + 28 (Day 2) + 37 (Day 3)
    • 30 (Day 4). Full sweep at 2472 passing (4 pre-existing schedule calendar-rot failures, unrelated).
  • 0 new runtime dependencies. Stdlib only.
  • 3 of 4 platforms shipped end-to-end. Twitter/X queued for Soc.2 (paid API tier).
  • Adapter line counts: Telegram ~470, Slack ~400, WhatsApp ~430. Foundation ~600 (base + store + router + mock). Total ~1900 lines for Phase 12 v1.