Volter World

Shape the world for a test

The record your test needs, the call that has to fail.

This page is executed as written by packages/cli/src/journeys/tutorials.test.ts; the recording is made from the same run.

Shape the world for a test, recorded

There are two kinds of thing a test wants from a vendor, and Volter has one rule for each:

If the twin stores it, create it through the vendor's own API. Everything else, judgment, lookups, faults, is a handler.

Data: create it through the API

A customer, an issue, a channel, a file: anything the vendor stores, the twin stores, and the way to put it there is the way your app would. In a test's setup, with the vendor's SDK:

const customer = await stripe.customers.create({ email: 'ada@example.com' });

The record is real to the twin: it has an id, it appears in lists, it can be updated and deleted, and the twin enforces the vendor's rules about it. A record the vendor would refuse is refused here too, which is the point. For state every test shares, put it in the seed instead; see seed and reset.

Behavior: write a handler

Some things a vendor does are not records. A model's answer, a search's ranking, a rate limit, a timeout, a 500 on the third call: these are judgment and faults, and the twin has no way to know what your test wants them to be. A handler tells it.

This page's app talks to Anthropic, and asks it questions with a small script:

{ "name": "agent", "private": true, "dependencies": { "@anthropic-ai/sdk": "^0.30" } }
ANTHROPIC_API_KEY=
import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, maxRetries: 0 });
try {
  const message = await anthropic.messages.create({ model: 'claude-sonnet-4-5', max_tokens: 64, messages: [{ role: 'user', content: process.argv[2] }] });
  console.log(200, message.content.find((c) => c.type === 'text')?.text ?? '');
} catch (error) {
  console.log(error.status, error.error?.error?.type ?? error.message);
}
npm install
npm install -g @volter/world
npm install -D @volter/twin-anthropic
volter world init

Handlers live in .volter/handlers/<vendor>.json, one file per twin; init copied the anthropic twin's starter there. The grammar is small: match a request, respond, or inject a fault.

{
  "handlers": [
    { "id": "triage-answer", "on": { "userTextIncludes": "classify this ticket" }, "respond": { "text": "billing" } },
    { "id": "flaky-once", "on": { "userTextIncludes": "summarize" }, "once": true, "fault": { "kind": "status", "status": 529, "message": "Overloaded" } }
  ]
}
  • on is what the handler matches: a phrase in the user's text, a tool the caller offered, a path, a query. Each twin documents its matchers at GET <twin-url>/twin.
  • respond is what the twin answers: text, or a tool call, in the vendor's own shape.
  • fault instead of respond injects a failure: { "kind": "status", "status": 529 } answers the vendor's own error envelope with that status, { "kind": "slow", "ms": 3000 } delays, { "kind": "drop" } holds the socket.
  • once fires the handler one time and then lets the twin's default behavior through, which is how a test gets "fails, then succeeds".

Handlers are data, not code, so they are deterministic, diffable and committed with the world. A twin reads them when it starts:

volter world up --no-seed
acme-web  1 twin up

The scripted answer, where the handler matches:

volter world run -- node ask.mjs "classify this ticket: my card was charged twice"
200 billing

The fault, once, then the default behavior, which for a generative twin is a labeled deterministic stub. The script sets maxRetries: 0 to show the fault; with the SDK's default retries on, it would retry the 529 and print the stub on the second try, exactly as production code would, which is what a once-fault is for: it exercises the retry path.

volter world run -- node ask.mjs "summarize the thread"
529 overloaded_error
volter world run -- node ask.mjs "summarize the thread"
200

See what matched

volter world run -- sh -c 'curl -s "$ANTHROPIC_TWIN_URL/twin/scenario"'
"id":"triage-answer","matches":1
"id":"flaky-once","once":true,"matches":1

A miss is a request no handler matched, and the recent ones carry enough of the request to write the handler you were missing. Unmatched asks are also in the world's log, so volter world log shows them in order with everything else the app did.

volter world down

Time

A test that needs "thirty days later" sets the world's clock rather than waiting or mocking Date. Every twin stamps records from it: volter world clock advance 30d. See seed and reset.

Faults that are not handlers

Some faults are the twin's own surface, because the vendor's are. A twin in --read-only mode refuses every write with the vendor's own refusal shape, which exercises your error paths. A vendor with rate limits enforces them as the vendor documents them. Each twin's README says which of these it models.

Playback

Each command above, as the recording shows it.

npm install

step 1

npm install -g @volter/world

step 2

npm install -D @volter/twin-anthropic

step 3

volter world init

step 4

volter world up --no-seed

step 5

volter world run -- node ask.mjs "classify this ticket: my card was charged twice"

step 6

volter world run -- node ask.mjs "summarize the thread"

step 7

volter world run -- node ask.mjs "summarize the thread"

step 8

volter world run -- sh -c 'curl -s "$ANTHROPIC_TWIN_URL/twin/scenario"'

step 9

volter world down

step 10