Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# AGENTS.md — Driving a Twilio→Bandwidth cutover with this adapter

This adapter translates a **live call flow** (Twilio TwiML ⇄ Bandwidth BXML) so a
customer's existing Twilio voice app runs over Bandwidth with a single URL change.
It does **not** provision anything on a Bandwidth account.

## The paired model

Use two tools together:

- **This adapter** — translates the call flow and proxies live calls.
- **The `band` CLI** — executes account-side actions on the user's Bandwidth
account (numbers, applications, service activation). `band` is agent-native:
JSON output by default, `--plain` for stable parsing, `--wait` for async ops,
`--if-not-exists` for idempotency.

An agent runs `band` to provision, then configures and runs this adapter.

## What an agent can and cannot do

**Can do unattended:** preflight analysis, provisioning on an *existing*
Bandwidth account via `band`, adapter configuration, and the readiness check.

**🧍 Human required** (flagged inline below): creating a brand-new Bandwidth
account, enabling the account "HTTP Voice" feature, providing a public HTTPS
host, and final by-ear call verification.

## Runbook

### Phase 1 — Preflight
```bash
npm install
npm run preflight -- <path-to-customer-twilio-app> # complexity + per-file verdict
npm run generate -- <in-dir> <out-dir> # writes out/bxml/*, out/MIGRATION.md, out/coverage.json
```
Read `out/coverage.json` for the machine-readable verdict. If a source file uses
the Twilio SDK to build TwiML at runtime, it is a **dynamic source** — there is no
static markup to transpile. See "Capability boundaries".

### Phase 2 — Provision (via `band`)
This runbook uses the **Universal Platform (VCP)** path, `band`'s default for
new accounts; legacy-platform accounts provision a SIP peer instead via the
`--legacy` subcommands (see `band quickstart --help`) and skip the
`vcp create`/`vcp assign` steps below.
```bash
band auth login # BW_CLIENT_ID / BW_CLIENT_SECRET; --plain for JSON. Needs a keychain backend on headless hosts.
band subaccount create --name "<name>" --if-not-exists
band number search --area-code <ac> --quantity 1
band number order <number> --subaccount <site-id> --wait
band app create --name "<app>" --type voice --callback-url "<PUBLIC_BASE_URL>/bw/initiate" --if-not-exists # PUBLIC_BASE_URL already includes the https:// scheme
band vcp create --name "<vcp>" --app-id <app-id> --if-not-exists
band vcp assign <vcp-id> <number>
band number activate <number> --voice-inbound --wait
```
🧍 **Human required:** if the account is brand-new, `band account register` needs
email/SMS OTP first. If `band app create` errors that the account lacks the "HTTP
Voice" feature, a human must request it from Bandwidth support — the CLI cannot
enable it.

### Phase 3 — Configure the adapter
Set these env vars (the server reads **these exact names** — note the checked-in
`.env` uses different, non-functional names):

| Var | Who sets it |
|---|---|
| `ADAPTER_ACCOUNT_SID`, `ADAPTER_AUTH_TOKEN` | agent (adapter's own Twilio-compat creds) |
| `CUSTOMER_VOICE_URL` | agent (the customer's unchanged Twilio app URL) |
| `WEBHOOK_USER`, `WEBHOOK_PASSWORD` | agent — Basic-auth creds Bandwidth presents on inbound `/bw/*` webhooks; **must match the `CallbackCreds` set on the BW Voice Application in Phase 2** |
| `PUBLIC_BASE_URL` | 🧍 **Human required** (public HTTPS host/tunnel) |
| `BW_ACCOUNT_ID`, `BW_CLIENT_ID`, `BW_CLIENT_SECRET`, `BW_APPLICATION_ID` | from Phase 2 |

> Non-loopback deploys: the listen host defaults to `127.0.0.1`; set `HOST=0.0.0.0` (or a specific interface) to expose the adapter behind your `PUBLIC_BASE_URL`.

### Phase 4 — Deploy
🧍 **Human required:** provide a public HTTPS host (or tunnel) for
`PUBLIC_BASE_URL`. The BW Voice Application's callback (set in Phase 2) must point
at `<PUBLIC_BASE_URL>/bw/initiate`.

### Phase 5 — Verify
```bash
npm run doctor # full local gate: which env is set AND whether the BW token exchange works. Exit 0 = ready.
curl -s localhost:3000/readyz | jq # running-server config/liveness check (env presence only; no token probe, no secrets)
```
🧍 **Human required:** place a real call to the Bandwidth number and walk the IVR
by ear. There is no scripted end-to-end call check.

## Capability boundaries

Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess.

- **Supported:** most core voice verbs — Say, Play, Gather, Pause, Hangup,
Redirect, Record, Number, Sip (translate cleanly to BXML). A few of these
still drop attributes safely rather than silently: `Say`/`Play`'s
`loop="0"` (infinite) can't be expressed inline in BXML, so it emits once and
warns; `Record`'s `transcribe` and `playBeep` attributes have no direct BXML
equivalent.
- **Lossy / partial (feature loss — needs a human decision):**
- `Reject` — maps to `Hangup`, but Bandwidth answers before hanging up, so the
caller may be billed for a short call.
- `Dial` — `Number`/`Sip` nouns map to `Transfer`, `Conference` noun maps to
`Conference`; the `Queue` and `Client` nouns are unsupported, and deep Dial
semantics (`answerOnBridge`, child-call status propagation) are not
replicated.
- `Start` — the `Transcription` noun maps to `StartTranscription`; the
`Siprec` and `VirtualAgent` nouns are unsupported.
- `Refer` — Bandwidth only honors `Refer` on inbound SIP URI calls, so a PSTN
call leg cannot be REFER'd (a platform constraint, not a translation gap).
- `Connect` — the `Stream` noun maps to `StartStream` via the Media Streams
bridge; `ConversationRelay` and `VirtualAgent` are unsupported (separate
IoV).
- `Stream` — Twilio's WS message schema is emulated by the adapter's stream
bridge; live Bandwidth-side binding requires fixture capture.
- `Conference` — basic named conferences work, but `waitUrl` hold music has
no Bandwidth equivalent, `beep` is only partially supported, and
`startConferenceOnEnter`/`endConferenceOnExit`/`maxParticipants` have no
verb-level equivalent — those are managed via the Bandwidth Conferences
**REST API**, not the BXML verb.
- **Unsupported (no BXML equivalent — a business decision to drop/redesign):**
`Enqueue`, `Leave`, `Queue`, `Client`, `Pay`. Bandwidth has no queue primitive
(`Enqueue`/`Leave`/`Queue` fail loudly), no WebRTC client endpoint
(`Client`), and PCI payment capture (`Pay`) is out of scope for the adapter.
- **Dynamic SDK-built TwiML:** if the customer app generates TwiML at runtime,
capture live responses and re-run `generate`, or port the logic by hand.

## Errors

Operational failures return a Twilio-shaped JSON body: `{ code, message, more_info, status }`.
Adapter-specific codes use a private range and are documented here:

| code | status | meaning |
|---|---|---|
| 90001 | 400 | Missing required request parameter |
| 90002 | 500 | Internal adapter error (detail is in server logs, not the response) |
| 90003 | 4xx | Malformed request rejected before handling |
| 90004 | 400 | Request parameter present but failed validation (e.g. an unsafe identifier) |

Twilio-API-compat errors (e.g. `401`/`404` on the `/2010-04-01/...` facade) keep
their Twilio codes and semantics.

One exception: a request to a path with **no route at all** (e.g. the removed
number-provisioning endpoints — use `band` instead) returns the framework's
default `404` body, not a Twilio-shaped one. Only routed operations go through
the structured-error path above.
68 changes: 38 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Run an existing Twilio voice app on Bandwidth's network — without rewriting it.** Point your app at the adapter, change one URL, and its calls now run on Bandwidth. The code never changes.

> **Status:** Inbound/outbound calls, call control, recordings, and the two key webhooks are working and proven on real calls. Number **search** is live-verified; number **ordering/release** are experimental (see [Number lifecycle](#number-lifecycle)). 265 automated tests, typecheck clean.
> **Status:** Inbound/outbound calls, call control, recordings, and the two key webhooks are working and proven on real calls, with an automated test suite and clean typecheck. This adapter does not order or manage Bandwidth numbers itself — pair it with the [`band` CLI](#pairs-with-the-band-cli) for account-side provisioning.

---

Expand All @@ -19,6 +19,18 @@ Same concepts, different words — like British vs. American English. This adapt

The translation is a fixed rulebook driven by a single [compatibility matrix](src/matrix/twilio-voice.json), not an AI guessing — for live calls, "mostly right" isn't good enough. When a customer uses something the adapter *can't* do yet, it **says so and stops**, rather than breaking the call silently.

### Pairs with the `band` CLI

This adapter only translates the **call flow** — it does not touch a customer's
Bandwidth account. Account-side actions (buying a number, creating a Voice
Application, activating voice on a number) are a separate concern, handled by
the [`band` CLI](https://dev.bandwidth.com/tools/cli/). The two pair naturally: an
agent runs `band` to provision the account, then configures and runs this
adapter to carry the calls. If you're an agent driving an end-to-end cutover,
start with **[`AGENTS.md`](AGENTS.md)** — it's the phased runbook with the
exact `band` commands, the env vars this adapter reads, and every step that
still needs a human.

---

## Capabilities
Expand Down Expand Up @@ -46,12 +58,9 @@ The translation is a fixed rulebook driven by a single [compatibility matrix](sr
| Recording callback (`recordingStatusCallback`) | ✅ | Unit |
| Media Streams bridge (AI-voice path) | ✅ | Unit |

### Number lifecycle
| Operation | Status | Verified |
|---|:--:|:--:|
| OAuth2 auth · **Search** | ✅ | **Live** |
| Order · Release | ⚠️ | Experimental — not verified ([details](#number-lifecycle)) |
| Activate (route a bought number) · Update | ❌ | — not built |
Number provisioning (search/order/activate) isn't part of this adapter — it's
handled by the [`band` CLI](#pairs-with-the-band-cli); see the Phase 2 runbook
in [`AGENTS.md`](AGENTS.md).

---

Expand All @@ -72,13 +81,14 @@ npm run preflight -- examples/full-pv-app
```
Prints a migration-complexity score and a per-feature *works-as-is / heads-up / blocker* breakdown.

**Run the translation loop locally (no credentials):** see [`docs/demo.md`](docs/demo.md) §1–§2 — the customer's sample app + the adapter + simulated Bandwidth webhooks, all on localhost. §4 covers the recording/status callbacks and number search.
**Run the translation loop locally (no credentials):** see [`docs/demo.md`](docs/demo.md) §1–§2 — the customer's sample app + the adapter + simulated Bandwidth webhooks, all on localhost. §4 covers the recording/status callbacks.

**Run the adapter** (Node 20+):
```bash
npm start # adapter on :3000
npm test # 303 tests
npm test # run the test suite
npm run typecheck
npm run doctor # readiness check — see AGENTS.md Phase 5
```

| Env var | Meaning |
Expand All @@ -90,12 +100,14 @@ npm run typecheck
| `EGRESS_ALLOW_HOSTS` | Optional comma-separated host allowlist; when set, outbound fetches are restricted to exactly these hosts (default-deny) |
| `PUBLIC_BASE_URL` | Public HTTPS base of this adapter |
| `CUSTOMER_VOICE_URL` | The customer's Twilio voice webhook (inbound calls) |
| `BW_ACCOUNT_ID` / `BW_CLIENT_ID` / `BW_CLIENT_SECRET` / `BW_APPLICATION_ID` | Bandwidth credentials (OAuth2 client-credentials) — shared by Voice and the number facade |
| `BW_SITE_ID` / `BW_PEER_ID` | Optional — enable number ordering (search/release use the shared `BW_CLIENT_*` creds) |
| `BW_NUMBERS_BASE_URL` | Optional — override the Numbers API v2 base (tests/staging) |
| `BW_ACCOUNT_ID` / `BW_CLIENT_ID` / `BW_CLIENT_SECRET` / `BW_APPLICATION_ID` | Bandwidth credentials (OAuth2 client-credentials), provisioned via `band` — see [`AGENTS.md`](AGENTS.md) |
| `BW_ENVIRONMENT` | Optional — `test` targets BW's test hosts; defaults to `prod` |
| `ADAPTER_LOG=1` | Optional — enable request logging |

Run `npm run doctor` (or `GET /readyz?deep=1` once the server is up) to confirm
this env is set and the Bandwidth OAuth2 token exchange works before routing
real calls.

---

## REST facade
Expand All @@ -111,11 +123,11 @@ Backend calls the Twilio SDK makes are served in Twilio's shape (under `/2010-04
| `GET /Recordings/{sid}.json` | `recordings(sid).fetch()` | recording metadata |
| `GET /Recordings/{sid}.{mp3,wav}` | recording media URL | audio, stream-proxied from Bandwidth |
| `POST /Calls/{sid}/Recordings/{recSid}.json` | `recordings(sid).update({status})` | pause / resume (`stopped` fails loudly — no BW REST stop) |
| `GET /AvailablePhoneNumbers/{Country}/Local.json` | `availablePhoneNumbers(c).local.list()` | search inventory — **verified live** |
| `POST /IncomingPhoneNumbers.json` | `incomingPhoneNumbers.create()` | order — ⚠️ **experimental, not verified** |
| `DELETE /IncomingPhoneNumbers/{sid}.json` | `incomingPhoneNumbers(sid).remove()` | release — ⚠️ **experimental, not verified** |

Unknown calls/recordings return Twilio's `20404`; bad credentials return `20003`. Call/recording state is in-memory (single-instance): a recording must be listed before it can be fetched by SID on a fresh instance.
Unknown calls/recordings return Twilio's `20404`; bad credentials return `20003`. Call/recording state is in-memory (single-instance): a recording must be listed before it can be fetched by SID on a fresh instance. Adapter-specific operational failures (missing params, internal errors) use a separate private code range — see [`AGENTS.md#errors`](AGENTS.md#errors).

This facade does **not** include number search/order/release — those routes
were removed in favor of the `band` CLI (see [`AGENTS.md`](AGENTS.md) Phase 2).

### Callbacks to your app (egress)

Expand All @@ -124,16 +136,6 @@ The adapter also calls *you* back in Twilio's shape, mapped from Bandwidth's eve
- **Status callback** — when a call ends, the `StatusCallback` set on `calls.create()` gets a Twilio-shaped `completed` callback (`CallSid`, `CallStatus`, `CallDuration`), mapped from Bandwidth's disconnect event.
- **Recording callback** — a `<Record recordingStatusCallback="…">` maps to Bandwidth's `recordingAvailableUrl`; when the recording is ready the adapter forwards a Twilio `recordingStatusCallback`, with `RecordingUrl` pointing back at this facade so a later fetch resolves through the adapter.

### Number lifecycle

Shares the platform OAuth2 client-credentials with the Voice API (one token; the account's roles decide what it can do). **Verified live (2026-06-19):**

- **Search** — ✅ verified. OAuth2 token exchange and `GET /accounts/{id}/availableNumbers` both confirmed against real Bandwidth, returning real inventory.
- **Order** (`POST /IncomingPhoneNumbers.json`) — ⚠️ experimental. The v2 JSON order endpoint rejects the current request body; the real order schema must be captured (e.g. from the `band` CLI) before this works. Needs `BW_SITE_ID`.
- **Release** (`DELETE`) — ⚠️ experimental. The real disconnect endpoint returns XML, not JSON; the client needs an XML path.

Reproduce the live check (read-only by default) with [`scripts/verify-numbers-live.ts`](scripts/verify-numbers-live.ts).

---

## What it can't do yet
Expand All @@ -142,7 +144,10 @@ Reproduce the live check (read-only by default) with [`scripts/verify-numbers-li
- **Conference hold music** — no Bandwidth equivalent.
- **Speech recognition** — translated correctly, but must be enabled on the Bandwidth account.
- **Stopping a recording via REST** — Bandwidth pauses/resumes over REST but has no REST *stop* (`StopRecording` is a BXML verb), so `Status=stopped` fails loudly rather than silently.
- **Number ordering / release / activate** — see [Number lifecycle](#number-lifecycle).
- **Number provisioning** — this adapter doesn't order, activate, or otherwise manage Bandwidth numbers; use the `band` CLI (see [`AGENTS.md`](AGENTS.md) Phase 2).

See [`AGENTS.md`](AGENTS.md) for the full unsupported/lossy verb breakdown and
the private error codes this adapter raises.

---

Expand All @@ -154,11 +159,14 @@ All translation is driven by one declarative compatibility matrix (`src/matrix/t
src/translator TwiML → BXML translation
src/twilio Twilio-shaped REST facade + signed webhooks (egress)
src/streams Media Streams bridge
src/numbers number-lifecycle facade (search/order/release)
src/server the proxy (Fastify) wiring it together
src/server the proxy (Fastify) wiring it together, readiness check (/readyz, npm run doctor)
src/preflight static migration-complexity report
src/generate batch BXML generation + coverage report (see AGENTS.md Phase 1)
web Migration Preflight playground (double-click HTML demo)
scripts build-playground.ts + benches / live-verify helpers
scripts build-playground.ts + benches
```

Tests live in `test/` (Vitest); CI gates `npm run typecheck` + `npm test`.

For driving an end-to-end cutover (including the account-side `band` steps and
every point a human still needs to be in the loop), see [`AGENTS.md`](AGENTS.md).
Loading