What is Tests as Code?
Normally you build evaluators in the Cekura dashboard and then run them. Tests as Code lets you skip that: write your test cases in a JSON file, commit it alongside the agent it tests, and run it straight from CI. Nothing in the file becomes an evaluator in your workspace. Your evaluator list stays exactly as you left it.Why Use It?
Tests live with the code
A change to your agent’s prompt and the tests that cover it land in the same pull request, reviewed together.
Full history
Your version control shows who changed which test case and why — including on branches, where dashboard evaluators cannot follow.
No dashboard setup
New environment, new branch, fork of the repo: the tests come with it. Nothing to recreate.
Same results
Runs appear in Results exactly like any other run, with the same metrics, transcripts, and reports.
Use dashboard evaluators when non-engineers author or review tests, and when you want them reusable across scheduled jobs. Use Tests as Code when the tests belong to a codebase. They work side by side — one agent can have both.
Quickstart
1. Create a spec file
Save this ascekura.tests.json in your repository:
$schema line is optional but gives you autocomplete and inline validation in most editors.
The file says what to test. Which agent to run it against, and how to reach that agent, are supplied by the request — so the same file works unchanged across agents, projects and environments.
2. Check it
Validate without running or spending anything:personality.mode tells you which form was resolved — existing (referenced by ID), override (a base plus your changes), inline (defined from scratch), or project_default. Use it to confirm the file did what you meant before spending anything.
3. Run it
Drop?dry_run=true and the runs start:
id against the Results API for progress and success rate.
The request
POST /test_framework/v1/scenarios/run_scenarios_json/
Authenticate with X-CEKURA-API-KEY. The key’s own scope resolves the project, so no project_id is needed.
* Send exactly one of
spec or file.
This endpoint is also reachable at POST /test_framework/v2/scenarios/run_scenarios_json/, which behaves identically; existing integrations on either path keep working.
Uploading the file instead of embedding it
Avoids$(cat …) quoting entirely, which is handy in shells where escaping gets awkward:
Writing a spec file
Structure at a glance
agent_id, no channel, no credentials, no provider ids. Those are request parameters or agent configuration — see Choosing the run target. A file containing agent_id is rejected, so the split stays honest.
The envelope
A test case
*
instructions is required for instruction cases; conditional_actions cases need conditional_actions and language instead.
Metrics must already exist
A spec references metrics, it cannot define them. Create them in the dashboard first, then point at them by numeric ID or by slug:Personalities
Three forms, depending on how much control you want:- Use an existing one
- Adjust an existing one
- Define one from scratch
name, prompt, language, voice_id, voice_model, provider, speed, background_noise, end_call_enabled.
accent is not in that list and a spec that sets it is rejected: the caller is synthesised from voice_id alone, so accent is a property of the voice and is reported back as a read-only label. Set voice_id to a voice that has the accent you want.
Personalities defined inline are used for that run only. No personality is created in your workspace — your personality list is untouched. Cekura manages the underlying voice and transport configuration, so a spec cannot reach those internals.
Test data
Reference a saved test profile, or supply the data inline:agent_variablesreach the agent under test as dynamic variables at call time.caller_variablesare context for the simulated caller only, and never sent to your agent. Reference them ininstructionsas{{test_profile.<key>}}.
Defaults
Anything repeated across cases can move up intodefaults:
defaults field by field. That includes metrics: a test case that sets its own metrics list replaces the default list rather than adding to it.
Pinning a phone number
By default a voice run uses the least-used number from your pool, so the Cekura-side number can differ from run to run. Setphone_number when a case needs a fixed one — for example when your agent whitelists caller IDs, or when your agent places the call and must be told which number to dial (a handoff or transfer flow):
+ followed by country code and digits). Anything else is rejected at scenarios[i].phone_number before any call is placed. Like the other case fields, phone_number can go in defaults to apply to every case in the file, and it is included when you export existing evaluators that have a number configured.
Choosing the run target
Two request parameters decide where and how a spec runs — neither belongs in the file:elevenlabs, LiveKit credentials for livekit_v2, Pipecat Cloud credentials for pipecat_v2, or a WebSocket URL (and optional Basic Auth) for chirp. If the agent isn’t configured for the channel you pick, the run is rejected with a message naming what’s missing.
For livekit_v2, pipecat_v2 and chirp, you can pass connection overrides in the request via livekit_data, pipecat_data or chirp_data respectively. These overrides apply only to the current run and are not part of the portable spec — keeping the committed file environment-agnostic while letting you target a specific agent deployment at request time.
Running against a CHIRP WebSocket agent
Achirp run needs no phone number, so it works for an agent that is reachable only over its WebSocket endpoint. If the endpoint changes between runs — for example a tunnel (Cloudflare Tunnel, ngrok) started fresh in each CI job — pass the current URL in chirp_data.websocket_url instead of updating the agent:
chirp_data, the run uses the WebSocket URL saved on the agent. Basic Auth credentials always come from the agent. If the agent has Basic Auth set, the URL must be wss://. Both the dry run and the validate endpoint reject a missing URL, or Basic Auth over plain ws://, before any call is placed.
Run overrides
Everything that describes a test case lives in the file: instructions, metrics, personality, test data, and the Cekura-sidephone_number. Settings that describe this run go on the request instead, so one committed file can run against different numbers or load levels without edits:
agent_numberreplaces the agent’s configured number for this run — for example to test a staging line with the same agent. It is your agent’s number; the case-levelphone_numberis Cekura’s side of the call.outbound_phone_numbersets the number Cekura dials when your agent receives calls. If you only passagent_number, Cekura dials that. For an agent that places calls, choose the Cekura number it dials withphone_numberon the test case instead;outbound_phone_numberis rejected there.frequencymultiplies each case’s ownfrequency: a case with"frequency": 2run withfrequency: 3runs 6 times. The dry run’stotal_runsand estimated cost include it.concurrency_limitwins overdefaults.concurrency_limitin the file.modeapplies to agents that receive calls.different_numberspicks the Cekura number for every call, so it can’t be combined with a test case’sphone_number.mock_tool_namesworks as it does on evaluator runs. Sendmock_tool_names: []to mock no tools; leave it out to mock every tool configured on the agent.
personality_ids and test_profile_ids are not accepted here. Neither are scenarios, tags or folder_path — the spec is the list of test cases. Sending any of them with a value returns a 400 that says where the setting belongs, rather than silently ignoring it; empty placeholders such as [] or null are ignored.
A dry run runs the same checks as the real run, so a rejected override, missing channel credentials, or a missing LiveKit agent_name or Pipecat pipecat_agent_name fails the dry run too. It echoes the overrides it applied under plan.run_overrides, and the numbers the run will use under plan.agent_number and plan.outbound_phone_number, so you can confirm them before placing any calls.
Reading validation errors
Every problem in a spec is reported at once, keyed by its location in the file, so one round trip tells you everything to fix:Using it in CI
Runs are asynchronous: the run call returns once they are queued, before anything is dialled. A CI step has to wait for the result to finish and then fail the job unless the runs passed.cekura run spec does both: --wait polls the result until it is completed, failed, timeout or cancelled, and --fail-on-threshold 1.0 exits non-zero unless every run passed. See Result statuses and gating for what each status and count means if you call the API directly instead.
--channel picks the target (voice by default, text, or elevenlabs). Lower --fail-on-threshold (e.g. 0.9) to allow some runs to fail; a result that ends failed, timeout or cancelled fails the job either way. The GitHub Actions, GitLab and Bitbucket integrations run dashboard evaluators by ID, tag or folder; for a committed spec, use the CLI as above.
Starting from evaluators you already have
If your tests currently live in the dashboard, export them rather than rewriting them by hand: select your evaluators, choose Export, and pick Test suite spec (.json). The file it produces is a valid spec you can commit as-is.Limits
Test cases from a spec are cleaned up automatically once no recent run needs them, and results keep their test-case names regardless. Inline personalities and test data never become rows at all, so nothing accumulates in your personality or test-profile lists.
Getting the schema
The machine-readable schema this endpoint accepts:$schema for editor autocomplete, or feed it to a JSON Schema validator to check specs locally before they reach CI.
The same document is published at a stable URL, which is what the $schema line in the examples above points at: