Skip to content

feat: add SignalWire, ElevenLabs, Vapi and Pipecat adapters; remove twilio_adapter shim - #14

Merged
howethomas merged 9 commits into
mainfrom
thomashowe/con-703-restore-adapters
Sep 26, 2026
Merged

howethomas merged 9 commits into
mainfrom
thomashowe/con-703-restore-adapters

Conversation

@howethomas

Copy link
Copy Markdown
Contributor

Adds the SignalWire, ElevenLabs, Vapi and Pipecat adapters to the monorepo, built on the current core/ patterns: fail-closed webhook validation, lawful basis, the media publisher and draft-04 output. They existed only as parked work-in-progress based on an older main; this rewrites them against today's code rather than copying the old versions, which had no shared config, no lawful basis, no media publisher and still used mimetype. Also removes the dead twilio_adapter/ shim and brings in the FreeSWITCH end-to-end harness.

Adapters

Adapter Transport Authentication Notes
Vapi Webhook (end-of-call-report) Shared secret in x-vapi-secret, constant-time compare (Vapi server authentication docs) Builds the vCon from the whole report, so it uses vcon-lib directly rather than the single-recording BaseVconBuilder
ElevenLabs Webhook (post_call_transcription, post_call_audio) elevenlabs-signature: t=<ts>,v0=<hex>, HMAC-SHA256 over "{ts}.{body}" A webhook, not the polling approach in the parked version. The signed-string format follows the documented header shape but has not been checked against a real signed request; verify before production. A mismatch fails closed. The two webhook types each produce a vCon, tagged with a shared conversation_id
Pipecat In-process observer n/a VconConversationObserver collects turns from pipeline frames; pipecat-ai is imported lazily. python main.py pipecat serves /health only
SignalWire Poller API credentials Uses core.tracker.StateTracker: a call is marked processed only when the POST succeeds, and a persisted watermark never moves past a call still waiting to send. The parked version marked calls processed on a failed POST and advanced the window regardless, so failed calls were never retried

Every webhook adapter validates by default and refuses to start without its secret unless ALLOW_UNSIGNED_WEBHOOKS=true. Every builder takes the lawful basis and media publisher from config, writes mediatype, and emits draft-04 attachments. All four are registered in main.py and the README config tables.

Other changes

  • twilio_adapter/ removed. Nothing imports it. Seven tests patched twilio_adapter.* paths, so their mocks never applied; they now patch adapters.twilio.*. Dockerfile and packaging updated.
  • tests/e2e/freeswitch/: the FreeSWITCH end-to-end harness (mock conserver, ESL, dialplan and Lua configs, test scripts). Needs a live FreeSWITCH, so it is excluded from the default pytest run by its own conftest.py. A machine-specific setup script was generalised.
  • SignalWire's duration arrives as a numeric string; it is now converted to a number (found by schema validation).

Tests

  • Fresh venv, pip install -e ".[dev,s3]": ruff check . and black --check . clean; pytest 621 passed (539 on main).
  • New: config and builder tests for all four; signature tests for Vapi and ElevenLabs (valid, bad, missing header, missing secret refuses to start, opt-out); Pipecat observer tests; a SignalWire poller suite covering dedupe, watermark, API errors and failed POSTs.
  • One sample vCon per adapter (LAWFUL_BASIS=consent, MEDIA_BACKEND=filesystem) validates against the vCon working group schema.

Not in this PR

  • The parked branch also restructured the Twilio adapter to add messaging (SMS, MMS, WhatsApp, RCS), fax, video and Conversations API webhooks. That is new behaviour and needs the same validation and lawful basis treatment; it is left for a separate change.
  • ElevenLabs timestamp-age (replay) checking.

Refs CON-703

🤖 Generated with Claude Code

howethomas and others added 9 commits September 25, 2026 18:41
The top-level twilio_adapter/ package was a backwards-compatibility layer
superseded by adapters/twilio/. Nothing on main imports it (verified by
grep); the top-level tests/test_{config,builder,webhook}.py already target
adapters.twilio, not this shim. Also drops it from the Dockerfile COPY and
pyproject's packaging/coverage config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Webhook-based: VAPI posts an end-of-call-report server message per call.
Authenticated via constant-time comparison of the shared secret VAPI
echoes back in x-vapi-secret; fails closed like every other webhook
adapter (config raises at startup unless the secret is set or
ALLOW_UNSIGNED_WEBHOOKS=true). VAPI also offers HMAC and OAuth auth with a
configurable header; this implements the simpler, documented shared-secret
header, not the HMAC variant (see README).

Wired to core.lawful_basis and core.media_publisher; ADAPTER_SOURCE
attachments use mediatype (draft-04), not mimetype. Not registered against
core.base_builder.BaseVconBuilder: VAPI's payload carries the whole
conversation (per-turn messages, transcript, analysis) in one event rather
than one recording per event, so the builder is written directly against
vcon-lib following the same conventions instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In-process observer, not a webhook receiver: Pipecat runs as a library
inside the caller's own voice-AI pipeline process, so there is no inbound
request to authenticate. VconConversationObserver accumulates per-turn
state via frame-processor hooks and builds a vCon on end-of-conversation;
as_frame_processor() wraps it as a real pipecat FrameProcessor, importing
pipecat-ai lazily so the rest of the monorepo doesn't need it installed.

`python main.py pipecat` starts a health-check-only FastAPI app so the
adapter registers and containerizes consistently with the others.

Wired to core.lawful_basis and core.media_publisher like the other new
adapters; written directly against vcon-lib rather than
core.base_builder.BaseVconBuilder since a Pipecat conversation is
multi-turn text plus one optional audio dialog, not a single recording
download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Webhook-based, not a poller: ElevenLabs Conversational AI sends
post_call_transcription and post_call_audio as independent webhook calls,
authenticated with an HMAC signature in elevenlabs-signature. The earlier
rescue/cursor-multi-mode-wip draft polled GET /convai/conversations
instead; the documented webhook is the more direct integration (no poll
lag, richer payload), so this version receives it directly like every
other webhook adapter here, and fails closed the same way (config raises
at startup unless the secret is set or ALLOW_UNSIGNED_WEBHOOKS=true).

Signature format caveat: ElevenLabs' public docs confirm the header name
and that it's an HMAC signature with a timestamp, but not the exact
signed-string byte format. This implements the widely-used
t=<timestamp>,v0=<hex_hmac_sha256> convention the header shape resembles;
flagged in the README as unverified against a real signed request. A
mismatch fails closed (rejects), it does not silently accept.

Because the two webhook types arrive independently, this builds one vCon
per webhook rather than merging them, tagged with a shared
conversation_id for downstream correlation. Wired to core.lawful_basis and
core.media_publisher; written directly against vcon-lib rather than
core.base_builder.BaseVconBuilder for the same reason as the VAPI/Pipecat
builders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Poller, not a webhook receiver: SignalWire's Compatibility API has no
"recording complete" push, so this polls GET /Recordings.json on an
interval. SIGNALWIRE_AUTH_TOKEN is outbound Basic Auth to SignalWire's own
API, not a webhook secret, so there is no VALIDATE_*_WEBHOOK setting here.

Fixes the dedupe/watermark bug an audit found in the standalone
rescue/cursor-multi-mode-wip draft: that poller marked a call processed
unconditionally after attempting delivery, including when the POST to the
conserver failed, and separately advanced its poll watermark to now() every
cycle regardless of outcome -- so a failed delivery was dropped silently,
and even if that weren't true, the failed call would fall out of the query
window on the very next poll and never be retried at all. This version
only marks a call processed (via core.tracker.StateTracker) once
HttpPoster.post() returns True, and persists a watermark
(<state_file>.watermark) that never advances past a call still pending, so
a failed build or post leaves that call inside the next poll's fetch
window until it succeeds.

One vCon per call with one dialog per recording (SignalWire can return
several recordings per call in one poll), wired to core.lawful_basis and
core.media_publisher, written directly against vcon-lib rather than
core.base_builder.BaseVconBuilder for the same multi-dialog reason as the
other three new adapters. `python main.py signalwire` runs the poller on a
background thread behind a /health endpoint, matching the container
healthcheck the other adapters get for free from being webhook receivers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds each adapter's run_*_adapter() to main.py's ADAPTERS registry so
`python main.py <name>` and the Docker image (ENTRYPOINT/CMD unchanged)
work for all four. Documents each in the README's platform list, adds a
config-table section per adapter (required vars, validation defaults,
signature-scheme caveats), updates the source tree, notes the webhook
vs. poller vs. in-process split in the "Webhook authentication" section,
and adds their env vars to .env.example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…patch targets

Adds config/builder/webhook (or observer/poller) tests for the four new
adapters: signature valid/bad/missing-header cases and missing-secret
refusal for the two webhook adapters (vapi, elevenlabs), lawful-basis
set/unset for every new builder, and the SignalWire poller's dedupe and
watermark behavior -- specifically that a failed build or a failed POST to
the conserver leaves the call unmarked and inside the next poll's fetch
window rather than being silently dropped (the CON-703 audit finding).

Also fixes six tests in tests/test_builder.py and one in
tests/test_webhook.py that patched twilio_adapter.builder.requests.get /
twilio_adapter.webhook.VconBuilder -- the deleted shim module, not
adapters.twilio (which is what create_app() and TwilioVconBuilder.build()
actually use). Deleting the shim surfaced this: those patches had no effect
on the code under test, so the tests were passing without actually
exercising the mocked HTTP call. Retargeted to
adapters.twilio.builder.requests.get and
adapters.twilio.webhook.TwilioVconBuilder.

Baseline (origin/main): 539 passed. After this change: 619 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from a private vcon-freeswitch-tester repo (cloned read-only to a
scratch dir, reviewed file by file, deleted after copying). One line in
setup_mac_mini.sh naming a specific machine and its LAN IP/username was
redacted to a generic comment (setup_local.sh here); everything else
(mock_conserver.py, the FreeSWITCH ESL/dialplan/Lua configs, and the
test_*.sh/test_ws_client.py scripts) only referenced localhost, Homebrew's
own /opt/homebrew paths, FreeSWITCH's documented default ESL password
("ClueCon", not a real credential), and already-fake phone numbers matching
the rest of this repo's fixtures.

Excluded from the default pytest run via collect_ignore_glob in
tests/e2e/freeswitch/conftest.py: it needs a live FreeSWITCH + Redis + this
repo's own adapter running locally, which CI doesn't have, and
test_ws_client.py imports websockets, which isn't a project dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Schema-validated a sample SignalWire vCon (LAWFUL_BASIS=consent,
MEDIA_BACKEND=filesystem) against the WG JSON schema and found
recording.get("duration") was passed straight through as the string
SignalWire's Recordings.json actually returns (e.g. "30"), failing
validation on dialog[0].duration. Coerces to float, logging and omitting
the field on anything unparseable rather than raising. Adds regression
tests for both the coercion and the omit-on-failure path.

Also fixes ruff/black findings in tests/e2e/freeswitch/ (an f-string with
no placeholders, a bare `raise` inside an except that should chain `from
None`, formatting) so `ruff check .` / `black --check .` stay clean across
the whole repo even though this directory is excluded from the pytest run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@howethomas
howethomas merged commit 5b05aad into main Sep 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant