Flow Definition Reference
klaus request definitions are plain YAML. One file = one flow (a sequence of steps), with captures and assertions embedded directly in the definition. The schema is validated with zod; violations result in exit 2 (ParseError).
File Structure
name: auth flow # Required: flow name
env: local # Optional: references environments/local.yaml
tags: [smoke, auth] # Optional: flow-level tags, used by `klaus run --tags` / `--exclude-tags`
steps: # Required: at least one. name must be unique within the flow
- name: login
request: { ... } # Exactly one of request / ws / use is required (mutually exclusive)
sse: { ... } # Optional: SSE receive settings
capture: { ... } # Optional: captures variables from the response
assert: { ... } # Optional: assertions- Environment files are resolved as
environments/<name>.yamlby searching upward from the cwd (stopping at the first ancestor directory containing.git, or at the filesystem root). See Getting Started for details.klaus run --env <name>overrides the flow'senv:.klaus run --env-file <path>loads an environment file from an arbitrary path instead (no upward search), andklaus run --var <key=value>adds or overrides individual variables on top — see CLI Reference - Environment files are a flat map of
key: string value. Values can use templates (such as{{env.X}}) - Setting the reserved key
$protected: truein an environment file makesklaus runrefuse to run against that environment by default (exit 3). It only runs when--allow-protectedis explicitly passed. This is a guardrail against accidentally running against a production-like environment;$protectedcannot be referenced as a template variable ({{...}}). Execution viaklaus ui/ the server API never passes this flag, so protected environments are always refused there $protectedcan only be set or unset by editing the file directly. It is not shown in theklaus uienvironment editor, and saving from the UI leaves any existing$protectedvalue untouched
tags
name: auth flow
tags: [smoke, auth] # Optional: array of non-empty strings. No uniqueness constraint
steps: [ ... ]- Flow-level only — there is no step-level tag
- Used exclusively for flow selection via
klaus run --tags <list>/--exclude-tags <list>(see CLI Reference); tags themselves have no other effect on execution - Not exposed in
klaus uior the server API
request (HTTP step)
request:
method: POST # Can be omitted only when graphql is specified (defaults to POST). Treated as uppercase
url: "{{baseUrl}}/login" # Required. Supports templates
headers: # Optional. Values support templates
Content-Type: application/json
query: # Optional. Values support templates
page: "1" # Merged into the url's query string. If url already has the same key, query wins
body: # Optional. object → sent as JSON (application/json is auto-set if Content-Type is unspecified)
email: "{{testEmail}}" # string → sent as-is
timeoutMs: 30000 # Optional. Defaults to 30000. Exceeding it is a RuntimeError (exit 3)If the Content-Type is JSON, the response is automatically parsed and becomes the target of JSONPath assertions / captures. Otherwise it's kept as text and becomes the target of bodyText assertions. Redirect and TLS behavior follow undici's defaults (klaus does not control these).
GraphQL
Specifying request.graphql is sugar for a GraphQL request. It's mutually exclusive with body (specifying both is a ParseError).
request:
url: "{{baseUrl}}/graphql"
graphql:
query: 'query { user(id: "{{userId}}") { id name } }' # Supports templates
variables: # Optional. Supports templates
limit: 10- If method is omitted it defaults to POST; if Content-Type is unspecified it defaults to application/json
- The sent body is
{ query, variables }(just{ query }if variables is unspecified) - The response is treated as regular JSON, so JSONPath assertions and captures against
$.data.…/$.errorswork as usual
SSE (Server-Sent Events)
A step becomes SSE mode if it has an Accept: text/event-stream header, or if an sse: block is written.
request:
method: GET
url: "{{baseUrl}}/events"
headers:
Accept: text/event-stream
sse:
maxEvents: 5 # Defaults to 100
maxDurationMs: 3000 # Defaults to 10000- Receiving stops as soon as either
maxEventsormaxDurationMsis reached, and the step ends successfully (stopping is not a failure) - Received events go into the result's
eventsfield as an array of{ event?, id?, data }.response.bodyis undefined captureis ignored for SSE steps- Assertions use
eventCount/events(described below)
WebSocket
Write ws: in place of request for the step (mutually exclusive — exactly one is required).
ws:
url: "{{wsBaseUrl}}/socket" # ws:// / wss:// (http(s):// is a ParseError). Supports templates
headers: # Optional
Authorization: "Bearer {{token}}"
send: # Optional: sent in order after connecting. string is sent as-is, object is JSON-encoded. Supports templates
- "ping"
- { type: subscribe, channel: orders }
maxMessages: 50 # Defaults to 100
maxDurationMs: 5000 # Defaults to 10000- The connection is closed and the step ends successfully once received messages reach either
maxMessagesormaxDurationMs. A normal close from the other side also ends successfully - Connection failure or an abnormal close is a RuntimeError (exit 3)
- Received messages go into the result's
wsMessagesfield as an array of{ data }. There is noresponse captureis ignored for WS steps- Assertions use
messageCount/messages(described below)
use (step reference)
A step can write use: instead of request / ws to reuse another flow definition file (one that contains a single step) by pulling in its request / sse / assert. It's mutually exclusive with request, ws, and sse (writing both is a ParseError). This lets multiple flows reuse the same API check without copy-pasting the request definition, while the referenced file itself can still be run standalone as before (the design treats api/ as an "executable API catalog" — see the examples for details).
# api/login-check.yaml — still runnable standalone
name: Login API check
steps:
- name: login
request:
method: POST
url: "{{baseUrl}}/login"
body: { email: "{{testEmail}}" }
assert:
status: 200
body:
- path: "$.token"
exists: true# flows/auth-flow.yaml — reuse login without rewriting it
name: Auth flow
steps:
- name: login
use: ../api/login-check.yaml # path relative to this flow file
capture:
token: "$.token"
- name: me
request:
method: GET
url: "{{baseUrl}}/me"
headers:
Authorization: "Bearer {{token}}"
assert:
status: 200- Resolution timing: at flow load time (
klaus run/klaus validate/ the UI's flow detail endpoint). The referenced file's single step is expanded into a normal step (taking itsrequest/sse/assert) before execution name/capturealways come from the calling step; the referenced step's values are ignoredassertis merged additively (not replaced):headers/body/events/messagesare concatenated in referenced-then-caller order.status/bodyText/duration/eventCount/messageCount/bodySchemabecome a ParseError / aklaus validateFlowIssue if defined on both sides, since that would weaken the guarantee made by the standalone check (define it on only one side, or let the referenced step's definition win)- The referenced file's
env:is not pulled in (the environment is always decided by the flow that's actually running). Placeholders ({{var}}) are resolved as usual, after expansion, using the calling flow's env / captures - The path must be relative to this flow file. Absolute paths are rejected. A resolved path that falls outside the project directory (the cwd
klausis run from) is also rejected (no escaping the project root via../) - If the referenced step itself has
use, it's resolved recursively. Circular references are detected and rejected
v1 Limitations
- The referenced file must contain exactly one step (pulling in multiple steps, inheritance, or overriding request fields is out of scope)
- The referenced step must be an HTTP request step (referencing a
ws:step is not supported) - A broken reference, a circular reference, a reference to a multi-step file, or a scalar
assertconflict becomes a hinted, structured issue inklaus validate, and a ParseError (exit 2) inklaus run
Templates
{{...}} is resolved in the following order. An unresolved variable or an undefined OS environment variable results in a RuntimeError (exit 3) (it does not silently become an empty string).
| Syntax | Resolves to |
|---|---|
{{var}} | ① capture variables from prior steps → ② values from the environment file (captures take precedence) |
{{env.X}} | The OS environment variable X. Use this for secrets instead of hard-coding them into the definition file |
{{newUuid}} | A UUID from crypto.randomUUID() |
{{newDate}} | The current time as an ISO 8601 string |
{{newTimestamp}} | The current time as epoch milliseconds |
Where expansion applies: request.url / values of request.headers / values of request.query / request.body (deep expansion of string values) / graphql.query / graphql.variables / ws.url / ws.headers / ws.send / assertion expected values (such as equals: "{{testEmail}}").
capture (variable capture)
capture:
token: "$.token" # variable name: JSONPath
userId: "$.data.user.id" # nested field
firstId: "$.items[0].id" # array index- Applies a JSONPath to the JSON response, making the result available as a template variable in subsequent steps (the classic case being login → token → Authorization header)
- If it doesn't match, or the response isn't JSON, this is a RuntimeError and the step becomes error (exit 3). A silent chain like
Bearer undefinedcannot happen. A capture whose value isnullis treated as a success - Captured values are not masked. Secret masking covers only values resolved via
{{env.X}}, so a token captured here is written as-is to the history JSONL, the JUnit report, and record cassettes. See SECURITY.md for the masking boundaries - Ignored on SSE / WS steps
assert (assertions)
All fields are optional. If multiple are given, all are evaluated; if even one fails, the step becomes failed (exit 4). Writing multiple matchers on a single entry produces a separate result (AssertionResult) per matcher.
assert:
status: 200
headers:
- { name: content-type, contains: json }
body:
- { path: "$.token", exists: true }
- { path: "$.email", equals: "{{testEmail}}" }
bodyText:
contains: "ok"
bodySchema:
type: object
required: [id, email]
properties:
id: { type: integer }
email: { type: string, format: email }
duration:
maxMs: 1000
# For SSE
eventCount: { min: 1, max: 10 }
events:
- { index: 0, path: "$.type", equals: "message" }
# For WebSocket
messageCount: { min: 1 }
messages:
- { path: "$.type", contains: "order" }Matcher List
| Target | Field | Matcher |
|---|---|---|
| Status | status | exact numeric match |
| Headers | headers[] | name + equals / contains / regex / exists |
| Body (JSONPath) | body[] | path + exists / equals / contains / regex |
| Body (raw text) | bodyText | equals / contains / regex |
| Body (JSON Schema) | bodySchema | a JSON Schema object |
| Duration | duration | maxMs |
| SSE event count | eventCount | min / max / equals |
| SSE event | events[] | index? + path? + the matchers above |
| WS message count | messageCount | min / max / equals |
| WS message | messages[] | index? + path? + the matchers above |
Common semantics for events / messages:
- When
indexis given: evaluated against the received data at that index - When
indexis omitted: passes if any received data item matches - When
pathis given: the received data (data) is JSON-parsed and the JSONPath is applied. When omitted, the matcher is applied to the raw string
bodySchema (JSON Schema body validation)
bodySchematakes a JSON Schema object embedded directly in the YAML (referencing an external file is not supported yet)- Validation is performed with ajv using draft 2020-12 (
Ajv2020). Schemas originating from OpenAPI 3.1 generally work as-is - When the schema has multiple violations, a separate
AssertionResultis returned for each violation (evaluation is not short-circuited on the first failure; all violations are reported together). Each result'smessageincludes ajv'sinstancePath((root)for a root-level violation) and the violation detail - SSE / WS steps, which have no body, always yield ok:false. An HTTP response whose body exists but fails to parse as JSON is validated against the schema as the raw string (e.g. a schema requiring
type: objectfails, whiletype: stringmay pass) - If the schema itself is invalid and ajv fails to compile it, this does not throw; it's reported as an ok:false assertion failure instead
regex patterns are template-rendered
Like other assertion values, the pattern given to a regex matcher (assert.headers[].regex, assert.body[].regex, assert.bodyText.regex, assert.events[].regex, assert.messages[].regex) is template-rendered before matching, so {{...}} in it is not limited to a literal — it can resolve from a capture or a --var. Because a capture is populated from the response body of the API under test, a flow that feeds a captured value into regex effectively lets that API choose the pattern used to check it. A catastrophic-backtracking pattern (e.g. ^(a+)+$) then makes matching take exponentially longer per added character of input — a few dozen characters is already enough to hang for well over a minute — and no timeout bounds assertion evaluation (unlike request.timeoutMs, which covers only the HTTP request itself). In klaus ui, this blocks the shared server process, not just the run.
This is an availability effect only (the run hangs; no data is exposed or altered), and reaching it already requires the ability to run/edit flows (the session token in klaus ui). It also only happens when a flow deliberately routes a captured or --var value into regex — a literal pattern written directly in the flow file is unaffected. contains and equals do not evaluate a pattern, so they are unaffected regardless of where their value comes from. See SECURITY.md for the related regex-timeout scope note.
if
steps:
- name: login
request: { method: POST, url: "{{env.baseUrl}}/login" }
assert: { status: 200 }
- name: cleanup
request: { method: DELETE, url: "{{env.baseUrl}}/session" }
if: steps.login.status == "passed" # only run when login passed
- name: use-token
request: { method: GET, url: "{{env.baseUrl}}/me", headers: { Authorization: "Bearer {{token}}" } }
if: captures.token != ""ifgates whether the step runs at all. It is a small, intentionally limited condition grammar — not a general expression language — evaluated bysrc/core/condition.ts:ref op literal, whererefissteps.<name>.statusorcaptures.<name>,opis==or!=, andliteralis a double-quoted string, a single-quoted string, or a bare token with no whitespacestepName/captureNamemay not contain.or whitespace- Quoted literals do not support escape sequences; to include the other quote character in a value, wrap it in the opposite quote style (e.g. use single quotes to include a
") - A literal that starts with
'or"must end with the same quote character; an unterminated quote (e.g.captures.token == "abc) is not silently treated as a bare token — it is rejected as a malformed expression (RuntimeError) - Comparison is always done as a string. The
captures.<name>side is coerced the same way template rendering (e.g.{{token}}) stringifies values — objects/arrays are JSON-stringified, other values viaString();steps.<name>.statusis already a string
- No
{{...}}template rendering is applied insideif. Reference prior captures directly viacaptures.<name>(not{{name}}) steps.<name>.statuscan only reference a step that appears earlier in the same flow and has already completed (including one that finished viacontinueOnError, in which case its realfailed/errorstatus is visible to later conditions — this is the point of combiningifwithcontinueOnError, e.g. running cleanup only when setup passed)- When the condition evaluates to false, the step becomes
skippedwithout executing (no request is sent,retrydoes not apply) witherror: "skipped because condition not met: <expression>". This does not skip the rest of the flow — later steps still run as normal (see Flow Behavior on Step Failure for the distinct "previous step failed" skip reason) - When the condition throws — a malformed expression, or a reference to an unknown step/capture name — the step becomes
errorwith the underlying message (e.g. listing the available step/capture names), and this follows the normal error semantics described below (remaining steps are skipped unlesscontinueOnErroris set) - A
skipRestin effect from an earlier unhandled failure (see below) takes priority: if the flow is already skipping remaining steps,ifis not even evaluated for this step
retry
retry:
count: 3 # Required. Number of retries after the first attempt (1-100)
intervalMs: 500 # Optional. Fixed wait between attempts in milliseconds. Defaults to 1000countis the number of retries after the first attempt, so the step runs at mostcount + 1times in total- The step is retried when its outcome is
failed(assertion failure) orerror(thrown exception, such as a connection failure or timeout). Apassedoutcome stops the loop immediately, even beforecountis exhausted - The wait between attempts is fixed at
intervalMs(no backoff, no condition expressions) - Applies uniformly to
request,sse, andwssteps — the whole step (request/response and assertions) is re-run on each attempt - Only the final attempt is recorded: one entry in the step results, one history entry, and a single
onStepStart/onStepCompletepair per step. Earlier failed/error attempts are not kept - When
retryis set, the result and history entry carry anattemptsfield with the number of executions actually performed (1 or more). Withoutretry,attemptsis omitted.durationMsremains the final attempt's own duration, as before
continueOnError
steps:
- name: optional-check
request: { method: GET, url: "{{env.baseUrl}}/optional" }
assert: { status: 200 }
continueOnError: true # Optional. Defaults to false
- name: next-step
request: { method: GET, url: "{{env.baseUrl}}/next" }- When
continueOnError: trueis set on a step, a final outcome offailedorerroron that step does not skip the remaining steps — the flow keeps running from the next step - "Final outcome" means after
retryis exhausted, ifretryis also set: retries run first as usual, andcontinueOnErroronly takes effect once no more retries remain - The step itself keeps its
failed/errorstatus, and the flow's (and run's) aggregate status and exit code are unaffected — they still reflect the failure as usual (see CLI Reference) continueOnErroronly affects that step's own failure. If a later step withoutcontinueOnErrorfails, it still skips the steps after it, as described below
Flow Behavior on Step Failure
- When a step becomes failed (assertion failure) or error (runtime error), the remaining steps in that flow are not run and are recorded as skipped (
error: "skipped because a previous step failed") — unless the failing step hascontinueOnError: trueset, in which case the remaining steps still run (see continueOnError) - A step whose
ifcondition evaluates to false is also recorded asskipped, but for a different reason (error: "skipped because condition not met: <expression>") and without triggering the above "remaining steps skipped" behavior — the flow keeps running from the next step either way - When multiple flow files are passed, other flows still run even if one flow fails
- The final exit code follows the priority rules in the CLI Reference
JSON Schema
The flow definition schema is also published as JSON Schema. Use it for editor completion/validation, or as a reference when an AI agent generates flow YAML.
- Published URL:
https://almondoo.github.io/klaus/schema/flow.schema.json - Path bundled in the npm package:
node_modules/@almondoo/klaus/dist/schema/flow.schema.json
Adding a # yaml-language-server: $schema= comment at the top of a YAML file enables completion and validation in editors that support it (such as VS Code's YAML extension).
# yaml-language-server: $schema=https://almondoo.github.io/klaus/schema/flow.schema.json
name: auth flow
steps:
- name: login
request:
method: POST
url: "{{baseUrl}}/login"Note: constraints enforced via superRefine and described elsewhere on this page — the mutual exclusivity of request.body and request.graphql, requiring exactly one of step.request / step.ws / step.use, the ws.url scheme restriction, and step name uniqueness — are not expressible in the JSON Schema structure itself (they are noted in the description of the relevant properties). These are enforced only by runtime validation in klaus validate / klaus run. use: reference resolution (path boundaries, circular references, additive assert merging, etc.) is likewise enforced during load-time validation in klaus validate / klaus run, not by the schema.