File format reference
TruSpec collections are plain-text YAML. This is the complete reference for all three file types and every field they support.
Source of truth. The Zod schema in
packages/core/src/format/schema.tsdefines the format. A JSON Schema is generated from it intopackages/core/schema/for editors and agents — see Editor integration. When in doubt, the schema wins.
Schema version: 0.1. Files may carry tspec: "0.1"; it's optional and defaults to 0.1. Any breaking change bumps the version and ships a migration.
Strict by default. Every file type rejects unknown keys, so a typo (assertion: instead of assertions:) surfaces immediately as a validation error rather than being silently ignored.
File types and naming
| File | Schema | Purpose |
|---|---|---|
<name>.tspec.yaml | Request | One HTTP request. |
folder.tspec.yaml | Folder config | Config inherited by requests in the folder. |
environments/<name>.env.yaml | Environment | Variables and secret names for one environment. |
Discovery: any file ending in .tspec.yaml (except folder.tspec.yaml) is treated as a request. Environments live in an environments/ directory at or above the collection.
Request
One request per file. The full set of fields:
tspec: "0.1" # schema version (optional; defaults to 0.1)
name: Get pet by id # REQUIRED — a human-readable name
method: GET # GET POST PUT PATCH DELETE HEAD OPTIONS (default GET)
url: "{{baseUrl}}/pets/{{petId}}" # REQUIRED — {{var}} resolved at run time
headers:
Accept: application/json
query:
expand: owner
body:
type: json # none | json | text | form | multipart | graphql
content: { name: "Rex" }
auth: # optional; can inherit from folder.tspec.yaml
type: bearer # none | bearer | basic | apikey
token: "{{token}}"
assertions: # declarative + machine-checkable
- { type: status, equals: 200 }
- { type: jsonpath, path: "$.id", exists: true }
- { type: duration, ltMs: 1000 }
- { type: schema } # validate body against the linked spec's response schema
capture: # save response values into vars for later requests
ownerId: "$.owner.id"
order: 1 # run order within a collection (lower first; default 0)
tags: [smoke, auth] # labels for `truspec run --tag smoke`
options: # transport: timeout, retries, redirects
retries: 2
script: # advanced — see ./scripting.md
pre: "tr.set('ts', new Date().toISOString())"
post: "tr.expect(tr.response.status === 200, 'ok')"
docs: "Fetch a single pet by its id."
spec: # links request → OpenAPI operation (drift/coverage)
operation: "GET /pets/{id}"
operationId: getPetByIdFields
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
tspec | string | no | "0.1" | Schema version. |
name | string | yes | — | Non-empty. Shown in run output and reports. |
method | enum | no | GET | GET POST PUT PATCH DELETE HEAD OPTIONS. |
url | string (template) | yes | — | May contain {{vars}}. Relative URLs are joined onto the folder baseUrl. |
headers | map | no | — | String/number/boolean values; templated. |
query | map | no | — | Appended as the query string; templated. |
body | Body | no | — | Omit entirely for no body. |
auth | Auth | no | inherits folder | Request auth overrides folder auth. |
assertions | Assertion[] | no | [] | Declarative checks. |
capture | map | no | — | Save response values into variables. |
order | number | no | 0 | Lower runs first; ties broken by file path. |
tags | string[] | no | — | Labels for selective runs (--tag). Free-form. |
options | Options | no | — | Timeout, retries, redirect policy. |
script | { pre?, post? } | no | — | Scripting. |
docs | string | no | — | Free-form documentation. |
spec | { operation?, operationId? } | no | — | Links to an OpenAPI operation. |
Tags
tags label a request so a run can select a subset of the collection without reorganizing folders:
tags: [smoke, auth, "owner:payments"]truspec run ./api --tag smoke # the fast gate on every push
truspec run ./api --tag smoke --tag auth # either tag
truspec run ./api --tag smoke --bail # …and stop at the first failureTags are plain strings, so a team convention like owner:payments or slow works without the format needing to know about it. They are orthogonal to order: selection decides what runs, order decides in what sequence.
Request options
Per-request transport behavior. Every field is optional, and every default matches what the runner did before the block existed — adding options never changes an existing request.
options:
timeoutMs: 5000 # overrides the run-wide --timeout for this request; 0 disables it
retries: 2 # re-send on a transport error, 429, or 5xx (never on a 4xx or a
# failed assertion — those are answers, not faults)
retryDelayMs: 200 # base backoff; doubles each attempt, capped at 5s
followRedirects: true # off by default
maxRedirects: 5Both are visible in the report rather than silent: a timeout names the limit that expired (Timed out waiting for api.example.com:443 after 500ms (3 attempts)), so it is clear whether the request's own timeoutMs, --timeout or the 30s default applied; and a response that arrived only after a re-send is annotated ↻ re-sent 2 time(s) before this response.
Streaming responses. A text/event-stream response is parsed into its events rather than left as one long string: response.events carries { event?, data, id? } per event, and the human report says ↯ 7 server-sent event(s). A stream that never ends — the ordinary shape of an LLM API — is closed after 200 events, or by the request timeout, and what arrived is still reported: before, such a request timed out having thrown away everything the server sent. streamTruncated says the stream was cut short rather than finished by the server.
Redirects are not followed by default. TruSpec reports the actual response a URL returns, so a 301 stays assertable and contract can validate a redirect operation the spec declares. Turn followRedirects on for a request where the hop is incidental.
When following, TruSpec does it itself rather than delegating to the platform, so that:
- each hop is reported back on the result (
redirects: [...]), 303— and301/302on a non-GET— become a bodilessGET, matching what every real client does, with the body'sContent-Type/Content-Lengthdropped with it,307/308preserve the method and body, andAuthorizationandCookieare dropped the moment the origin changes, so a redirect can't walk your credentials to a host you never named.
A result reports retries and redirects only when they actually happened.
Bodies
The body field is a tagged union on type. Omit body (or use type: none) for no request body. The runner sets a default Content-Type for each type unless you've already set one in headers.
json
body:
type: json
content:
name: Rex
tags: [good, boy]content is any JSON value (object, array, string, number, …). Templated deeply — every string inside is interpolated. Sent as application/json.
text
body:
type: text
content: "plain text payload {{suffix}}"Sent as text/plain.
form
body:
type: form
content:
grant_type: password
username: "{{user}}"A map of string values, serialized as application/x-www-form-urlencoded.
multipart
body:
type: multipart
fields:
title: "Rex" # plain value
tags: "{{tagList}}" # templated
photo: { file: "./rex.jpg", contentType: image/jpeg }
meta: { text: '{"a":1}', contentType: application/json, filename: meta.json }Sent as multipart/form-data. Do not set a Content-Type header — the boundary is generated when the body is assembled, and a hand-written header would not match the payload.
A file part is read at send time. Its path resolves relative to the request file and is confined to the workspace, so a collection — which may have been imported, generated, or written by an agent — cannot read ../../.ssh/id_rsa and POST it. filename and contentType override what would otherwise be inferred from the file.
A text part with a contentType travels as its own typed part (useful for the JSON-metadata + binary pattern); a bare string is sent as a plain field.
File parts need filesystem access, so they work under truspec run, truspec serve, and the MCP server. In an embedded/browser runner without a file reader, the request fails with a clear message rather than silently sending nothing.
graphql
body:
type: graphql
query: "query($id: ID!) { user(id: $id) { name } }"
variables: { id: "{{userId}}" }Sent as a POST with a JSON { query, variables } body (application/json). variables is optional and templated.
Auth
The auth field is a tagged union on type. Auth can be set on the request or inherited from folder config; a request's own auth wins. Secrets are referenced by name ({{token}}), never inlined.
| Type | Fields | Effect |
|---|---|---|
none | — | No auth. |
bearer | token | Authorization: Bearer <token> |
basic | username, password | Authorization: Basic <base64(user:pass)> |
apikey | name, value, in | API key in a header (default) or query param. |
oauth2 | see below | Fetches a token at run time and sends it as Authorization. |
auth:
type: bearer
token: "{{token}}"auth:
type: apikey
name: X-API-Key
value: "{{apiKey}}"
in: header # header (default) | queryFor apikey with in: query, the key is appended to the URL's query string — and its value is masked in reported output when declared as a secret.
oauth2
The runner fetches an access token from tokenUrl before sending the request, then sends it as Authorization: Bearer <token>. The token is cached for the rest of the run, so a folder-level oauth2 block shared by twenty requests hits the token endpoint once — not twenty times.
# folder.tspec.yaml — every request under this folder inherits it
auth:
type: oauth2
grant: client_credentials # client_credentials (default) | password | refresh_token
tokenUrl: "{{authUrl}}/oauth/token"
clientId: "{{clientId}}"
clientSecret: "{{clientSecret}}" # an environment SECRET — never inline it
scope: "read:pets write:pets"| Field | Required for | Notes |
|---|---|---|
tokenUrl | all | The token endpoint. Templated. |
grant | — | client_credentials (default), password, refresh_token. |
clientId / clientSecret | client_credentials | Also sent for the other grants when set. |
username / password | password | |
refreshToken | refresh_token | |
scope | — | Space-separated scopes. |
audience | — | Extra token parameter some providers require (Auth0, Okta). |
clientAuth | — | body (default) or basic — where the client id/secret go. |
extra | — | Any further token-endpoint parameters, sent verbatim. |
scheme | — | Authorization scheme for the acquired token. Default Bearer. |
Only unattended grants are supported. An interactive authorization-code flow needs a browser, which a CI gate does not have; capture the resulting refresh token once and use grant: refresh_token. This is a deliberate limit, not an omission.
A token failure fails the request with the provider's own reason (invalid_client, unsupported_grant_type, …) and the API is never called — so a CI log says why the gate failed.
Because the token only exists at run time, truspec codegen renders an oauth2 request with an Authorization: Bearer {{accessToken}} placeholder rather than inventing a credential.
Assertions
Assertions are declarative and machine-checkable — they (not JS scripts) are what power CI gating and coverage. Each assertion is an object with a type and one or more conditions. An assertion must specify at least one condition; an assertion with none always fails. When an assertion lists several conditions, all of them must hold.
| Type | Conditions | Checks |
|---|---|---|
status | equals · in: [..] · lt · gte | The HTTP status code. |
header | name + (equals · matches · exists) | A response header (name is case-insensitive). |
jsonpath | path + (equals · exists · matches) | A value selected from the JSON body. |
body | contains · matches | The raw response body text. |
duration | ltMs | Wall-clock request duration (strictly less than). |
sse | event · count · minCount · maxCount · contains · matches · jsonpath + equals/exists | A text/event-stream response's events. |
schema | status · contentType · required | The body against the spec's OpenAPI response schema. |
status
- { type: status, equals: 200 }
- { type: status, in: [200, 201, 204] }
- { type: status, lt: 400 } # any non-error
- { type: status, gte: 200, lt: 300 } # combine: a 2xxheader
- { type: header, name: Content-Type, contains: "application/json" }
- { type: header, name: X-Request-Id, exists: true }
- { type: header, name: Cache-Control, equals: "no-store" }
- { type: header, name: Server, notEquals: "nginx" }
- { type: header, name: Content-Type, matches: "^application/(json|problem\\+json)" }| Condition | Meaning |
|---|---|
exists | true — the header is present; false — it is absent. |
equals / notEquals | Exact string comparison. |
contains | Substring. |
matches | JavaScript regular expression (as a string). |
Header names are matched case-insensitively.
jsonpath
- { type: jsonpath, path: "$.id", exists: true }
- { type: jsonpath, path: "$.status", equals: "active" }
- { type: jsonpath, path: "$.items[0].sku", matches: "^SKU-" }
- { type: jsonpath, path: "$.total", valueType: number, gte: 0 }
- { type: jsonpath, path: "$.items", minLength: 1 }
- { type: jsonpath, path: "$.tags", contains: "featured" }
- { type: jsonpath, path: "$.state", oneOf: ["queued", "running"] }
- { type: jsonpath, path: "$.errors", empty: true }| Condition | Meaning |
|---|---|
exists | Whether the path selects any value. |
equals / notEquals | Structural equality, so objects and arrays work too. |
oneOf | The value equals one of the listed values. |
contains | Substring of a string value, or membership in an array value. |
matches | Regex against the stringified value. |
gt / gte / lt / lte | Numeric comparison. A non-number fails rather than being coerced. |
valueType | string, number, boolean, object, array, or null (arrays and null are not object). |
length / minLength / maxLength | Length of a string or array. Anything else fails. |
empty | true for an empty string, array or object; false for a non-empty one. |
Conditions on one assertion combine as an AND. The body must parse as JSON; if it doesn't, jsonpath assertions don't match.
A failure names the value that was actually there and only the conditions that failed:
✗ jsonpath $.total → "12.50" fails is a number & >= 0See JSONPath support for the supported subset.
body
- { type: body, contains: "ok" }
- { type: body, notContains: "stack trace" }
- { type: body, equals: "pong" }
- { type: body, empty: false }
- { type: body, matches: "\"status\"\\s*:\\s*\"active\"" }Runs against the raw response text — useful for non-JSON responses. notContains is the one to reach for in a security or privacy check ("no internal hostname in the error body").
duration
- { type: duration, ltMs: 1000 } # fail if the request took ≥ 1ssse
- { type: sse, minCount: 1 } # the stream produced at least one event
- { type: sse, event: token, minCount: 2 } # at least two events named `token`
- { type: sse, contains: "[DONE]" } # some event's data contains this
- { type: sse, event: token, jsonpath: "$.delta", exists: true }
- { type: sse, event: done, jsonpath: "$.usage.total_tokens", equals: 42 }For a text/event-stream response. A streaming endpoint's response is its events: asserting on the concatenated stream text works but says nothing about how many arrived, in what order, or under which name.
event narrows every other condition to events with that name; the count conditions apply after that filter. jsonpath parses each event's data as its own JSON document — an SSE stream is many small documents, not one — and the condition holds if any event satisfies it; an event whose data is not JSON ([DONE]) is skipped rather than failing the assertion. Against a response that is not an event stream, the assertion fails and says so.
schema
- { type: schema } # validate against the linked operation's response schema
- { type: schema, status: 200 } # pin a specific status's schema
- { type: schema, contentType: application/json }
- { type: schema, required: true } # fail if the spec declares no schema for this statusValidates the response body against the OpenAPI response schema for the operation the request is linked to — catching behavioral drift the structural drift check can't see. It needs a spec supplied to the run (truspec run --spec <openapi>, or the dedicated truspec contract gate); without a spec it's a passing skip, so a collection stays runnable spec-free. By default it checks the schema for the response's actual status and application/json; an undocumented status is skipped unless required: true. With truspec run --spec, every spec-linked request is validated automatically — no explicit schema assertion needed. See Spec sync → Response validation.
Invalid regexes fail gracefully. A bad
matchespattern fails that assertion with anassertion error: …message rather than aborting the whole run.
Chaining with capture
capture saves values out of a response into variables that later requests in the same run can use. Combined with order, this expresses login-then-call flows with no scripting.
# auth/01-login.tspec.yaml
name: Log in
method: POST
url: "{{baseUrl}}/login"
order: 1
body:
type: json
content: { username: "{{user}}", password: "{{password}}" }
capture:
token: "$.access_token" # jsonpath shorthand# users/02-me.tspec.yaml
name: Get current user
method: GET
url: "{{baseUrl}}/me"
order: 2
auth:
type: bearer
token: "{{token}}" # the value captured aboveA capture source can be:
| Form | Example | Captures |
|---|---|---|
| jsonpath string (shorthand) | token: "$.access_token" | A value from the JSON body. |
{ jsonpath } | id: { jsonpath: "$.data.id" } | Same, explicit. |
{ header } | loc: { header: "Location" } | A response header value. |
{ status: true } | code: { status: true } | The numeric status code. |
Notes:
Requests run in
order(ascending), then by file path — so lower-orderrequests can feed higher ones.A jsonpath that selects an object/array is captured as its JSON string.
A capture whose source resolves to nothing leaves the variable unset, and says so on the request that should have produced it:
✓ PASS Login (api/01-login.tspec.yaml) 200 25ms ! capture token ← $.access_token matched nothing — $ has no "access_token"; keys: token_valueIt is not a failure by itself — nothing may consume the variable — but the request that does consume it fails with
Unresolved variables: {{token}}, which names the consumer rather than the producer. The web UI shows the same thing beside the values that were captured.Captures flow forward only within a single
runinvocation; they are not persisted.
Spec link
The spec block ties a request to an OpenAPI operation so drift and coverage can reason about it.
spec:
operation: "GET /pets/{id}" # "${METHOD} ${path}" — matches the spec's path template
operationId: getPetById # preferred when both the spec and request have itMatching rules:
- If both the request and the spec operation have an
operationId, they match on that. - Otherwise the
operationstring (METHOD path) is normalized and matched against the spec'sMETHOD pathkey.
Use the path template exactly as it appears in the spec (/pets/{id}), not a concrete URL.
Variables and interpolation
Any string field may contain {{name}} placeholders. They're resolved at run time from the active environment, folder config, secrets, and values captured earlier in the run (see Core concepts → Variables).
- Placeholder names may contain letters, digits,
.,-, and_:{{baseUrl}},{{api.key}},{{user-id}}. - Surrounding whitespace is ignored:
{{ token }}≡{{token}}. - Interpolation descends into objects and arrays (e.g. every string in a JSON body).
- Unresolved variables fail the request before it is sent, and the run reports exactly which names were missing — nothing is silently sent with an empty value baked in.
Types in a JSON body
In a json (or graphql variables) body, a value that is exactly one placeholder keeps the variable's own type. Anything else is text, because concatenation is a string operation:
body:
type: json
content:
qty: "{{qty}}" # qty = 2 -> 2 (a number)
live: "{{live}}" # live = true -> true (a boolean)
sku: "sku-{{qty}}" # qty = 2 -> "sku-2"This matters most for a spec-validated API: an integer field sent as "2" fails its own schema. Typed values reach a run from an environment's variables (declared as string | number | boolean), from a capture (which stores the JSON value it read), and from a JSON dataset. A CSV dataset has no types — every cell is text — so use a JSON dataset when the type matters.
URLs, headers, query parameters, text bodies and form fields are always text: those are string formats, and there is nothing to preserve.
Folder config
folder.tspec.yaml holds configuration inherited by every request in its folder and all subfolders. It's how you avoid repeating a base URL or auth on every request.
tspec: "0.1"
name: Blog # optional label
baseUrl: "{{baseUrl}}" # prepended to relative request URLs
headers:
Accept: application/json
auth:
type: bearer
token: "{{token}}"| Field | Type | Notes |
|---|---|---|
tspec | string | Defaults to 0.1. |
name | string | Optional label. |
baseUrl | string (template) | Joined onto a request url that isn't already absolute. |
headers | map | Merged into each request's headers (request headers win). |
auth | Auth | Used when a request has no auth of its own. |
Composition (root → leaf, deeper wins):
baseUrl,auth,name— the deepest value replaces shallower ones.headers— merged key by key across the chain, then merged with the request's own headers (the request wins on conflicts).
A request url that begins with http:// or https:// is treated as absolute and the baseUrl is not applied.
Environment files
Environments live in environments/<name>.env.yaml and are selected with --env <name>.
tspec: "0.1"
name: local # REQUIRED
variables:
baseUrl: "http://localhost:4000"
petId: "1"
secrets: # NAMES only — values come from the OS env or a .env file
- token| Field | Type | Notes |
|---|---|---|
tspec | string | Defaults to 0.1. |
name | string | Required. |
variables | map | String/number/boolean values exposed as {{name}}. Default {}. |
secrets | string[] | Names of OS/.env variables surfaced as {{name}}. Default []. |
Secrets are never stored here — only their names. At run time each name is looked up in:
- a
.envfile at the workspace root (KEY=VALUElines,#comments, optional quotes), then - real OS environment variables, which win over the
.envfile.
If a declared secret can't be resolved, truspec run prints a warning naming it. Resolved secret values (6+ characters) are masked with *** everywhere they could surface in reported output — URLs, bodies, headers, captured values, and error messages — including their percent-encoded form in query strings.
JSONPath support
jsonpath assertions and captures use a small, dependency-free subset of JSONPath, enough for typical response shapes. A path must start with $.
| Syntax | Example | Selects |
|---|---|---|
| Root | $ | The whole body. |
| Member access | $.user.name, $['user']['name'] | An object property. |
| Array index | $.items[0] | An element by index. |
| Negative index | $.items[-1] | An element counted from the end. |
| Wildcard | $.items[*], $.items.* | All array elements / object values. |
Not supported in v0: recursive descent (..) and filter expressions ([?(…)]). For exact behavior, see packages/core/src/runner/jsonpath.ts.
When a path matches multiple values, equals/matches pass if any match satisfies the condition. If a path selects nothing, exists: false passes and exists: true fails. A capture of a multi-match path takes the first value.
Editor integration
A JSON Schema is generated from the Zod source into packages/core/schema/:
| File | Validates |
|---|---|
request.schema.json | *.tspec.yaml request files |
folder.schema.json | folder.tspec.yaml |
environment.schema.json | environments/*.env.yaml |
Point your editor's YAML language server at them for autocomplete and inline validation. With the VS Code YAML extension:
// .vscode/settings.json
{
"yaml.schemas": {
"./node_modules/@truspec/core/schema/request.schema.json": "*.tspec.yaml",
"./node_modules/@truspec/core/schema/environment.schema.json": "environments/*.env.yaml"
}
}You can also validate programmatically — see Programmatic API → format. To regenerate the schema after a format change, run pnpm gen:schema.
See also
- CLI — run and validate these files.
- Spec sync — drift and coverage off the
speclink. - Scripting — the
script.pre/script.postescape hatch. - Core concepts — the workspace, inheritance, and variable model.