Day 9 — 'werbos goes outbound.'
Sprint Soc.1 days 1-4: external integrations layer. Four commits, +148 tests, three working bridges (Telegram, Slack, WhatsApp via Twilio) plus the SocialAdapter Protocol that lets a fourth (Twitter) drop in next sprint. werbos can now be reached from any of three platforms, with the same skills / personas / mirror slider / private mode / vision pipeline working over each — the adapter is the wire-format layer; the chat behavior doesn't know which platform it's talking through. Plus the design pattern: protocol first, runtime second, mirroring how the orchestration plane shipped.
“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:
SocialAdapterProtocol (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.InboundMessagedataclass — platform / sender_id / sender_name / text / attachments / received_at / raw. The normalized shape every adapter produces.Attachmentcarriesimage_b64as a sidecar so the existingUserMessage.imagesvision pipeline drops in unchanged.SocialIdentityStoreSQLite 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-tenanttenant_id/user_idcolumns mirror tasks/skills/budgets — the hosted SKU is a default-value change, not a refactor.SocialMessageRouterglues 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.
- pairing prompt,
KIND_SOCIAL_INBOUNDaudit event emitted on paired inbound. METADATA ONLY:{platform, sender_id_prefix (6-char), sender_name, chars_in, attach_count, received_at}. Same compliance shape asKIND_USER_PROFILE_LOADEDandKIND_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 theX-Telegram-Bot-Api-Secret-Tokenheader set atsetWebhookregistration time. - Polling (dev): when no public URL is configured, a background
task long-polls
getUpdatesand 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
SocialAdapteris 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_INBOUNDcarries the same shape (path + sha256 + size, never the body) as Tom’sKIND_TOM_ATTESTATIONand the user.md’sKIND_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_threadwraps blockingurlopencalls 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.