Collection file format
Collections are plain YAML files at the root of your project folder. They are committed to your repository, so the format is a real contract: it is versioned, output is written minimally and in a stable order, and only the fields you actually set are emitted.
This page documents schema v0.10.0, the version shipped in desktop v0.1.0-preview.10.
formatVersionfor a collection is2.formatVersionfor a chain is3— chains version independently and live in their own files under.tinspec/chains/.
Nothing you have on disk changed
Section titled “Nothing you have on disk changed”v0.10.0 grew a lot of fields, and not one of them bumped a version number. Both format versions are the same as in v0.8.0, every new key is optional, and every one of them is absent by default. A collection written by the previous release round-trips byte-identically through this one: the writer still emits only what is set, so opening and saving an old file produces no diff.
What is new, all of it optional:
| Where | New keys |
|---|---|
Request | settings, scripts, mcp |
defaults | settings, scripts |
Request.auth | inject, inline |
AuthProvider | oauth, aws |
Request.grpc | descriptorSource, descriptorSetPath |
SpecSource | kind, pollIntervalSecs |
Chain | settings |
ChainNode | onError, region |
Plus two widened vocabularies: assertions went from four sources and seven
operators to seven and eighteen, and protocol gained mcp.
A complete example
Section titled “A complete example”formatVersion: 2name: Example APIrequests: - name: Get user protocol: http method: GET url: "{{baseUrl}}/users/{{id}}" headers: Accept: application/json auth: provider: keycloak-dev routeMatcher: matcher: edge-kong upstream: users-service gatewayUrl: "{{gatewayUrl}}" tests: - source: status op: eq expected: "200" - source: duration op: lt expected: "800" settings: timeoutMs: 30000specSources: - name: Users API location: http://localhost:8080/openapi.json pollIntervalSecs: 300routeMatchers: - name: edge-kong kind: kong config: | services: - name: users-service url: http://users:8000 routes: - name: users paths: [/api/v1] strip_path: trueenvironments: - name: local variables: baseUrl: http://localhost:8080 gatewayUrl: https://gw.example.com id: "1"Collection
Section titled “Collection”The complete set of top-level keys.
| Key | Type | Required |
|---|---|---|
formatVersion | number | yes |
name | string | yes |
requests | Request[] | no |
environments | Environment[] | no |
pipelines | Pipeline[] | no |
specSources | SpecSource[] | no |
authProviders | AuthProvider[] | no |
routeMatchers | RouteMatcher[] | no |
defaults | RequestDefaults | no |
RequestDefaults
Section titled “RequestDefaults”Merged into every request in the collection; a request’s own value wins.
| Field | Type | Required |
|---|---|---|
headers | map<string, string> | no |
query | map<string, string> | no |
variables | map<string, string> | no — layered under the active environment |
auth | AuthRef | no — used when a request specifies none |
settings | SendSettings | no — the collection layer; see the layering rules |
scripts | RequestScripts | no — inherited field by field |
scripts is the one that merges rather than replaces: a collection-wide pre that signs every
request coexists with a request’s own post. A request that sets the same hook wins outright.
Request
Section titled “Request”| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
protocol | http | grpc | graphql | websocket | langgraph | mcp | no | Absent means HTTP |
method | GET | HEAD | POST | PUT | PATCH | DELETE | OPTIONS | yes | Required even for gRPC, GraphQL and MCP — use POST |
url | string | yes | {{var}} allowed; grpc:// or grpcs:// for gRPC; the server endpoint for MCP |
query | map<string, string> | no | Appended at send time; kept out of the URL string |
headers | map<string, string> | no | HTTP headers, or gRPC call metadata |
body | Body | no | |
auth | AuthRef | no | A provider reference, or an inline credential that is still a keychain name |
grpc | GrpcCall | no | When protocol: grpc |
graphql | GraphqlCall | no | When protocol: graphql |
langgraph | LangGraphCall | no | When protocol: langgraph |
mcp | McpCall | no | When protocol: mcp |
prerunChain | string | no | Name of a chain to run first; its outputs merge into the environment |
routeMatcher | RouteMatcherRef | no | |
useCookieJar | boolean | no | Absent or true means the shared jar applies; false opts out |
tests | Assertion[] | no | Evaluated against this request’s own response |
settings | SendSettings | no | The most specific transport layer |
scripts | RequestScripts | no | Pre/post hooks, stored inline |
| Field | Type | Required | Notes |
|---|---|---|---|
contentType | string | yes | Advisory for multipart — the client sets the real boundary |
content | string | yes | Use "" for multipart and file bodies |
kind | text | multipart | file | no | Absent means text |
parts | BodyPart[] | no | When kind: multipart |
filePath | string | no | When kind: file. Workspace-relative inside the project, absolute otherwise; {{var}} allowed |
BodyPart
Section titled “BodyPart”| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
kind | text | file | no | Absent means text |
value | string | no | Text part; {{var}} allowed |
filePath | string | no | File part |
contentType | string | no | Default: none for text, application/octet-stream for file |
fileName | string | no | Content-Disposition filename; defaults to the path’s basename |
Per-protocol calls
Section titled “Per-protocol calls”GrpcCall
Section titled “GrpcCall”| Field | Type | Required | Notes |
|---|---|---|---|
descriptorSource | proto | descriptorSet | reflection | no | Absent means proto |
protoPath | string | no | Workspace-relative .proto. Required for descriptorSource: proto; ignored by the other two |
descriptorSetPath | string | no | Workspace-relative compiled FileDescriptorSet. Read when descriptorSource: descriptorSet |
service | string | yes | Fully qualified, e.g. acme.users.v1.UserService |
method | string | yes | |
message | string | no | Request message as JSON; {{var}} allowed |
messages | string[] | no | Further messages for client-streaming and bidi, sent in order after message |
Streaming mode is not stored — it is read from the descriptor.
descriptorSource is why an existing .proto request writes no new key: absent still means
proto, so nothing that already works produces a diff. The three values are two different file
formats plus a live one — reflection asks the running server, so the descriptor is re-read
rather than frozen into a file that rots. descriptorSetPath is a separate field from
protoPath rather than an overloaded one, so you can flip between source, compiled and live
without retyping a path.
GraphqlCall
Section titled “GraphqlCall”| Field | Type | Required |
|---|---|---|
query | string | yes |
variables | string (JSON-encoded object) | no |
operationName | string | no |
McpCall
Section titled “McpCall”Present when protocol: mcp — one saved request calls one tool on one MCP server.
| Field | Type | Required | Notes |
|---|---|---|---|
tool | string | yes | The tool’s name exactly as the server’s tools/list reports it |
arguments | string (JSON-encoded object) | no | Absent means no arguments; {{var}} allowed |
There is no server entity and no token field. The server is Request.url, the credential is
an ordinary AuthRef, extra headers are Request.headers, and transport is
settings — all four already inheritable from defaults. The arguments are a
JSON string for the same reason grpc.message is: the argument names are the server’s
vocabulary, not ours, and a string diffs line by line in a YAML block scalar.
A stdio server — a command plus its arguments — cannot be spelled as a URL, and the engine does not spawn child processes for MCP. That transport is not expressible in this format, which is the honest position rather than a field promising something the tool does not do.
LangGraphCall
Section titled “LangGraphCall”| Field | Type | Required | Notes |
|---|---|---|---|
input | string | no | JSON-encoded payload |
streamMode | stream | buffered | no | Absent means stream |
eventsVersion | string | no | |
customFields | { name, path }[] | no | Both required; path is a dot-path into the raw event’s data. A missing path renders blank |
Environment
Section titled “Environment”| Field | Type | Required |
|---|---|---|
name | string | yes |
variables | map<string, string> | no |
Secret values never go here.
Assertion
Section titled “Assertion”Used by both Request.tests and a chain’s assert node.
| Field | Type | Required | Notes |
|---|---|---|---|
source | see below | yes | |
path | string | no | A path expression for body, header name for header, cookie name for cookie, variable name for var. Unused by status/duration/size |
op | see below | yes | |
expected | string | no | Unused for exists/isEmpty; {{var}} allowed |
Seven sources. Every one of them yields a string; numeric operators parse it as a float at compare time, so a source is never typed at the format level.
| Source | Reads |
|---|---|
status | The numeric HTTP status |
body | The response body at path |
header | A response header named by path (case-insensitive) |
var | A current variable named by path |
duration | The wall-clock round trip, in milliseconds |
size | The response body’s size, in bytes |
cookie | A cookie’s value by name from the shared jar — so a cookie set by an earlier request is assertable |
Eighteen operators.
| Operator | Compares |
|---|---|
eq / ne | String equality / inequality |
contains / notContains | Substring presence. A missing value passes notContains |
startsWith / endsWith | Prefix / suffix |
gt / gte / lt / lte | Numeric. false if either side is not a number |
between | Inclusive numeric range, written 1..10 |
exists / isEmpty | Present-and-non-empty / absent-or-empty |
length | Array items, object keys or string characters against a count (3), a comparison (>0, <10) or a range (1..5) |
in / notIn | Membership in a comma-separated list or a JSON array |
matches | Regular expression |
schema | The body matches the endpoint’s own declared OpenAPI response schema for the status it came back with |
Two absences are deliberate, not oversights. There is no neq — ne is the canonical
spelling, and a second on-disk spelling of one check is poison for a format built on clean git
diffs. And there is no negate flag: notContains, notIn and isEmpty are the negations
worth having, while a general flag would give you negate + gt (which is lte) and negate +
schema (which is nothing).
Path expressions
Section titled “Path expressions”Several fields hold a path — Assertion.path for source: body, Extraction.path for
from: body. They are one grammar, evaluated by one evaluator, so a path means the same thing
in a request test, a chain assertion and an extraction.
Two spellings, picked from the expression itself. A leading / is XPath; anything else is
JSONPath.
| Spelling | Examples |
|---|---|
| JSONPath subset | data.token · items[0].id · items[*].id · ..id · ['odd key'] · an optional leading $ |
| XPath subset | /order/line[2]/@sku · //token · /rss/channel/item/title/text() |
The document model is read from the body, not declared in the file: the body is parsed as
JSON, and failing that as XML. Neither the format nor you has to say which, and either grammar
addresses either model — order.line['@sku'] walks an XML document, /data/token walks a JSON
one.
What a path binds when it matches more than once is a rule worth knowing before you write one:
- An expression that is plural by construction — it contains
[*],.*,..name,..*, or an XPath//— always binds a JSON array of every match in document order, and[]when nothing matched. Always, not “an array when there are several and a bare value when there is one”: a binding whose shape depended on how many rows the server happened to return is a chain that works on Tuesday. - Any other expression binds the single node it addresses. Zero matches is not present — an
extraction error, an assertion failure. Two or more is an error, naming the count and the
first few concrete locations. Over JSON that cannot happen; over XML it happens the moment a
document has two
<line>siblings, and picking the first would be a coin flip dressed as an answer.
Evaluation is capped at 20,000 matches and says so when it stops, rather than hanging on a
..* over a 50 MB response.
One thing on screen disagrees with this page, and this page is right. Type a wildcard or a
.. into a chain extraction’s path field, or into a source: body assertion, and
v0.1.0-preview.10 shows an amber line under it: “Wildcards and .. aren’t evaluated here — the
engine’s extractor walks keys and numeric indexes.” That warning is stale. It describes an
older extractor that was replaced: at the engine release this app ships, extraction and
assertions both go through the evaluator described above, wildcards included. The line is a
warning and not an error — it does not stop you typing the path, saving it, or running it, and
the path will resolve. Believe the run, not the warning.
SendSettings
Section titled “SendSettings”Transport settings for a send: timeout, TLS policy, redirects, response cap, HTTP version, proxy and gRPC tuning. Attachable at several points, which is the whole point of it.
| Field | Type | Notes |
|---|---|---|
timeoutMs | number | 0 means no timeout. For gRPC this is also the call deadline (grpc-timeout) |
insecure | boolean | Skip TLS certificate verification — the self-signed staging case |
followRedirects | boolean | |
maxRedirects | number | Hops, when followRedirects is on |
maxResponseBytes | number | 0 = unlimited |
httpVersion | auto | http1 | http2 | auto is normal ALPN negotiation; http2 assumes prior knowledge with no downgrade |
proxy | ProxySettings | |
tls | TlsSettings | |
grpc | GrpcSettings |
Every field is optional, and absent means inherit from the next layer out — not “use the default”. Four layers, most specific first:
Request.settings— this one call.defaults.settings— the collection/folder layer.- The API provider’s
settings(in.tinspec/providers/<stem>.yaml). - The app’s global send options, in Settings — the floor.
Resolution is field by field, so a request that sets only timeoutMs still inherits the
provider’s proxy. The app shows where each value came from (30 s — from provider billing-staging); that provenance is computed by the engine, which is the only thing that
resolves these layers.
ProxySettings
Section titled “ProxySettings”| Field | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Scheme included: http://, https://, socks5://, socks5h:// |
noProxy | string | no | Comma-separated hosts, domain suffixes (.internal), IPs or CIDRs — the conventional NO_PROXY syntax |
auth | ProxyAuth | no |
ProxyAuth
Section titled “ProxyAuth”| Field | Type | Required |
|---|---|---|
username | string | yes |
passwordRef | string | yes — an OS-keychain key name, never the password |
TlsSettings
Section titled “TlsSettings”| Field | Type | Notes |
|---|---|---|
clientCertRef | string | Keychain key holding the client certificate PEM (mTLS) — a reference, not the PEM |
clientKeyRef | string | Keychain key holding the client private key PEM |
caBundlePath | string | Path to an extra CA bundle trusted in addition to the built-in roots. A CA certificate is public, so this is a path, not a ref |
useOsTrustStore | boolean | Also trust the OS certificate store — how a corporate TLS-inspecting proxy’s CA becomes trusted |
GrpcSettings
Section titled “GrpcSettings”| Field | Type | Notes |
|---|---|---|
maxDecodingMessageBytes | number | Largest inbound message |
maxEncodingMessageBytes | number | Largest outbound message |
sendCompression | none | gzip | zstd | |
acceptCompression | none | gzip | zstd |
none is an explicit value rather than an absence, so a request can turn off an encoding a
lower layer enabled.
These four are carried, resolved and applied by the engine, but v0.1.0-preview.10 has no editor for them — set them by hand in the YAML if you need them.
RequestScripts
Section titled “RequestScripts”User JavaScript run immediately before a request goes out and immediately after its response comes back.
| Field | Type | Notes |
|---|---|---|
pre | string | Runs after {{var}} substitution, with the resolved request readable as ts.request. Variables it produces are substituted into the request before it is sent |
post | string | Runs when the response arrives, readable as ts.response. Variables it produces go back to the host — the scripted counterpart of an Extraction |
The code is stored inline, as a YAML block scalar — not a file path. A hook belongs to
exactly one request, and an imported Postman collection has nothing but source, so a path would
mean inventing a file for every imported request. Block scalars diff cleanly, so the format cost
is nil. (An auth provider’s script stays a path, because one login script is shared by many
requests.)
requests: - name: Signed webhook method: POST url: "{{baseUrl}}/hooks" headers: X-Signature: "{{signature}}" scripts: pre: | const sig = ts.hmac("sha256", ts.secret("WEBHOOK_KEY"), ts.request.body); ts.setVar("signature", sig);Both hooks run in the same sandboxed QuickJS runtime as every other user script, under the same
interrupt handler, memory cap and wall-clock bound. The available surface is ts.* — the same
object auth-provider scripts get; see the auth script contract.
Two limits worth knowing before you write one:
- There is no module loader and no
require, deliberately and permanently. This code is committed, pulled from teammates and executed against real credentials; module resolution would turn “review this collection’s scripts” into “audit a dependency tree fetched at run time”. What a send runs is always the inline hook — exactly the text a reviewer reads in the YAML. The project’s.tinspec/liband.tinspec/functionsare an editing convenience, not an import path. - A
posthook does not run on a streaming response. An SSE send evaluates no request tests, runs no drift check and runs no post hook; aprehook still runs, but its console output is not captured. The response pane names the gap rather than hiding it.
AuthRef
Section titled “AuthRef”What a request may say about auth. There is still no way to express a credential value here — the inline form names a keychain entry.
| Field | Type | Required | Notes |
|---|---|---|---|
provider | string | no | The AuthProvider name. Omitted only when inline is set |
sender | string | no | Which sender to authenticate as |
inject | AuthInject | no | Overrides the provider’s placement, for this request only |
inline | InlineAuth | no | A credential authored here instead of a named provider |
provider became optional so inline could exist; every file that already had one still writes
it. Set both and provider wins.
inject is the per-request override: the provider puts its token in a header everywhere else,
and this one request wants it inside the JSON body.
InlineAuth
Section titled “InlineAuth”A credential authored on the request rather than obtained by a provider — the API whose authentication is a long-lived key that no token dance produces.
| Field | Type | Required | Notes |
|---|---|---|---|
scheme | bearer | basic | apiKey | yes | |
placement | header | query | cookie | no | For apiKey; default header. body and variable are rejected by the engine — an inline API key is a transport credential, not a body field |
name | string | no | For apiKey — the header, query-parameter or cookie name; default X-API-Key. Unused by bearer/basic, whose placement is fixed to Authorization |
username | string | no | For basic. Not a secret; it identifies rather than authenticates |
valueRef | string | yes | OS-keychain key naming the credential — the bearer token, the Basic password, or the API key. Never the value |
There is no field here that holds a value. That is the point of it: before this existed, the
only way to express a static key was a raw Authorization: header typed into the request — a
literal credential committed to git, which is the worst possible outcome. The app’s editor
reflects that; the value input is write-only and is never read back.
AuthProvider
Section titled “AuthProvider”| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
kind | script | integration | request | oauth2 | awsSigV4 | yes | |
script | string | no | kind: script — workspace-relative path to the JS |
integration | keycloak | clerk | auth0 | firebase | supabase | no | kind: integration |
request | Request | no | kind: request — any protocol |
extract | Extraction (singular) | no | kind: request — the result is exposed as {{token}} |
oauth | OAuthConfig | no | kind: oauth2 |
aws | AwsSigV4Config | no | kind: awsSigV4 |
config | map<string, string> | no | Non-secret only: baseUrl, realm, clientId, tokenUrl, scope, domain, grant… |
secretRefs | string[] | no | Keychain key names, never values |
senders | AuthSender[] | no | Empty means one implicit identity |
inject | AuthInject | no | Default when absent: Authorization: Bearer {{token}} |
keycloak and auth0 take config.grant of password or client_credentials. firebase and
supabase exchange email and password for a token. Those five integrations are the whole
list and will stay the whole list — a generic authorization server belongs behind
kind: oauth2, not a sixth integration.
A provider’s kind and integration are fixed when it is created and cannot be edited
afterwards; changing approach means a new provider.
OAuthConfig
Section titled “OAuthConfig”For kind: oauth2 — the vendor-neutral path, for any conformant OAuth 2.0 / OIDC server.
| Field | Type | Required | Notes |
|---|---|---|---|
grant | authorizationCode | clientCredentials | deviceCode | refreshToken | yes | |
clientId | string | yes | Public by specification — not a secret |
issuer | string | no | Endpoints are discovered from {issuer}/.well-known/openid-configuration |
authorizationUrl | string | no | Explicit endpoint; overrides discovery |
tokenUrl | string | no | Explicit endpoint; overrides discovery |
deviceAuthorizationUrl | string | no | Explicit RFC 8628 endpoint; overrides discovery |
userinfoUrl | string | no | Explicit endpoint; also the first source of senders (the signed-in subject) |
sendersUrl | string | no | An admin/directory endpoint returning users, so a generic provider can populate senders the way the named integrations do |
clientSecretRef | string | no | Keychain key. Omitted for public clients |
refreshTokenRef | string | no | Keychain key holding a seed refresh token for grant: refreshToken. A rotated replacement is kept in memory for the session and never written back |
scopes | string[] | no | Sent space-joined |
audience | string | no | For APIs that need one to issue a non-opaque access token |
redirectPort | number | no | Fixed loopback port for authorizationCode, when the server requires an exact registered redirect_uri. Absent or 0 = an ephemeral port on 127.0.0.1 |
redirectPath | string | no | Path of the loopback redirect URI; default /callback |
pkce | boolean | no | PKCE (S256) on the authorization-code flow. Defaults to true |
clientAuth | basic | post | none | no | How the client authenticates at the token endpoint. Default: basic when there is a clientSecretRef, else none |
extraParams | map<string, string> | no | Appended verbatim to the authorization and token requests (prompt, resource, acr_values, a vendor’s own). An open map — the server’s vocabulary, not ours |
tokenField | string | no | Which JSON field of the token response carries the credential. Default access_token; id_token for APIs that want the OIDC identity token |
The explicit *Url fields exist for servers with no discovery document, or a wrong one. When
both are present, the explicit value wins.
AwsSigV4Config
Section titled “AwsSigV4Config”For kind: awsSigV4. This kind signs the request; it does not produce a token.
| Field | Type | Required | Notes |
|---|---|---|---|
region | string | yes | {{var}} allowed |
service | string | yes | execute-api, s3, lambda, … {{var}} allowed |
accessKeyIdRef | string | yes | Keychain key |
secretAccessKeyRef | string | yes | Keychain key |
sessionTokenRef | string | no | Keychain key, for temporary credentials obtained elsewhere. Sent as x-amz-security-token |
stsEndpoint | string | no | Used for assume-role resolution. Default https://sts.amazonaws.com |
A SigV4 signature covers the final method, path, canonical query string, a set of headers and a
hash of the body, so it cannot be expressed as an AuthInject template — a
template is rendered from variables, a signature is a function of the bytes being sent. The
engine applies it as the last step of request preparation. Setting inject on an awsSigV4
provider is an error, not a field that gets quietly dropped.
Its senders are assume-role targets, carried in the ordinary AuthSender.vars
rather than a new field: roleArn (required), plus optional sessionName, externalId,
durationSeconds and a region override. Selecting the sender makes the engine call STS
AssumeRole with the provider’s base credentials and sign with the temporary ones — which is how
“send as the read-only role” becomes a dropdown. A provider with no senders signs with the base
credentials directly. The app relabels that provider’s Senders tab to Roles for the same
reason.
AuthSender
Section titled “AuthSender”| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
label | string | no | |
tenant | string | no | An informational label only, not a selection axis |
secretRef | string | no | This identity’s keychain key name |
vars | map<string, string> | no | Non-secret per-sender values, available as {{var}} while resolving the token — and the assume-role fields for an awsSigV4 provider |
AuthInject
Section titled “AuthInject”| Field | Type | Required | Notes |
|---|---|---|---|
target | header | query | cookie | body | variable | yes | |
name | string | no | Header, query-parameter or cookie name; for body, a JSON dot-path such as auth.token. Unused by variable |
template | string | no | e.g. Bearer {{token}}, or bare {{token}}. Unused by variable |
The two newer targets:
bodysets a dot-path inside the JSON body — the HTTP body,grpc.message, orgraphql.variables. An existing value already at that path wins.variableinjects nothing at all. It exposes{{token}}so you can place it by hand, which is the honest shape for an API whose credential goes somewhere no target describes.
SpecSource
Section titled “SpecSource”| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
location | string | yes | What it points at depends on kind |
kind | openapi | grpcReflection | graphql | mcp | no | Absent means openapi |
pollIntervalSecs | number | no | Absent or 0 = never poll |
routeMatcher | RouteMatcherRef | no |
location by kind: a URL or file path for openapi; the grpc:///grpcs:// target whose
reflection service is queried for grpcReflection; the endpoint the introspection query is
POSTed to for graphql; the streamable-HTTP endpoint whose tools/list is called for mcp.
pollIntervalSecs is the field that turns reflection and introspection from a one-shot import
into a live binding — with it set, the source is re-resolved on a timer and consecutive polls can
be diffed.
RouteMatcher
Section titled “RouteMatcher”| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | |
kind | kong | nginx | envoy | yes | |
config | string | yes | The raw gateway config, stored inline |
overrides | { upstream, from, to }[] | no | All three required. from is the backend path, to the gateway-facing one |
transcodes | boolean | no | gRPC↔JSON transcoding; auto-detected for Envoy |
RouteMatcherRef
Section titled “RouteMatcherRef”| Field | Type | Required | Notes |
|---|---|---|---|
matcher | string | yes | |
upstream | string | no | Omit to auto-match the spec’s server URL against route upstreams |
gatewayUrl | string | no | scheme://host[:port]; {{var}} allowed |
Pipeline
Section titled “Pipeline”The original linear chaining path. It is still in the format, but chains are the chaining surface in the app.
| Type | Field | Required |
|---|---|---|
Pipeline | name | yes |
steps | no | |
PipelineStep | request — a Request name in the same collection | yes |
extract | no |
Extraction
Section titled “Extraction”| Field | Type | Required | Notes |
|---|---|---|---|
var | string | yes | |
from | body | header | status | yes | |
path | string | no | A path expression for body, header name for header, unused for status |
A separate file — .tinspec/chains/<name>.yaml, formatVersion: 3.
| Field | Type | Required |
|---|---|---|
formatVersion | number | yes |
name | string | yes |
nodes | ChainNode[] | yes |
edges | ChainEdge[] | yes |
settings | ChainSettings | no |
ChainSettings
Section titled “ChainSettings”Run-wide policy — what a CI runner needs to say about a whole chain rather than about one node. Absent means every default, which is exactly what chains did before this block existed.
| Field | Type | Notes |
|---|---|---|
timeoutMs | number | Wall-clock bound on the run. Absent or 0 = unbounded. The teardown region is not counted against it |
onError | abort | continue | The default for a node that does not declare its own. Absent = abort |
teardownTimeoutMs | number | Bound on the teardown region — its own clock, default 30 s. A chain that timed out after ten minutes must not wait another ten to clean up |
The chain timeout is applied per node rather than around the whole region, so the failure
names the node that was running when the clock ran out. It is deliberately not catchable: a
timeout a node’s onError could swallow would leave the run walking its graph past the deadline.
ChainNode
Section titled “ChainNode”| Field | Type | Required | Applies to |
|---|---|---|---|
id | string | yes | all — edges reference it |
type | see below | yes | all |
position | { x, y }, both required | yes | all |
request | ChainRequestRef | no | request |
extract | Extraction[] | no | request |
utility | { fn, var, args? } | no | generator |
rules | { expr?, port, label? }[] | no | router — ordered, first match wins; an empty expr always matches |
config | map<string, string> | no | Per-kind settings |
script | string | no | script — async function run(vars, ctx); returned keys become outputs |
assertions | Assertion[] | no | assert |
fields | { var, value }[] | no | set |
ports | NodePorts | no | all |
onError | abort | continue | no | all — overrides settings.onError |
region | main | teardown | no | all — absent means main |
type is one of the sixteen kinds: request, input, environment, math,
transform, generator, set, aggregate, script, assert, condition, router,
loop, merge, split, delay.
config keys by kind: input → value; environment → key; math → expr, var;
transform → op, expr, var; condition → expr; delay → ms; loop → mode,
count, listVar, itemVar, indexVar; aggregate → fromVar, intoVar.
ChainRequestRef is either collection (workspace-relative path) + name, or inline
(a whole Request). If both are given, inline wins.
utility.fn is uuid, randomInt (min/max), randomString (length), timestamp
(format: unix or iso), sequence (start/step), or list (values, comma-separated).
NodePorts: inputEnabled, outputEnabled (both default true), inputs / outputs as
{ var, from } mappings (empty means pass everything through), retry, retryDelayMs.
onError is consulted only for an unhandled failure. An outgoing edge whose
sourceHandle is error is a declared catch, and a node with one never aborts the run whatever
the policy says — declaring where a failure goes is the statement that it is handled.
region: teardown is a marker on a node, not a node kind. The region is computed by forward
reachability, so marking the first node of a cleanup branch puts the whole branch in it. Teardown
runs after a pass, after a failure and after a timeout, against the main region’s final
variable scope, and its own failure never replaces the one it was cleaning up after. It does not
survive a killed process — a cancelled CI job leaves fixtures behind.
ChainEdge
Section titled “ChainEdge”| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | |
source | string | yes | |
target | string | yes | |
sourceHandle | string | no | true/false for a condition, a rule port for a router, body/done for a loop, error for a catch |
Versioning is enforced, not just recorded
Section titled “Versioning is enforced, not just recorded”formatVersion used to be written and never checked. It is now the seam it was always meant to
be, in both directions.
A file from an older Tinspec is migrated up on load. Every version step is listed explicitly in the schema, even when it is the identity, so “that change was additive” is a recorded decision rather than an assumption.
A file from a newer Tinspec is read, not rejected. Fields this build does not model are pruned and reported, so a teammate on a newer version does not hand you a file that simply fails to open. Loading produces a structured warning of one of three kinds:
| Warning | Means |
|---|---|
migrated | The file declared an older version and was upgraded |
unknownFieldsIgnored | Fields were dropped, and the warning lists their exact dotted paths |
newerFormatVersion | The file came from a newer build than this one |
Saving such a file is refused. It was loaded with fields ignored, so writing it back would silently delete a teammate’s data — and re-injecting ignored subtrees by path is unsound once list items move. Refusing leaves the file on disk untouched, which is the only lossless option.
An unknown field in a file at or below the known version is still an error, not a warning: there it is a typo, not a future.
Diff hygiene
Section titled “Diff hygiene”The writer emits only what is set, in a stable order. An empty tests list is dropped rather
than written as tests: []; useCookieJar: true is stored as absent because it is the
default; an absent grpc.descriptorSource keeps its meaning of proto rather than being
written out. A one-header change should be a one-line diff — if it is not, that is a bug worth
reporting.