Skip to content

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.ts defines the format. A JSON Schema is generated from it into packages/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 ​

FileSchemaPurpose
<name>.tspec.yamlRequestOne HTTP request.
folder.tspec.yamlFolder configConfig inherited by requests in the folder.
environments/<name>.env.yamlEnvironmentVariables 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:

yaml
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: getPetById

Fields ​

FieldTypeRequiredDefaultNotes
tspecstringno"0.1"Schema version.
namestringyes—Non-empty. Shown in run output and reports.
methodenumnoGETGET POST PUT PATCH DELETE HEAD OPTIONS.
urlstring (template)yes—May contain {{vars}}. Relative URLs are joined onto the folder baseUrl.
headersmapno—String/number/boolean values; templated.
querymapno—Appended as the query string; templated.
bodyBodyno—Omit entirely for no body.
authAuthnoinherits folderRequest auth overrides folder auth.
assertionsAssertion[]no[]Declarative checks.
capturemapno—Save response values into variables.
ordernumberno0Lower runs first; ties broken by file path.
tagsstring[]no—Labels for selective runs (--tag). Free-form.
optionsOptionsno—Timeout, retries, redirect policy.
script{ pre?, post? }no—Scripting.
docsstringno—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:

yaml
tags: [smoke, auth, "owner:payments"]
bash
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 failure

Tags 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.

yaml
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: 5

Both 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 — and 301/302 on a non-GET — become a bodiless GET, matching what every real client does, with the body's Content-Type/Content-Length dropped with it,
  • 307/308 preserve the method and body, and
  • Authorization and Cookie are 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 ​

yaml
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 ​

yaml
body:
  type: text
  content: "plain text payload {{suffix}}"

Sent as text/plain.

form ​

yaml
body:
  type: form
  content:
    grant_type: password
    username: "{{user}}"

A map of string values, serialized as application/x-www-form-urlencoded.

multipart ​

yaml
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 ​

yaml
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.

TypeFieldsEffect
none—No auth.
bearertokenAuthorization: Bearer <token>
basicusername, passwordAuthorization: Basic <base64(user:pass)>
apikeyname, value, inAPI key in a header (default) or query param.
oauth2see belowFetches a token at run time and sends it as Authorization.
yaml
auth:
  type: bearer
  token: "{{token}}"
yaml
auth:
  type: apikey
  name: X-API-Key
  value: "{{apiKey}}"
  in: header        # header (default) | query

For 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.

yaml
# 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"
FieldRequired forNotes
tokenUrlallThe token endpoint. Templated.
grant—client_credentials (default), password, refresh_token.
clientId / clientSecretclient_credentialsAlso sent for the other grants when set.
username / passwordpassword
refreshTokenrefresh_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.

TypeConditionsChecks
statusequals · in: [..] · lt · gteThe HTTP status code.
headername + (equals · matches · exists)A response header (name is case-insensitive).
jsonpathpath + (equals · exists · matches)A value selected from the JSON body.
bodycontains · matchesThe raw response body text.
durationltMsWall-clock request duration (strictly less than).
sseevent · count · minCount · maxCount · contains · matches · jsonpath + equals/existsA text/event-stream response's events.
schemastatus · contentType · requiredThe body against the spec's OpenAPI response schema.

status ​

yaml
- { 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 2xx
yaml
- { 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)" }
ConditionMeaning
existstrue — the header is present; false — it is absent.
equals / notEqualsExact string comparison.
containsSubstring.
matchesJavaScript regular expression (as a string).

Header names are matched case-insensitively.

jsonpath ​

yaml
- { 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 }
ConditionMeaning
existsWhether the path selects any value.
equals / notEqualsStructural equality, so objects and arrays work too.
oneOfThe value equals one of the listed values.
containsSubstring of a string value, or membership in an array value.
matchesRegex against the stringified value.
gt / gte / lt / lteNumeric comparison. A non-number fails rather than being coerced.
valueTypestring, number, boolean, object, array, or null (arrays and null are not object).
length / minLength / maxLengthLength of a string or array. Anything else fails.
emptytrue 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 & >= 0

See JSONPath support for the supported subset.

body ​

yaml
- { 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 ​

yaml
- { type: duration, ltMs: 1000 }   # fail if the request took ≥ 1s

sse ​

yaml
- { 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 ​

yaml
- { 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 status

Validates 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 matches pattern fails that assertion with an assertion 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.

yaml
# 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
yaml
# users/02-me.tspec.yaml
name: Get current user
method: GET
url: "{{baseUrl}}/me"
order: 2
auth:
  type: bearer
  token: "{{token}}"          # the value captured above

A capture source can be:

FormExampleCaptures
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-order requests 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_value

    It 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 run invocation; they are not persisted.


The spec block ties a request to an OpenAPI operation so drift and coverage can reason about it.

yaml
spec:
  operation: "GET /pets/{id}"   # "${METHOD} ${path}" — matches the spec's path template
  operationId: getPetById       # preferred when both the spec and request have it

Matching rules:

  • If both the request and the spec operation have an operationId, they match on that.
  • Otherwise the operation string (METHOD path) is normalized and matched against the spec's METHOD path key.

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:

yaml
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.

yaml
tspec: "0.1"
name: Blog                 # optional label
baseUrl: "{{baseUrl}}"     # prepended to relative request URLs
headers:
  Accept: application/json
auth:
  type: bearer
  token: "{{token}}"
FieldTypeNotes
tspecstringDefaults to 0.1.
namestringOptional label.
baseUrlstring (template)Joined onto a request url that isn't already absolute.
headersmapMerged into each request's headers (request headers win).
authAuthUsed 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>.

yaml
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
FieldTypeNotes
tspecstringDefaults to 0.1.
namestringRequired.
variablesmapString/number/boolean values exposed as {{name}}. Default {}.
secretsstring[]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:

  1. a .env file at the workspace root (KEY=VALUE lines, # comments, optional quotes), then
  2. real OS environment variables, which win over the .env file.

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 $.

SyntaxExampleSelects
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/:

FileValidates
request.schema.json*.tspec.yaml request files
folder.schema.jsonfolder.tspec.yaml
environment.schema.jsonenvironments/*.env.yaml

Point your editor's YAML language server at them for autocomplete and inline validation. With the VS Code YAML extension:

jsonc
// .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 spec link.
  • Scripting — the script.pre / script.post escape hatch.
  • Core concepts — the workspace, inheritance, and variable model.

Released under the MIT License.