State & scenarios
Everything on this page uses one running example: a checkout mock with three transactions — POST /login remembers
who is calling, POST /cart/items appends to their cart, and POST /checkout moves them to Paid and counts an
order for everyone. Callers are told apart by their Authorization header.
Overview
Section titled “Overview”{ "stateConfig": { "sessionKey": { "source": "header", "name": "Authorization" }, "variables": [ { "name": "userName", "type": "text", "scope": "caller", "initial": "guest" }, { "name": "cartItems", "type": "list", "scope": "caller", "initial": [] }, { "name": "cartCount", "type": "counter", "scope": "caller", "initial": 0 }, { "name": "orderCount", "type": "counter", "scope": "everyone", "initial": 0 } ] }}POST /logincaptures the request’susernameintouserName, scoped to that caller.POST /cart/itemsappends the item tocartItemsand countscartCountup, both scoped to that caller.POST /checkoutmoves the caller’s flow fromHasCarttoPaidand countsorderCountup for everyone.
Alice calls /login with {"username": "alice"}, then /cart/items twice, then /checkout. Bob does the same at
the same time. Each of them sees only their own userName and cartItems; orderCount is one shared number both
checkouts add to.
The rest of this page builds up to that example one concept at a time:
- Where state lives — durability, replicas, and what leaves the mock’s pod.
- Variables & starting values — declaring
userName,cartItems,cartCount. - Remember values — capturing
usernameintouserName. - Transforms — cleaning up a captured value before it’s stored.
- JavaScript snippets — custom logic in a transform step.
- Using values in responses — rendering
userNameandcartItemsback out. - Counters —
cartCountandorderCount. - Cases — branching a response on state or the request.
- Only match when… — gating a whole transaction on a condition.
- Flows —
HasCart → Paid. - Response sequences — a fixed script of responses over repeat calls.
- CRUD resources — generating a create/read/update/delete set in one click.
- Try it — running a request against this state safely, before or after you save.
- Inspect, clear, back up & restore — the State tab, and the API underneath it.
- Limits & latency — every cap that applies.
- Agents — the same capabilities from MCP or Loadrigo.
Where state lives
Section titled “Where state lives”kubectl delete pod -n <namespace> -l maxoperf.io/virtual-service-id=vs_123kubectl delete pod -n <namespace> -l maxoperf.io/virtual-service-id=vs_123Do that twice in a row — deleting whichever pod comes back each time — and GET /cart for alice still returns her
cart. State doesn’t live in the mock’s pod at all: it’s stored in MaxoPerf’s own database, shared by every replica of
the virtual service.
Data egress. A stateful request — one whose transaction reads or writes state — sends that request’s headers, cookies, query and body (up to 1 MiB) from the mock’s pod to the MaxoPerf API, including when the mock runs on your own (self-hosted) private datacenter. The values it remembers are stored centrally, in MaxoPerf’s own database and in backups (retention per kind — see Inspect, clear, back up & restore). Raw session keys are never stored, logged or returned — only a keyed hash id and a masked display key. Agents (MCP, Loadrigo) only ever read redacted stored values through their own tools; the one exception is a live Try it, whose rendered response body is the mock’s real public response — exactly what any client calling the mock gets — returned as rendered (its diff stays redacted; see Try it). Stateless transactions send nothing to the API at all, so use them for data that must stay on-prem. This page doesn’t promise request-data minimization.
Access-log volume. Each request to a stateful transaction is one call from the mock to the MaxoPerf API, and that call writes one MaxoPerf API access-log line; a stateless transaction writes none. At a high stateful request rate this adds to the platform’s log volume, not only its database load.
Replicas. Every virtual service deploys with 2 replicas behind one endpoint, so any of them can answer any
caller — they all read and write the same stored values. Each response carries x-maxoperf-vs-replica (the last 5
characters of the pod that answered), so you can see which one you hit. While the virtual service is running,
the header shows Running · 2 replicas and State: durable · shared (both chips are hidden when it isn’t running);
the State tab’s rail always shows Replicas: 2 (desired) with the hint any replica · same data · older outposts run 1. The two replicas are backed by a PodDisruptionBudget (minAvailable: 1) with soft anti-affinity — on a
single-node cluster, that budget still blocks a drain of that node, so stop the virtual service first. Each
running virtual service also spends 2 capacity slots of its private datacenter for as long as it runs: on a
self-hosted datacenter with a container cap set, that’s 2 fewer slots for runner containers per running virtual
service — raise the cap by 2 per virtual service you keep running, or stop idle ones. A datacenter with no room left
fails the deploy with PDC_CAPACITY_EXCEEDED.
Redeploy for old runtimes. A virtual service whose runtime predates this release doesn’t know how to talk to the
state store. The console shows Redeploy to enable durable state and the API reports runtimeSupportsState: false until you redeploy it; a virtual service that hasn’t fetched its config yet (just deploying) reports null
and shows no banner.
Variables & starting values
Section titled “Variables & starting values”{ "stateConfig": { "sessionKey": { "source": "header", "name": "Authorization" }, "sessionIdleTtlSeconds": 1800, "variables": [ { "name": "userName", "type": "text", "scope": "caller", "initial": "guest" }, { "name": "cartItems", "type": "list", "scope": "caller", "initial": [] }, { "name": "cartCount", "type": "counter", "scope": "caller", "initial": 0 }, { "name": "orderCount", "type": "counter", "scope": "everyone", "initial": 0 }, { "name": "maintenance", "type": "flag", "scope": "everyone", "initial": false } ] }}A caller who has never called /login still gets userName = "guest" in a template, a condition, or a counter —
declaring a variable’s initial is enough, whether or not anything has written to it yet.
| Type | Example initial |
|---|---|
text | "guest" |
number | 0 |
flag | false |
list | [] |
object | {} |
counter | 0 |
| Scope | Console name | Meaning |
|---|---|---|
caller | Each caller | one value per caller (alice’s cart is not bob’s) |
everyone | Everyone | one shared value every caller reads and writes |
When a starting value applies: any declared variable with no stored value reads as its initial — in templates,
conditions and counters, for a caller who has never written it, for a variable you add later, and for an
everyone-scoped value until the first write (also again after Clear all). Once something is stored, the stored
value wins. A redeploy keeps everything already stored — nothing resets on deploy.
Telling callers apart. sessionKey names where the caller identity comes from (header, cookie, or query).
Leave it unset (null, the default) and there are no separate callers at all: every request shares the single
Everyone bucket, and session is just an alias for state.
Idle expiry. The console lets you set this in minutes (1–10 080, e.g. “2 hours”); the API field
sessionIdleTtlSeconds takes the same thing in seconds (60–604 800), default 1 800 s (30 minutes) when unset. A
caller idle longer than that is forgotten — within about two minutes after the TTL passes (one minute of slack plus
the once-a-minute sweep that does the forgetting). Any request counts as activity, reads included, so a caller
that sends at least one request per TTL window is never forgotten. Shared (Everyone) values are never expired. Once
a caller is forgotten, GET …/state?caller=<its id> answers 404 VS_STATE_CALLER_NOT_FOUND (console: “This caller
was forgotten — refresh”).
Declaring 200 variables or 256 KiB of starting values is the ceiling — see Limits & latency.
Remember values
Section titled “Remember values”{ "state": { "captures": [ { "from": { "source": "body", "path": "$.username" }, "to": { "scope": "session", "key": "userName" }, "action": "set" } ] }}Alice calls POST /login with {"username": "alice"} → her userName becomes "alice".
Sources (from) — where a captured value comes from:
| Source | Shape | Notes |
|---|---|---|
body | { "source": "body", "path": "$.username" } | JSONPath into the request body |
header / query / path / cookie | { "source": "header", "name": "X-Request-Id" } | a multi-value header/query takes the first value |
template | { "source": "template", "template": "{{request.headers.x-user}}" } | the only source that can combine values before storing them |
value | { "source": "value", "value": 0 } | a literal — any JSON |
from is required for set, append and merge; it’s ignored for increment and delete.
Actions (action) — what happens to the target key, with the console’s own words:
| Console | action | Effect |
|---|---|---|
| Replace | set | overwrites the whole value |
| Add to a list | append | pushes onto an array (creating it if missing) |
| Count up | increment | adds 1 (or the captured number) to a numeric value |
| — | delete | removes the key |
| — | merge | shallow-merges an object into the stored object |
default(optional) is written when the source is missing, or a transform’s result isnull/""(used verbatim — no further transforms run on it).- Up to 20 captures per transaction, run in order, so a later one can read what an earlier one just wrote.
- Key rules: up to 8 dot segments, 256 characters, no control characters.
- A failing capture never blocks the response — the request is still served, and
captures[].reasonin the low-level result says why (see Limits & latency for the full list). - A value or key containing a NUL character or a lone surrogate is never stored (
reason: "value_invalid"). - A request whose fault fires (an injected error or extra latency) stores nothing.
- Think time runs after values are saved, so a slow response doesn’t delay when the write is visible to other callers.
Transforms
Section titled “Transforms”{ "from": { "source": "header", "name": "Authorization" }, "transforms": [ { "kind": "regexReplace", "pattern": "^Bearer\\s+", "replacement": "" }, { "kind": "regexExtract", "pattern": "sess-(\\w+)" } ], "to": { "scope": "session", "key": "sessionSuffix" }, "action": "set"}Authorization: Bearer sess-7f3a → strip the Bearer prefix → extract the part after sess- → stores "7f3a".
Every transform kind, in order, with its step JSON and a one-line example:
| Kind | Step JSON | Input → output |
|---|---|---|
trim | { "kind": "trim" } | " padded " → "padded" |
lowercase | { "kind": "lowercase" } | "MiXeD Case" → "mixed case" |
uppercase | { "kind": "uppercase" } | "MiXeD Case" → "MIXED CASE" |
capitalize | { "kind": "capitalize" } | "jane doe" → "Jane Doe" |
prefix | { "kind": "prefix", "value": "acct-" } | "42" → "acct-42" |
suffix | { "kind": "suffix", "value": "-x" } | "42" → "42-x" |
template | { "kind": "template", "template": "Hi {{value}}!" } | "Ann" → "Hi Ann!" |
regexMatch | { "kind": "regexMatch", "pattern": "^\\d+$" } | "12345" → true (a boolean) |
regexExtract | { "kind": "regexExtract", "pattern": "(\\d+)" } | "abc123def" → "123" |
regexReplace | { "kind": "regexReplace", "pattern": "\\d", "replacement": "X" } | "a1b2c3" → "aXbXcX" |
dateFormat | { "kind": "dateFormat", "format": "YYYY-MM-DD" } | "2024-06-15T12:00:00.000Z" → "2024-06-15" |
numberFormat | { "kind": "numberFormat", "format": "fixed:2" } | "3.14159" → "3.14" |
path | { "kind": "path", "path": "a.b" } | {"a":{"b":"deep"}} → "deep" |
pick | { "kind": "pick", "fields": ["a"] } | {"a":1,"b":2} → {"a":1} |
omit | { "kind": "omit", "fields": ["a"] } | {"a":1,"b":2} → {"b":2} |
join | { "kind": "join", "separator": " | " } | ["a","b","c"] → "a | b | c" |
default | { "kind": "default", "value": "fallback" } | "" → "fallback" |
required | { "kind": "required" } | "present" → "present"; empty input → transform_failed |
javascript | { "kind": "javascript", "inline": "return String(value).toUpperCase();" } | "ab" → "AB" |
- Up to 16 steps per capture, applied in order.
- Objects are JSON-stringified before a text-only step and parsed back after
path,pickoromit. - A
regexMatch/regexExtract/regexReplacestep whose input is over 64 KiB fails (reason: "transform_failed",regex_input_too_large). - Any step failure sets
reason: "transform_failed"— thedefaultis not applied, and the request is still served. See JavaScript snippets for thejavascriptkind’s own rules.
JavaScript snippets
Section titled “JavaScript snippets”// A snippet's body — no wrapper, just return the new value.return String(value).replace(/^Bearer\s+/i, '').toLowerCase();// State-aware: read a stored value alongside the input.return state.session.userName ?? value;A snippet is one more transform step:
{ "kind": "javascript", "inline": "return String(value).toUpperCase();" }or, for a snippet saved in your workspace’s shared library and pinned to a specific revision:
{ "kind": "javascript", "snippetId": "dts-xxxxxxxxxxxx", "snippetRevisionId": "dtr-xxxxxxxxxxxx" }- Signature:
(value, request, state)— the function body only;returnthe new value.requestandstateare read-only (also reachable asrow.request/row.state); the body runs inside its own block, so it may declare its own localstateorrequestvariable without a syntax error. - Scalar in, scalar out, same as a dataset transform — objects arrive as JSON text. To store an object,
return JSON.stringify(obj)and follow with apathstep to parse it back. - A snippet only reads state when its source text mentions
stateorrow.state(a barerow.requestdoesn’t count). A state-reading snippet sees the caller’s whole bucket and the whole Everyone bucket, so its cost scales with their size — keep state-reading snippets for state that stays small. Over 256 KiB combined, the step fails (javascript_state_too_large). - Request body cap: JavaScript sees at most a 256 KiB request body. A step that reads
bodyon a bigger request fails (javascript_request_too_large); every other JS step on that request still runs, just withoutbodyonrequest. helpers.random(),helpers.uuid()andhelpers.nowIso()are deterministic, seed-derived values —nowIso()is not the current time.- Limits: 50 ms execution, 16 MiB memory, inline source ≤ 8 KiB, output ≤ 64 KiB.
- A saved snippet lives in the workspace’s shared library (the same one datasets use — see Test data
transforms) and is pinned by revision; a snippet from another workspace fails validation
(
400 VS_STATE_TRANSFORM_INVALID). - A timeout or a thrown error →
transform_failed. - Preview before you save: in the Behavior tab, each step’s value — JavaScript included — previews from your unsaved chain. Nothing reaches live traffic until you save; a saved edit reaches the running mock within about 2 seconds.
Using values in responses
Section titled “Using values in responses”{ "bodyTemplate": "{\"hello\": \"{{session.userName}}\", \"items\": {{toJson session.cartItems}}}"}Alice’s response body renders as:
{ "hello": "alice", "items": ["sku-1"] }| Root | Reads |
|---|---|
state | the Everyone bucket |
session | the calling caller’s bucket (an alias for state with no session key configured) |
local | values this exchange captured into a local-only scratch scope |
scenario | the caller’s current flow step, by flow name |
request.* | the incoming request (headers, query, path params, body) |
| Syntax | Escaping | Use for |
|---|---|---|
{{session.userName}} | HTML-escaped | a plain scalar |
{{{session.userName}}} | Raw, unescaped | a header value, or anywhere entity-escaping would corrupt it |
{{toJson session.cartItems}} | JSON-escaped | an object or array in a JSON body |
{{toJson value}} and {{values bucket}} help build JSON from a whole bucket or object; {{uuid}} generates a
fresh id inline (deterministic, seed-derived, like the JavaScript helpers).
Scaling note. Name the values you actually use, like session.userName above. A bare root such as {{toJson state}}, or a lookup with a dynamic key, loads the whole bucket on every request, so its latency grows with
how much that bucket holds.
Preview as. In the Behavior tab, the Response panel’s Preview renders your template for either New caller (starting values) (the default) or a live caller you pick by key mask (e.g. …7f3a · alice) — nothing is saved or
sent to live traffic. When some of the previewed values are hidden, it says so: 2 hidden values preview as [REDACTED].
Counters
Section titled “Counters”{ "thenOps": [ { "counter": "cartCount", "scope": "caller", "op": "increase" }, { "counter": "orderCount", "scope": "everyone", "op": "increase", "by": 1 } ]}Alice’s cartCount goes from 0 to 1; the shared orderCount goes from 12 to 13.
Each operation names its own scope (caller or everyone — console ”+ caller” / ”+ everyone”); you don’t
need to declare a counter-typed variable at all, though doing so gives it a console display and a default starting
value.
op | Effect |
|---|---|
increase | adds by (default 1) |
decrease | subtracts by (default 1) |
reset | sets it to op.initial, else the declared variable’s initial, else 0 |
- Counter ops run on every winning response of the transaction — every case and the Otherwise. Put a response that shouldn’t count in its own transaction instead.
- Up to 10 counter ops per transaction.
- An invalid op, scope or
by→400 VS_COUNTER_OP_INVALID. - The
incrementcapture action (in Remember values) still works too — use whichever fits: a capture when the count is tied to one field, a then-op when it should fire on every answer.
{ "cases": [ { "when": [{ "scope": "session", "key": "cartCount", "match": "gt", "value": 0 }], "response": { "status": 200, "bodyTemplate": "{\"order\":\"ord-{{state.orderCount}}\"}" } }, { "when": [{ "source": "header", "name": "x-plan", "match": "equals", "value": "pro" }], "response": { "status": 202 } } ], "response": { "status": 409, "bodyTemplate": "{\"error\":\"cart is empty\"}" }}The first case whose when holds wins; if none does, the transaction’s own response (the Otherwise) answers
instead.
- Up to 10 cases, each with 1–10 conditions in
when(all must hold). - A condition reads either
state/session(viascope+key) or the request (body/header/query/path/cookie). - Response sequences (below) apply to the Otherwise only.
- Cases only exist on mock transactions — a passthrough transaction has nothing to branch between.
- An invalid
casesshape →400 VS_CASES_INVALID.
Only match when…
Section titled “Only match when…”{ "scope": "session", "key": "userName", "match": "present" }A whole transaction can require a condition to hold before it’s even considered a match — not just to pick a case.
match | Meaning |
|---|---|
equals / notEquals | exact value comparison |
contains | substring or array membership |
present / absent | the key does or doesn’t exist |
gt / lt | numeric comparison |
A failing condition doesn’t error — the candidate is skipped and the next one is tried. Only the first 50 transactions whose request matcher fits are ever considered, and that’s never an error either — it just means a very low-priority transaction can be shadowed on a busy mock.
A flow models a multi-step scenario per caller (or globally, with no session key configured):
login— norequiredState(any step) → moves the caller toLoggedIn.cart—requiredState: LoggedIn→ moves toHasCart.checkout—requiredState: HasCart→ moves toPaid.logout— any step → moves back toStarted.
The console calls a scenario a flow and a scenario state a step; Started is every caller’s initial step. A
flow advance is atomic under concurrency — two simultaneous requests from the same caller advance it exactly once,
even under load-testing traffic. The Flow & try it tab draws this as a canvas.
Response sequences
Section titled “Response sequences”{ "responses": [ { "status": 500 }, { "status": 500 }, { "status": 200, "bodyTemplate": "{\"ok\":true}" } ], "afterLast": "repeat_last"}The first call from a caller gets 500, the second 500 again, the third 200 — and every call after that keeps
getting 200 (repeat_last). Set afterLast: "cycle" instead to start back over from the first response.
- 1–20 responses per sequence, tracked per caller (or globally, with no session key configured).
- A sequence can’t be attached to a passthrough transaction — there’s no mocked response to cycle through.
CRUD resources
Section titled “CRUD resources”POST /v1/virtual-services/{id}/crud-resources{ "path": "/orders", "scope": "global", "seed": [ { "id": "o-1", "total": 20 } ] }{ "groupId": "vs_service_…", "transactionIds": [ "…7 ids…" ], "seedAppliedOnReset": true }GET /orders/o-1 answers the seeded item right away — no reset needed.
pathnames the collection endpoint. It must start with/, and its last segment (letters, digits,_and-, starting with a letter or_) becomes the collection’s key in state (orders). A reserved word (__proto__,constructor,prototype,true,false,null, …) →400 VS_CRUD_PATH_INVALID.scopeissession(each caller gets their own collection) orglobal(everyone shares one).seed(optional,globalscope only — otherwise400 VS_CRUD_SEED_SCOPE) pre-populates the collection.
This creates seven transactions, appended as a new group named Resource: /orders (<virtual service name>). With
scope: "session", capture/condition scope is session and the template root is session; with scope: "global",
they’re global and state. A lower priority number is tried first, so row 3 answers 404 for an unknown id
before rows 4–7 are considered. Every response is content-type: application/json.
| # | Method | URL | Priority | State | Response |
|---|---|---|---|---|---|
| 1 | POST | equals /orders | 10 | capture local.id ← template {{uuid}}; set session orders.{{local.id}} ← body $; set session orders.{{local.id}}.id ← template {{local.id}} | 201 {{toJson (lookup session.orders local.id)}} |
| 2 | GET | equals /orders | 10 | — | 200 {{toJson (values session.orders)}} |
| 3 | * | template /orders/${id} | 5 | condition { scope: session, key: "orders.{{request.path.id}}", match: "absent" } | 404 {"error":"not_found"} |
| 4 | GET | template /orders/${id} | 10 | — | 200 {{toJson (lookup session.orders request.path.id)}} |
| 5 | PUT | template /orders/${id} | 10 | set body → orders.{{request.path.id}}; set orders.{{request.path.id}}.id ← {{request.path.id}} | 200 the stored item |
| 6 | PATCH | template /orders/${id} | 10 | merge body → orders.{{request.path.id}} | 200 the stored item |
| 7 | DELETE | template /orders/${id} | 10 | delete orders.{{request.path.id}} | 204 |
They’re ordinary transactions afterward — edit any of the seven the same way you’d edit a hand-written one.
Seed, exactly. A global-scoped seed[] becomes the starting value of an Everyone object variable named
after the collection key: initial = { "o-1": { "id": "o-1", "total": 20 }, … } (an existing id is replaced, other
keys are kept). Because it’s a starting value, it’s served immediately on a collection nothing has written yet —
and again after Clear all, with no separate reset step. If a variable of the same
name already exists with a different type or scope, the call fails with 400 VS_STATE_VARIABLE_INVALID and why: "crud_collection_variable_conflict" (console: “A variable named <name> already exists with a different type or
scope — rename the collection or change that variable”). The seed counts toward the 256 KiB variables total (see
Limits & latency).
Size. The whole collection is one stored value, capped at 1 MiB, rewritten on every create, update or
delete. A POST that would push it over 1 MiB is still answered (its normal status), but carries x-maxoperf-vs- state: capacity and a null item — nothing is stored. For a large or write-heavy collection, use scope: "caller"
so each caller has their own collection, or keep the collection itself small.
Generating a second CRUD resource whose collection key is already used by a Resource: … group on the same virtual
service is rejected with 409 VS_CRUD_EXISTS — give it a different last path segment, or generate it on another
virtual service.
Try it
Section titled “Try it”On the Flow & try it tab, pick a caller (or leave scratch mode on), send POST /login as alice, and watch the
timeline: + userName = "alice" and flow: Started → LoggedIn.
POST /v1/virtual-services/vs_123/try{ "caller": "alice", "request": { "method": "POST", "path": "/login", "body": { "username": "alice" } } }{ "matched": true, "matchedTransactionId": "vstx-0000000003", "response": { "status": 200, "headers": [{ "name": "content-type", "value": "application/json" }], "body": "{\"ok\":true}" }, "notMatchedReason": null, "captures": [{ "index": 0, "applied": true }], "sequenceIndex": null, "caseIndex": null, "diff": [{ "op": "set", "scope": "caller", "key": "userName", "before": "guest", "after": "alice" }], "scenario": { "name": "login", "from": "Started", "to": "LoggedIn" }, "callerId": "aB3xQ…"}Try it comes in two flavors:
- Scratch (the console default): starts from starting values and never touches live state. Under the hood this
is
POST …/sandbox, carrying the previous call’sstateAfterback in as the nextstate— nothing is committed anywhere. - Use live state: reads and writes the real stored state through
POST …/try. Works on a draft, stopped, or running virtual service; needs the editor role.
caller (1–256 characters) is the session-key value — it’s written into the configured session-key header,
cookie or query, replacing any value already there. A virtual service with no transactions yet isn’t an error: both
routes answer normally with matched: false.
Response shape. Both routes return the same base shape: matched, matchedTransactionId, response {status, headers: [{name, value}], body}, notMatchedReason, captures, sequenceIndex, caseIndex, diff[] (scope: "caller" | "everyone"), scenario {name, from, to}. The sandbox additionally returns stateAfter (round-trip it as
the next call’s state); /try has no stateAfter, and adds callerId instead.
The live rendered body is not redacted. diff values, the inspector, and every value route stay redacted — but
response.body from a live Try it (and MCP try_virtual_service with live: true) is exactly what the mock
serves that caller publicly, unredacted. Stored values only reach it through your own response templates, so keep
secret-named values out of a template you intend to render — reference {{session.userName}}, not a token.
Passthrough. A passthrough transaction is never actually forwarded in Try it: it answers 204 with header
x-maxoperf-vs-try: passthrough-not-forwarded, and the timeline shows passthrough — not forwarded in Try it. Its
captures, counters and flow moves still apply, exactly as they would on a real forwarded call.
Try it and the Behavior tab’s JavaScript preview share the same per-pod JavaScript slots as live traffic (see
Limits & latency); a store outage answers 503 (console: “State store unavailable · try
again”). The state a scratch Try it or a Behavior “Preview as” sends is capped at 2 MB — over it, 400 VS_STATE_LIMIT (console: “This caller’s state is too large to preview (max 2 MB)” — preview as a new caller
instead).
Inspect, clear, back up & restore
Section titled “Inspect, clear, back up & restore”The State tab shows what’s stored right now: a variables table, a live callers panel, and a Backups dialog.
curl https://app.maxoperf.com/v1/virtual-services/vs_123/state?caller=aB3xQ2… \ -H "Authorization: Bearer mpak_example_key" \ -H "X-Account-Id: acn-1234567890"
curl -X POST https://app.maxoperf.com/v1/virtual-services/vs_123/state/clear \ -H "Authorization: Bearer mpak_example_key" \ -H "X-Account-Id: acn-1234567890"# 204, or 503 VS_STATE_BUSY if its lock wait times out — nothing was deleted, retry in a momentClearing state never changes the virtual service’s own configuration. Only POST …/state/clear exists — Round 1’s
reset route and its version-counter field are both gone from this API.
Reading state. The inspector returns up to 500 names per bucket (namesTruncated: true beyond that); a value
over 8 KiB comes back as { "truncated": true, "bytes": 20480 } — open “Show full value” in the console, or call
GET …/state/values/{name}?caller= directly (full value, up to 1 MiB). For a shared (Everyone) variable, “Value
now” is the shared value even when a caller is selected; for an Each-caller variable, it’s that caller’s own value.
A forgotten caller answers 404 VS_STATE_CALLER_NOT_FOUND (“This caller was forgotten — refresh”); calling
GET …/state/values/{name}?caller= for a name with no stored entry yet — even a declared variable still on its
initial — answers 404 VS_STATE_VALUE_NOT_FOUND.
Finding a caller. Caller ids are opaque, server-computed ids (a keyed hash) — never a raw session key — shown by
a key mask like …7f3a. Find one with GET …/state/callers?q=7f3a (1–8 characters, matching the mask suffix), or
from a /try response’s callerId. ~everyone addresses the shared bucket, ~anonymous addresses callers who
didn’t send the session key at all.
With no session key configured, there are no callers: an Each-caller variable is read and written with
?caller=~everyone, and any other caller id → 400 VS_STATE_SCOPE_MISMATCH. With a session key configured, an
Each-caller variable needs a real caller id or ~anonymous, and an Everyone variable needs ~everyone — a mismatch
gets the same 400.
Editing a value. PUT …/state/values/{name}?caller= replaces the whole top-level value — you can’t patch one
field of it through this route. Because the redacted view shows [REDACTED] in place of the real value, writing
back a value that still contains the literal string "[REDACTED]" under a secret-named key is rejected with 400 VS_STATE_VALUE_REDACTED (“This value is hidden — type the whole value to replace it”) — type the whole real value
to replace it, don’t copy the masked one back.
Clear variants:
| Action | Route | Keeps |
|---|---|---|
| Clear one value | DELETE …/state/values/{name}?caller= (required) | everything else; the value reads its initial again |
| Forget a caller | DELETE …/state/callers/{callerId} | every other caller and the shared values |
| Reset flow positions (one caller) | POST …/state/reset-flows {callerId} | that caller’s other values and sequence counters |
| Reset flow positions (everyone) | POST …/state/reset-flows (no callerId) | every value and sequence counter — only flow steps go back to Started |
| Clear all state | POST …/state/clear | nothing — every bucket is deleted; starting values apply again |
Backups
Section titled “Backups”Every backup is one point-in-time snapshot of everything stored. Retention is per kind:
| Kind | When it’s made | Kept |
|---|---|---|
auto | hourly, only when state changed | 7 days; at most 168 per virtual service; shares a 64 MiB cap with pre_restore (oldest pruned first) |
pre_restore | automatically, right before every restore | 7 days; inside the 64 MiB cap, the newest 3 are never pruned for size — they still expire at 7 days |
| manual (Back up now) | you click it, optional 0–80 character label | until you delete it |
| uploaded | you upload a file | until you delete it |
Manual and uploaded backups share 20 slots; a 21st manual/upload attempt → 409 VS_BACKUP_LIMIT (“20 manual
backups max — delete one to free a slot”). DELETE …/state/backups/{id} frees a slot for manual and uploaded
backups; deleting an auto or pre_restore backup → 409 VS_BACKUP_NOT_DELETABLE (“Automatic backups expire on
their own”).
One size measure, everywhere. Every backup — automatic, manual, or uploaded — is measured the same way: the sum
of its stored values must be ≤ 17 MiB (16 MiB plus 1 MiB of maintenance-tick slack) and ≤ 50 000 callers.
Back up now, or restore, over that measure → 409 VS_STATE_CAPACITY; an uploaded file over it → 400 VS_BACKUP_INVALID {why: "too_large"}. The upload request itself may be up to 32 MiB (JSON overhead) — the
console rejects a bigger file client-side before it ever sends it. There’s no separate “uploads ≤ 16 MiB” rule.
Uploading another virtual service’s backup. Its shared (~everyone) and ~anonymous values are kept, but every
caller bucket is dropped — caller ids are computed per virtual service, so a foreign caller id can never match here.
The response carries details.droppedCallers, and the console shows Uploaded · N callers from another virtual service were dropped (shared values kept).
Restore. POST …/state/backups/{id}/restore takes a fresh pre_restore backup of the current state first, then
atomically replaces everything with the target backup. Every restored caller counts as seen right now — it’s not
immediately forgotten by the next idle sweep. An upload, or a restore, over the 17 MiB / 50 000-caller measure fails
before anything changes (409 VS_STATE_CAPACITY).
Download vs. export. GET …/state/backups/{id}/download (editor only) gives you the raw backup file, real
values included — it’s for taking data out of the platform, not for agents. GET …/state/export returns the
current live state in the same file format without spending a backup slot; it’s redacted by default
(?redacted=true, matching secret-named values masked), and only an explicit ?redacted=false — which only the
console’s download button sends — returns raw values. Agents always get the redacted form; an agent calling the
generic API tool can never reach either raw path. The backup file format is maxoperf-vs-state/1.
Uploading, creating, or expiring a backup never blocks a live request — only clear all, restore, and reset flow positions for all callers briefly do (see Limits & latency).
Redaction, in one place. A value under a secret-looking key name — any key at any depth whose name ends in
token, secret, password, passwd, apikey / api_key / api-key, authorization, cookie, or sessionid
/ session_id / session-id (case-insensitive) — shows as [REDACTED] for every role: viewer or editor, in the
inspector, GET …/state/values/{name}, caller labels, both Try it/preview diffs, and the Behavior tab’s response
preview (which says N hidden values preview as [REDACTED]). The only raw copies of stored state anywhere are
the editor-only backup download and the raw export (?redacted=false); the one thing that is not a stored-value
view at all — the live Try it’s rendered response body — is documented separately above, because it’s the mock’s
public output, not a peek at storage.
Errors, verbatim. A stateful request that can’t be evaluated gets one of two different 503 shapes, and it
matters which:
HTTP/1.1 503 Service Unavailablecontent-type: application/json; charset=utf-8x-maxoperf-vs-state: unavailablex-maxoperf-vs-error: state_unavailable{ "error": "state_unavailable" }This is what a client calling the mock sees.
{ "code": "VS_STATE_UNAVAILABLE", "message": "…" }This is what you see calling /try directly, and what any other live stateful request to this virtual service gets
while a clear-all, restore, or reset-flows-for-all is in progress elsewhere.
x-maxoperf-vs-state: capacity marks a served response where a write didn’t take because a value was over 1 MiB, or
the virtual service is full and the write would have grown state.
Limits & latency
Section titled “Limits & latency”| Limit | Value |
|---|---|
| Per stored value | 1 MiB (hard; measured as UTF-8 bytes of the value as JSON) |
| Value nesting depth | 128 levels (400 VS_STATE_LIMIT) |
| Total state / callers per virtual service | 16 MiB / 50 000 callers (checked about once a minute — see “Full”, below) |
| Declared variables | ≤ 200 entries, ≤ 256 KiB total (400 VS_STATE_VARIABLE_INVALID {why: "variables_too_large"}, also from a CRUD seed[] merge) |
| Captures per transaction | ≤ 20 |
| Transform steps per capture | ≤ 16 |
| Regex step input | ≤ 64 KiB (regex_input_too_large) |
| Cases | ≤ 10, each with 1–10 conditions |
| Counter ops (then-ops) | ≤ 10 per transaction |
| Candidate transactions considered | first 50 |
| Key segments / length | ≤ 8 segments / 256 characters |
| JavaScript | 50 ms, 16 MiB memory, ≤ 8 KiB inline source, ≤ 64 KiB output, ≤ 256 KiB combined state read, ≤ 256 KiB request body |
| Stateful request body | ≤ 1 MiB |
Scratch / preview state (Try it, Behavior preview) | 2 MB (400 VS_STATE_LIMIT) |
| Inspector | 500 names per bucket, values over 8 KiB shown truncated |
| Backup size (any kind) | ≤ 17 MiB of values, ≤ 50 000 callers |
| Manual/uploaded backup slots | 20 |
| Automatic + pre-restore backups | ≤ 64 MiB per virtual service (newest 3 pre-restore exempt from the size cap) |
A top-level key — a CRUD collection, a cart, any object you capture into — is one stored value, sharing that
1 MiB cap and rewritten whole on every write.
“Full.” Roughly once a minute, a sweep checks each virtual service’s total bytes and caller count. Over either
cap, the virtual service is marked full (State tab: “Full — new values are refused”): until it’s back under,
any write that would grow state, and any new caller, gets capacity; reads, deletes and shrinking writes still
work. So a fast burst can overshoot by at most about one minute of writes, and rotating session keys can’t grow it
further once it’s full. The least-recently-seen callers over 50 000 are evicted by the same sweep. These are the
only two total caps — there’s no other per-request total check, and the sweep runs whether or not idle
auto-shutdown is on.
Storage bound, per virtual service: 16 MiB of live state (plus about one minute of in-flight writes) + 64 MiB of automatic and pre-restore backups (plus up to 3 protected pre-restore backups) + 20 manual/uploaded backups (≤ 17 MiB each). These bounds apply to each virtual service on its own.
Latency. A stateful request costs +30–120 ms over a stateless one (the one round trip to the MaxoPerf API);
a stateless request is unaffected. A stateful request whose state step doesn’t finish within 3 seconds answers
503 — a 503 never means the change was saved, except in the rare case where the network drops the reply after it
committed; it is never stale data. Other 503 causes: the state store is unreachable, the virtual service isn’t
deploying or running, the restore window (clear-all / restore / reset-flows-for-all only — everything else never
blocks a request), or waiting for a shared JavaScript slot. Editing a live virtual service never answers 503
because of the edit itself; a saved edit applies within about 2 seconds, and for that brief window a pod may still
answer with its previous copy, so a brand-new case or sequence step it hasn’t seen yet falls back to the Otherwise
response.
No throttling, on any plan — a stateful request costs only the values it touches. But stateful requests share
MaxoPerf’s own database with the rest of the platform, so a very high stateful request rate adds database load and
slows stateful responses first. A bare-root template ({{toJson state}}), a dynamic lookup, and a state-reading
JavaScript snippet all scale with bucket size rather than with the values they actually use (see Using values in
responses and JavaScript snippets). JavaScript steps run on a
small worker pool shared by every tenant on an API pod — a JavaScript-heavy mock belonging to another tenant can add
latency or 503s to your own JavaScript-using stateful requests; requests without JavaScript are isolated from
that queue. Try it and the Behavior JavaScript preview share the same slots. Everyone-scope writes serialize per
virtual service — keep per-user counters on Each caller scope rather than Everyone.
“State store unavailable.” A request that got the 503 above shows in Analytics with the transaction name
“State store unavailable”, and the State tab shows a banner: “State store unreachable from VS: N requests
failed.”
Agents
Section titled “Agents”“Ask Loadrigo: remember the username on POST /login per caller and show it on GET /me”
Everything on this page is also reachable as an MCP tool, so an agent — or Loadrigo — can build and iterate on a stateful mock the same way you would in the console.
The existing virtual-service tools still apply: update_virtual_service, list_vs_transactions,
get_vs_transaction, create_vs_transaction, update_vs_transaction, delete_vs_transaction,
sandbox_vs_transaction, get_virtual_service_state, create_vs_crud_resource. This release adds exactly 14 new
tools, with no alias for anything removed:
| Tool | Does |
|---|---|
list_vs_state_callers | search callers by key-mask suffix |
get_vs_state_value | read one value (redacted, up to 1 MiB) |
set_vs_state_value | write one value |
delete_vs_state_value | clear one value back to its starting value |
forget_vs_state_caller | remove one caller |
reset_vs_state_flows | reset flow positions (one caller, or every caller) |
clear_vs_state | clear everything |
list_vs_state_backups | list backups |
create_vs_state_backup | back up now |
restore_vs_state_backup | restore a backup |
delete_vs_state_backup | delete a manual or uploaded backup |
export_vs_state | export current state (always redacted for agents) |
try_virtual_service | run Try it — live: true returns the mock’s real rendered response |
list_transform_snippets | list the workspace’s saved JavaScript snippets |
Agents only ever read redacted stored values — export_vs_state always masks secret-named keys, and the backup
download/upload flow stays in the console. try_virtual_service with live: true is the one exception: it returns
the mock’s rendered response body exactly as served (its diff stays redacted), the same rule as the console’s live
Try it.
See the MCP tools reference for exact tool arguments, and Loadrigo for how the agent chat surface uses them.