Testing apps with emulation
Emulation lets you test a Dasha application end-to-end without a real phone call and without touching real backends. In one conversation.execute() call you can:
- Simulate the human on the other end of
#connect— an audio-to-audio voice model plays a persona you describe with a prompt (it speaks, listens, can send DTMF tones, and hangs up). - Simulate your backend — every external function,
#httpRequest, and SDK dynamic tool your app calls is answered either by a canned response you script, or by an LLM driven by a single backend-simulation prompt.
Your application code is unaware it is under test: it runs the same DSL, the same recognition pipeline, and sees the same tool list as in production.
Prerequisites
@dasha.ai/sdk0.15.0 or newer (theemulationoption onexecute).- The
test_mode_enabledfeature flag enabled for your account (andvp_a2a_emulation_enabledfor the emulated human). Contact support to enable them. Without the flags, theemulationoption is ignored and calls behave exactly as in production.
Quick start: two prompts
import * as dasha from "@dasha.ai/sdk"; const app = await dasha.deploy("./app"); await app.start(); const conv = app.createConversation({ endpoint: "emulated" }); const result = await conv.execute({ emulation: { // 1. The human: the i-th #connect in your app consumes the i-th entry. connections: [ { prompt: "You are Maria, a busy customer calling about a double charge on your card. " + "Answer briefly, get impatient if asked to repeat, hang up once resolved.", voice: "verse", maxDuration: 180, }, ], // 2. The backend: every tool/HTTP call not explicitly listed below is // answered by an LLM with this prompt plus the call's name and arguments. defaultToolEmulationPrompt: "You are Acme Bank's backend (CRM, billing, calendar). Return realistic, " + "consistent JSON. Maria (customer id 1042) has a duplicate $59.90 charge " + "from 2026-06-28 that is refundable.", }, }); // Assert however you like — with your test runner or an LLM judge: console.log(result.output); console.log(result.transcription.map(t => `${t.speaker}: ${t.text}`).join("\n")); await app.stop(); app.dispose();
Scripting connections
connections is an ordered array: the first #connect/#connectSafe in your app consumes connections[0], the second consumes connections[1], and so on. An unscripted #connect (more connects than entries) fails the conversation with a clear error, so tests are always explicit.
Each entry is either an emulated human or a scripted failure:
connections: [ // An emulated human: { prompt: "You are ...", // required — the persona voice: "verse", // optional voice id model: "gemini/gemini-3.1-flash-live-preview", // optional — provider routing, see below timeToOpen: 2, // optional: simulated ring/answer latency, seconds maxDuration: 180, // optional: hard cap on the call, seconds tools: { finish: true, dtmf: true }, // optional: allow hanging up / sending DTMF (default: both true) }, // Or a scripted connection failure: { fail: { reason: "Busy", statusCode: 486, description: "line busy" } }, ]
reason is one of ServiceError | ConnectionError | NoAnswer | NotAvailable | Busy. Note that #connectSafe stops the dialog on a failed connection — use #connect and check isSessionOpened if your test needs to observe the failure.
Choosing the voice model
The emulated human is driven by an audio-to-audio model, routed by the model prefix:
gemini/<model>— Google Gemini Live, e.g.gemini/gemini-3.1-flash-live-previewopenai/<model>— OpenAI Realtime, e.g.openai/gpt-realtime-2.1-mini
model is optional and defaults to gemini/gemini-3.1-flash-live-preview — Gemini Live is a cheaper alternative to OpenAI Realtime. An unprefixed value (e.g. gpt-realtime-2) routes to OpenAI.
{ prompt: "You are ..." } // default — Gemini { prompt: "You are ...", model: "openai/gpt-realtime-2.1-mini" } // OpenAI Realtime { prompt: "You are ...", model: "gemini/gemini-3.1-flash-live-preview" } // Gemini (explicit)
The emulated human hears your app's speech, replies with its own voice (your app's STT/VAD process it like a real caller), can send DTMF tones your app receives via the normal DTMF path, and hears DTMF your app sends.
Simulating tools and HTTP
Anything your app calls — external functions, #httpRequest, and dynamic tools registered via setDynamicTool — can be simulated. Two mechanisms compose:
The backend-simulation prompt (main flow)
Set defaultToolEmulationPrompt and every call not explicitly listed is answered by an LLM. The model receives your prompt plus the call name and arguments and must reply with JSON, which becomes the call's result. For #httpRequest the reply becomes the response body (status 200), unless the model deliberately returns a { status, body } envelope.
defaultToolEmulationPrompt: "You are the backend. Return realistic data. ...", defaultToolEmulationOptions: { model: "openai/gpt-4o-mini", temperature: "0" }, // optional
The synth model defaults to dasha/reflex-2; override it (or temperature etc.) with defaultToolEmulationOptions.
To constrain a specific call's output shape, give it a JSON schema — the LLM then runs in schema-constrained mode for that call:
externals: { getCalendar: { schema: { type: "array", items: { type: "object", properties: { day: { type: "number" } } } } }, }
Hardcoded outcomes (determinism / cost reduction)
List specific calls to script them exactly — these never hit the LLM:
externals: { getSocks: { data: 2 }, // canned result getOpts: { exception: new Error("boom") }, // the call raises this error in the DSL getCalendar: { delay: 1000, data: [/* ... */] }, // delayed canned result chargeCard: "passthrough", // run the REAL handler you registered }, http: { "https://foo.dasha.ai/**": "passthrough", // real HTTP request (glob patterns: * and **) "https://bar.dasha.ai/**": { status: 500, body: "Help me" }, }, dynamicTools: { bookMeeting: { data: "Booked for Tuesday 3pm" }, // same grammar for setDynamicTool tools },
Resolution order for each call: explicit entry → defaultToolEmulationPrompt LLM synth (if set) → the real handler / real HTTP → error. Dynamic tools keep their production declarations — your agent's LLM sees the exact same tool list; only execution is simulated.
Asserting on the result
result.transcription (who said what, including the emulated human) and result.output (your DSL's output variables) are the assertion surface:
expect(result.output.refundConfirmed).toBe(true); const humanSaid = result.transcription.filter(t => t.speaker === "human").map(t => t.text).join(" "); expect(humanSaid).toMatch(/thank/i);
For semantic checks, feed the transcript to your own LLM judge (e.g. "did the agent confirm the refund amount?" → pass/fail).
Safety
Emulation only activates when the conversation runs with the emulation option. Production conversations are unaffected: without both, #connect, external functions, HTTP requests, and dynamic tools behave exactly as always.