The CLI's native collection format is one JSON file — conventionally *.scoll.json —
built to be written by hand and reviewed in a pull request. This page is the reference:
suite shape, requests, assertions, captures, variables, data files, and the sidecar
that carries assertions for desktop-app exports. The behavior here is the normative
spec (docs/semantics.md in the CLI repo); when the desktop app gains execution, it
will implement these same rules. For installing and running the CLI itself, start with
the CLI guide.
The suite
{
"schemaVersion": 1,
"name": "Products API",
"baseUrl": "{{baseUrl}}",
"variables": { "baseUrl": "https://api.example.com/v2", "apiKey": "demo-key" },
"defaults": {
"timeout": 10000,
"headers": { "Accept": "application/json" },
"retries": { "attempts": 3, "delayMs": 200, "backoff": 2, "on": ["network", "5xx"] }
},
"items": [
{
"name": "Products",
"auth": { "type": "apikey", "name": "X-API-Key", "value": "{{apiKey}}", "location": "header" },
"items": [
{
"name": "List products",
"method": "GET",
"url": "/products/",
"params": { "page_size": "3" },
"expect": { "status": 200, "contentType": "application/json", "responseTime": 5000, "body": { "$.count": true } },
"capture": { "firstProductId": "$.results[0].id" },
"schema": "schemas/product.sc"
},
{
"name": "Get that product",
"method": "GET",
"url": "/products/{{firstProductId}}/",
"expect": { "status": 200, "body": { "$.id": { "matches": "^\\d+$" } } }
}
]
}
]
}
An entry in items that has its own items is a folder; folders nest to any
depth. A folder can carry auth (inherited by everything inside it), variables
(scoped to that subtree), and "parallel": true to run its requests concurrently.
Marking a folder parallel asserts its requests are independent — they all fire at
once, so none of them can rely on a sibling's captures.
defaults apply to every request: timeout in milliseconds (30000 when unset),
headers merged onto each request, and a retry policy. "retries": 3 is accepted as
shorthand for three attempts with the default backoff, retrying on network errors
and 5xx.
Requests
method defaults to GET. A relative url joins the suite's baseUrl with exactly
one / between them — and any path on the base is preserved:
| baseUrl | url | result |
| --- | --- | --- |
| https://api.example.com/v2 | /products | https://api.example.com/v2/products |
| https://api.example.com | /products | https://api.example.com/products |
This is deliberately not new URL() semantics, which would silently drop the /v2
prefix. A url with its own scheme (https://…) is used verbatim and ignores
baseUrl. Entries in params merge onto the URL's query string; a param with the
same name as one already in the URL replaces it.
body takes a type of json, raw (with optional contentType), form
(multipart, fields may be text or files), urlencoded, binary (a file path), or
graphql (query, optional variables and operationName). A bare object with no
type is shorthand for JSON.
auth supports basic, bearer, apikey (header or query), and oauth2 client
credentials. Resolution walks outward: the request's own auth, then the nearest
folder's, then the suite's. {"type": "none"} is an explicit value, not an
absence — set it on a request to opt out of inherited auth, which is how you call a
public endpoint inside an authenticated folder. A literal Authorization header on
the request always beats computed auth.
Requests also take their own timeout, retries, and "disabled": true to skip
without deleting. Pacing between requests is a run-level concern — the --delay <ms>
flag — not a per-request field.
Assertions: expect
The expect block is a shorthand object; each key becomes one assertion:
| Key | Accepted forms |
| --- | --- |
| status | 200 · [200, 201] · "2xx" (also 3xx/4xx/5xx) |
| contentType | substring of the Content-Type header |
| responseTime | maximum milliseconds |
| headers | {"X-Id": true} exists · "value" equals · {"matches": "regex"} |
| body | {"$.path": true} exists · a value equals · {"matches": "regex"} · {"contains": "substring"} |
Body paths are JSONPath. An explicit array-of-assertions form also exists, but the shorthand covers what suites actually need.
A request with no expect block is not a free pass: a 4xx or 5xx response
fails it by default. To assert that an error status is correct, state it —
"expect": { "status": 404 }.
Chaining with capture
capture pulls values out of a response into variables for later requests:
"capture": { "token": "$.access_token" }
The shorthand reads from the JSON body. The explicit form reaches headers, cookies, and the status code:
"capture": [
{ "name": "requestId", "from": "header", "path": "X-Request-Id" },
{ "name": "session", "from": "cookie", "path": "sessionid" },
{ "name": "code", "from": "status" }
]
A captured value is available as {{token}} in every subsequent request. Captures run
before assertions, so a failing request still reports what it captured; a capture
that matches nothing is reported as a problem, not silently skipped.
Variables
Interpolation is {{name}} anywhere in URLs, params, headers, bodies, auth
credentials, and assertion values — but not in schema references, because a contract
that varies per run is not a contract. Lookup walks a chain where later scopes
win: SCHEMACLIENT_VAR_* environment variables (lowest), the config file, suite
variables, folder variables, an --env file, the current data-file row, --var
flags, captures, then script writes (highest). Two placements are deliberate: ambient
environment values rank low so a value written in the collection beats one exported in
your shell, and --var beats the data file so an operator can pin a value across all
iterations.
Generators are evaluated fresh at each occurrence: {{$uuid}} (alias {{$guid}}),
{{$timestamp}} (Unix ms), {{$isoTimestamp}}, and {{$randomInt}} (0 to 999999).
Under --seed <n> they become deterministic per iteration, so runs reproduce.
Write \{{name}} for a literal {{name}} — needed when the API's own templating uses
the same delimiters. An unresolved variable is an error and the request is not
sent, so a typo names itself instead of surfacing as a DNS failure against a host
called {{baseurl}}. Relax with --unresolved=warn or ignore if you must.
Data files
-d users.csv (or .json) runs the collection once per row, with each row's columns
as variables. CSV columns may declare a type so values are not sent as quoted strings:
username,expected_id:number,active:boolean,meta:json
ada,1,true,"{""role"":""admin""}"
grace,2,false,"{}"
JSON data files are arrays of objects and keep their native types with no annotation. Because assertion values interpolate, a row can carry the input and the expected output:
"expect": { "body": { "$.id": "{{expected_id}}" } }
Schema assertions and the desktop sidecar
A request's schema field validates the response against a schema file — the same
engine and .sc language the desktop app uses.
The string form names a file; the object form adds options:
"schema": { "file": "schemas/product.sc", "schemaName": "Response", "strict": true }
Collections exported from the desktop app run in the CLI directly — but they cannot
hold assertions. The app (and its sync backend) rebuild collections from a fixed
field list, so any extra field like expect is silently dropped the next time the
collection is opened or synced. Instead, assertions for an exported collection live in
a sidecar next to it — collection.json gets collection.tests.json — keyed by item
id:
{
"entries": {
"req_a1b2c3": {
"expect": { "status": 200, "body": { "$.id": true } },
"schema": { "file": "schemas/product.sc" }
}
}
}
The sidecar is picked up automatically; --tests <file> points elsewhere and
--no-tests ignores it. The app owns the requests, the sidecar owns the CI contract,
and neither tool can destroy the other's data. One sharp edge: recreating a request in
the app gives it a new id, so its sidecar entry stops matching — the CLI warns about
unmatched entries and keeps them in the file rather than discarding your assertions.