diff --git a/AGENTS.md b/AGENTS.md
index cd6aea5..ef430fd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -120,7 +120,12 @@ Translation is a fixed rulebook (`src/matrix/twilio-voice.json`), not a guess.
(`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.
+ there is no static markup to transpile. Run the adapter with
+ `ADAPTER_CAPTURE_DIR=
` and place a few test calls; each customer TwiML
+ response is written there verbatim (content-addressed, deduped). Then
+ `npm run generate -- ` produces standalone BXML for the paths those
+ calls exercised. Capture only covers exercised paths — branches you never dial
+ won't appear, so drive every flow you care about (or port the rest by hand).
## Errors
diff --git a/README.md b/README.md
index d1bf433..b801244 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,7 @@ npm run doctor # readiness check — see AGENTS.md Phase 5
| `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), provisioned via `band` — see [`AGENTS.md`](AGENTS.md) |
| `BW_ENVIRONMENT` | Optional — `test` targets BW's test hosts; defaults to `prod` |
+| `ADAPTER_CAPTURE_DIR` | Optional — dir to persist each customer TwiML response (verbatim, content-addressed) so `generate` can turn the paths a test call exercised into standalone BXML. Essential for SDK-built apps with no static TwiML to transpile. Off by default |
| `ADAPTER_LOG=1` | Optional — enable request logging |
Run `npm run doctor` (or `GET /readyz?deep=1` once the server is up) to confirm
diff --git a/src/server/app.ts b/src/server/app.ts
index b80a6e3..c37d7de 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -23,6 +23,7 @@ import { CallStore, type CallRecord } from "./call-store.js";
import { isSafeBwId, type BwClient } from "../bw/client.js";
import { checkReadiness } from "./readiness.js";
import { safeEqual } from "./safe-equal.js";
+import { captureTwiml } from "./capture.js";
export interface AdapterConfig {
accountSid: string;
@@ -36,6 +37,8 @@ export interface AdapterConfig {
/** Basic-auth credentials Bandwidth presents on inbound webhooks (must match the app's CallbackCreds). */
webhookUser: string;
webhookPassword: string;
+ /** Opt-in dir to persist each customer TwiML response for later `generate`. Undefined → no capture. */
+ captureDir?: string;
}
export interface AdapterDeps {
@@ -155,6 +158,16 @@ export function buildApp(config: AdapterConfig, deps: AdapterDeps): FastifyInsta
}
throw err;
}
+ // Capture the raw customer TwiML (URLs verbatim, pre-rewrite) before we
+ // translate it — this is exactly what `generate` ingests to produce
+ // standalone BXML for the paths a test call exercised.
+ if (config.captureDir) {
+ try {
+ captureTwiml(config.captureDir, twiml);
+ } catch (err) {
+ app.log.error({ err }, "twiml capture failed");
+ }
+ }
const translateStart = performance.now();
const result = translateTwiml(twiml, {
rewriteUrl: rewriter(customerUrl),
diff --git a/src/server/capture.ts b/src/server/capture.ts
new file mode 100644
index 0000000..9291480
--- /dev/null
+++ b/src/server/capture.ts
@@ -0,0 +1,30 @@
+import { mkdirSync, writeFileSync, existsSync } from "node:fs";
+import { createHash } from "node:crypto";
+import { join } from "node:path";
+
+/**
+ * Persist a raw TwiML response the adapter fetched from the customer app, so a
+ * later `npm run generate` over the capture dir can turn the paths a test call
+ * actually exercised into standalone BXML.
+ *
+ * The filename is a content hash — never request input — so identical responses
+ * dedupe to one file and untrusted input never participates in filesystem
+ * addressing (same guardrail as scripts/capture-server.mjs). We store the raw
+ * TwiML verbatim (customer URLs intact, no proxy rewrite), which is exactly what
+ * `generate` ingests.
+ *
+ * Written private-by-default (dir 0700, file 0600): raw TwiML can carry callback
+ * URLs with embedded tokens, so captures are not world-readable. Synchronous I/O
+ * is deliberate — this is an opt-in, test-call-volume eval feature on local disk,
+ * and serial writes keep the dedup check (existsSync → writeFileSync) race-free
+ * within the process; we accept the event-loop cost over async write races.
+ *
+ * Returns the path written, or the existing path if this TwiML was already seen.
+ */
+export function captureTwiml(dir: string, twiml: string): string {
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
+ const hash = createHash("sha256").update(twiml).digest("hex").slice(0, 16);
+ const file = join(dir, `${hash}.xml`);
+ if (!existsSync(file)) writeFileSync(file, twiml, { mode: 0o600 });
+ return file;
+}
diff --git a/src/server/index.ts b/src/server/index.ts
index ec3a456..87f916f 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -19,6 +19,7 @@ const app = buildApp(
webhookPassword: env("WEBHOOK_PASSWORD"),
allowPrivateEgress: process.env.EGRESS_ALLOW_PRIVATE === "1",
egressAllowHosts: process.env.EGRESS_ALLOW_HOSTS?.split(",").map((s) => s.trim()).filter(Boolean),
+ captureDir: process.env.ADAPTER_CAPTURE_DIR,
},
{
fetchImpl: fetch,
diff --git a/test/capture.test.ts b/test/capture.test.ts
new file mode 100644
index 0000000..1a228a3
--- /dev/null
+++ b/test/capture.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync, readdirSync, readFileSync, statSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { captureTwiml } from "../src/server/capture.js";
+
+const TWIML = `Hello`;
+
+let dir: string;
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "capture-test-"));
+});
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe("captureTwiml", () => {
+ it("writes the raw TwiML verbatim (no added bytes) to a file under the capture dir", () => {
+ const file = captureTwiml(dir, TWIML);
+ expect(readFileSync(file, "utf8")).toBe(TWIML);
+ });
+
+ it("writes captures private (0600 file, 0700 dir)", () => {
+ const file = captureTwiml(dir, TWIML);
+ expect(statSync(file).mode & 0o777).toBe(0o600);
+ expect(statSync(dir).mode & 0o777).toBe(0o700);
+ });
+
+ it("names the file from the content hash only, never request input", () => {
+ const file = captureTwiml(dir, TWIML);
+ // A content-addressed hex name — no path, params, or caller-supplied string.
+ expect(file.split("/").pop()).toMatch(/^[0-9a-f]{16}\.xml$/);
+ });
+
+ it("dedupes identical TwiML to a single file", () => {
+ captureTwiml(dir, TWIML);
+ captureTwiml(dir, TWIML);
+ captureTwiml(dir, TWIML);
+ expect(readdirSync(dir)).toHaveLength(1);
+ });
+
+ it("writes distinct files for distinct TwiML", () => {
+ captureTwiml(dir, TWIML);
+ captureTwiml(dir, `Goodbye`);
+ expect(readdirSync(dir)).toHaveLength(2);
+ });
+
+ it("creates the capture dir if it does not exist", () => {
+ const nested = join(dir, "does", "not", "exist");
+ const file = captureTwiml(nested, TWIML);
+ expect(readFileSync(file, "utf8")).toContain(TWIML);
+ });
+});
diff --git a/test/server-capture.test.ts b/test/server-capture.test.ts
new file mode 100644
index 0000000..522dbbd
--- /dev/null
+++ b/test/server-capture.test.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { buildApp } from "../src/server/app.js";
+
+const webhookAuth = "Basic " + Buffer.from("u:p").toString("base64");
+
+function baseConfig(captureDir?: string) {
+ return {
+ accountSid: "AC123",
+ authToken: "tok",
+ publicBaseUrl: "https://adapter.test",
+ voiceUrl: "https://customer.test/voice",
+ allowPrivateEgress: true,
+ webhookUser: "u",
+ webhookPassword: "p",
+ captureDir,
+ };
+}
+
+function appWithTwiml(config: ReturnType, twimlByUrl: Record) {
+ const fetchImpl = vi.fn(async (url: any) => {
+ const twiml = twimlByUrl[String(url)];
+ if (!twiml) return new Response("not found", { status: 404 });
+ return new Response(twiml, { status: 200 });
+ }) as unknown as typeof fetch;
+ const bwClient = { createCall: vi.fn(), modifyCall: vi.fn(), getCall: vi.fn(), listRecordings: vi.fn(), getRecording: vi.fn(), getRecordingMedia: vi.fn(), updateRecording: vi.fn() };
+ return buildApp(config, { fetchImpl, bwClient });
+}
+
+async function initiate(app: ReturnType, callId: string) {
+ return app.inject({
+ method: "POST",
+ url: "/bw/initiate",
+ headers: { authorization: webhookAuth },
+ payload: { eventType: "initiate", callId, from: "+1", to: "+2", direction: "inbound" },
+ });
+}
+
+let dir: string;
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "server-capture-"));
+});
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe("live capture", () => {
+ it("writes the customer's raw TwiML (not the translated BXML) when captureDir is set", async () => {
+ const raw = `Hello`;
+ const app = appWithTwiml(baseConfig(dir), { "https://customer.test/voice": raw });
+ await initiate(app, "c-1");
+
+ const files = readdirSync(dir);
+ expect(files).toHaveLength(1);
+ const content = readFileSync(join(dir, files[0]), "utf8");
+ expect(content).toContain(raw);
+ // The captured artifact is the source TwiML, not the proxy's BXML output.
+ expect(content).not.toContain("SpeakSentence");
+ });
+
+ it("preserves the customer's URLs verbatim (no proxy rewrite) so generate can reuse them", async () => {
+ const raw = `Press 1`;
+ const app = appWithTwiml(baseConfig(dir), { "https://customer.test/voice": raw });
+ await initiate(app, "c-2");
+
+ const content = readFileSync(join(dir, readdirSync(dir)[0]), "utf8");
+ expect(content).toContain(`action="/menu"`);
+ expect(content).not.toContain("/bw/continue");
+ });
+
+ it("still serves the call when capture fails (unwritable target)", async () => {
+ // Parent path is a file, so mkdir under it throws — capture must not fail the call.
+ writeFileSync(join(dir, "blocker"), "x");
+ const app = appWithTwiml(baseConfig(join(dir, "blocker", "sub")), {
+ "https://customer.test/voice": `Hello`,
+ });
+ const res = await initiate(app, "c-4");
+ expect(res.statusCode).toBe(200);
+ expect(res.body).toContain("Hello");
+ });
+
+ it("writes nothing when captureDir is unset", async () => {
+ const app = appWithTwiml(baseConfig(undefined), {
+ "https://customer.test/voice": `Hello`,
+ });
+ await initiate(app, "c-3");
+ expect(readdirSync(dir)).toHaveLength(0);
+ });
+});