# Syntax Reference
## Variables
```text
$ variable_name = value
$ variable_name = $(command)
$ variable_name = secret.env("NAME")
$ variable_name = secret.file("./path/to/value.txt")
$ variable_name = [[ prompt_name ]]
$ variable_name = [[ prompt_name = default_value ]]
$ variable_name = [foo, bar]
```
- Array assignments expand the request once per value. When a request references multiple array variables, Hen runs the Cartesian product of those values (up to two variables and 128 total combinations).
- Arrays must contain simple scalar values (no whitespace or nested arrays). Their values can still come from commands or prompts.
- Prompt placeholders may include defaults with `[[ name = default ]]`. Defaults are plain text up to the closing `]]`, so URL-shaped values such as `[[ ws_origin = wss://example.com ]]` are valid.
- Requests generated from arrays are suffixed with the selected values (for example, `[USER=foo]`). A failing iteration aborts the remaining iterations, and exports from each iteration are suffixed with the same label.
- Requests cannot declare dependencies on a mapped request; share setup through an unmapped helper instead.
## Local Secret Providers
```text
$ API_TOKEN = secret.env("HEN_API_TOKEN")
$ CLIENT_ID = secret.file("./secrets/client_id.txt")
```
- `secret.env("NAME")` reads the named environment variable when the collection is prepared for execution.
- `secret.file("PATH")` reads a UTF-8 text file relative to the collection working directory and strips one trailing line ending.
- Repeated secret references are cached once per run after the first lookup.
- Secret references are valid anywhere this slice accepts scalar assignments, including collection variables, request variables, and environment overrides.
- Secret reference arguments are string literals in this slice. Interpolation such as `secret.env("{{ NAME }}")` or `secret.file("[[ path ]]")` is rejected.
- `hen verify` parses and validates secret references without reading environment variables or files.
- Supported providers in this slice are `env` and `file`. Remote secret backends are out of scope, and OS keychain is not implemented yet.
## Redaction Rules
```text
redact_header = X-Session-Token
redact_capture = SESSION_ID
redact_body = body.session.accessToken
redact_body = json(body.payload).token
```
- Redaction rules are only valid in the collection preamble before the first `---`.
- Built-in masking already covers `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`, and API-key style headers, plus values loaded through `secret.env(...)` and `secret.file(...)`.
- `redact_header = NAME` adds one exact header name to the masked header set for text output, structured reports, transcripts, and retained artifacts.
- `redact_capture = NAME` treats captured or exported values under that name as sensitive in the current request and in downstream dependent requests that reuse the exported value.
- `redact_body = ...` accepts the same body-path syntax used by captures, but it must resolve from the current response body. Valid forms start from `body...` or `json(body...). ...`.
- Redaction rules are additive. They broaden the default safe-output policy and do not disable the built-in masking rules.
- `hen verify` validates the directive syntax and body-path shape without resolving any live values.
## Named Environments
```text
$ API_ORIGIN = https://api.example.com
$ CLIENT_ID = [[ client_id ]]
env local
$ API_ORIGIN = http://localhost:3000
$ CLIENT_ID = hen-local
env staging
$ API_ORIGIN = https://staging.example.com
```
- Environment blocks are only valid in the collection preamble before the first `---`.
- Environment overrides may only target previously declared scalar variables.
- Environment values in this milestone must stay scalar; arrays, shell substitutions, and nested environment syntax are rejected.
- Select an environment with `hen run --env <name>` or with the MCP `run_hen` tool's `environment` argument.
- If no environment is selected, Hen uses the collection defaults.
- `hen verify` reports `availableEnvironments`, and structured run output reports `selectedEnvironment`.
Resolution order is:
1. Collection preamble scalar assignments.
2. The selected named environment.
3. Explicit CLI `--input key=value` or MCP `inputs` values for prompt placeholders.
4. Prompt defaults declared with `[[ name = default ]]`.
5. Runtime dependency captures and callback exports for downstream requests.
## OAuth Profiles
```text
oauth api
grant = client_credentials
issuer = https://login.example.com
client_id = secret.env("HEN_CLIENT_ID")
client_secret = secret.env("HEN_CLIENT_SECRET")
scope = read:profile
param audience = https://api.example.com
access_token -> $API_ACCESS_TOKEN
token_type -> $API_TOKEN_TYPE
---
Get profile
auth = api
GET https://api.example.com/me
```
- `oauth <name>` blocks are only valid in the collection preamble before the first `---`.
- `auth = <name>` attaches the named OAuth profile to one request.
- Supported grants in this slice are `client_credentials` and `refresh_token`.
- A profile must define exactly one endpoint source: `issuer = ...` or `token_url = ...`.
- `issuer = ...` performs OIDC discovery at run time and caches the discovered `token_endpoint` for the current run.
- `param name = value` adds an extra form field to the token request.
- `<field> -> $VARIABLE` maps token-response fields into ordinary scalar exports that the current request and downstream dependent requests can reference.
- Hen injects `Authorization: Bearer ...` automatically unless the request already sets `Authorization` explicitly.
- `refresh_token` profiles remember a rotated refresh token returned by the token endpoint and use it for later refreshes in the same run.
- Discovery and token acquisition never run during `hen verify`; verification stays structural.
- Mapped export names participate in the existing built-in sensitive-export masking rules and any configured `redact_capture` rules, while `Authorization` headers are already redacted by default.
## Headers
```text
* header_key = header_value
* header_key = {{ variable_name }}
* header_key = [[ prompt_name ]]
```
Request-level headers override global preamble headers when the header name matches exactly.
Use the same casing when overriding a global header; case-only differences are not a reliable override.
## Query Parameters
```text
? query_key = query_value
? query_key = {{ variable_name }}
? query_key = [[ prompt_name ]]
```
## Multipart Form Data
```text
~ form_key = form_value
~ form_key = {{ variable_name }}
~ form_key = [[ prompt_name ]]
~ form_key = @/path/to/file
```
## Request Body
```text
~~~ [content_type]
body
~~~
```
## Request Reliability
```text
timeout = 30s
poll_every = 1s
---
Wait for export
GET https://example.com/exports/{{ JOB_ID }}
timeout = 5s
poll_until = 2m
poll_every = 2s
^ & status == 200
^ & body.state == "completed"
```
- `timeout = ...` may appear in the preamble or on a request. It applies per attempt and defaults to `30s`.
- `poll_until = ...` may appear in the preamble or on a request. It sets the total polling window across attempts and is disabled by default.
- `poll_every = ...` may appear in the preamble or on a request. It sets the fixed retry interval and defaults to `1s` whenever polling is enabled and no interval is provided.
- Request-level reliability directives override only the fields they declare and inherit the remaining preamble defaults.
- Durations currently accept `ms`, `s`, or `m` suffixes such as `250ms`, `2s`, or `1m`.
- When polling is enabled, Hen reruns the same request and reevaluates its ordinary assertions on each attempt.
- Polling retries only after assertion failures or per-attempt request timeouts. Transport failures remain terminal unless a separate retry policy is added later.
- `within = ...` remains separate from request reliability. Use `within` only for SSE `receive` steps and WebSocket `receive` or `exchange` steps.
- When the polling window expires, Hen reports the last assertion mismatch or timeout detail rather than a generic poll exhaustion message.
- See [examples/request_reliability.hen](examples/request_reliability.hen) for a complete collection that combines preamble defaults, a captured job ID, and a polled follow-up request.
## Assertion Labels
```text
# The page loads
^ & status == 200
# User payload is present
[FEATURE_ENABLED == true] ^ & body.user.id == "123"
```
- A `# ...` comment directly above an assertion attaches a short plain-text label to that assertion only.
- Labels appear in successful text CLI output so passing assertions can describe intent instead of repeating the raw expression.
- Labels are literal text and are not interpolated.
- Blank lines or other request items break the association.
## Protocol Directives
```text
protocol = graphql
operation = GetUser
variables = {"id":"123"}
protocol = mcp
session = app
call = initialize
tool = search
arguments = {"query":"hedgehog"}
protocol_version = 2025-11-25
client_name = hen
client_version = 0.14.0
capabilities = {}
protocol = sse
session = prices
receive
within = 5s
protocol = ws
session = chat
~~~json
{"type":"hello"}
~~~
```
- If `protocol` is omitted, the request is ordinary HTTP.
- `protocol = graphql` enables GraphQL authoring while still executing over HTTP.
- `protocol = mcp` enables MCP-over-HTTP authoring while still executing over HTTP.
- `protocol = sse` enables server-sent event streams with an opening request plus session-backed `receive` steps.
- `protocol = ws` enables session-backed WebSocket open, `send`, `exchange`, and `receive` steps.
- GraphQL requests currently require `POST`.
- MCP requests currently require `POST`.
- SSE opening requests currently require `GET`.
- WebSocket opening requests currently require `GET`.
- `variables = ...` must be valid JSON after interpolation.
- `call = ...` selects the MCP JSON-RPC method for the request.
- `session = ...` names a reusable session handle. For ordinary HTTP requests, steps sharing the same session reuse one cookie jar. When a later session-backed request omits its method/URL line, Hen inherits the most recent method/URL used for that session.
- For `protocol = ws`, a body block implies a send step. Plain `~~~` or `~~~text`/`~~~text/plain` sends text, while `~~~json` or `~~~application/json` sends JSON.
- `receive` is valid for `protocol = sse` and `protocol = ws` steps.
- For `protocol = ws`, adding `within = ...` to a body-backed step turns it into an exchange step.
- `within = ...` is valid for `protocol = sse` receive steps, `protocol = ws` receive steps, and `protocol = ws` exchange steps, and currently accepts `ms`, `s`, or `m` suffixes such as `250ms`, `2s`, or `1m`. Use request-level `timeout`, `poll_until`, and `poll_every` for ordinary execution timeouts and polling.
- `tool = ...` and `arguments = ...` are only valid with `call = tools/call`.
- `protocol_version`, `client_name`, `client_version`, and `capabilities` are only valid with `call = initialize`.
- `arguments = ...` and `capabilities = ...` must be valid JSON objects after interpolation.
## HTTP Session Cookies
```text
Login
session = web
POST https://httpbin.org/cookies/set/session/hen-demo
# Login request succeeds
^ & status == 200
---
Load cookies
session = web
GET https://httpbin.org/cookies
# Reuses the session cookie
^ & body.cookies.session == "hen-demo"
```
- Reusing the same HTTP `session = ...` shares one cookie jar across those steps.
- Session-backed HTTP requests also get the same implicit planner ordering Hen already uses for MCP, SSE, and WebSocket session reuse.
- Cookie values are covered by the built-in redaction rules for `Cookie` and `Set-Cookie`.
## GraphQL Requests
```text
Get User GraphQL
protocol = graphql
POST https://example.com/graphql
operation = GetUser
variables = {"id":"123"}
~~~graphql
query GetUser($id: ID!) {
user(id: $id) {
id
}
}
~~~
^ & graphql.errors == null
^ & graphql.data.user.id == "123"
```
- Use `~~~graphql` or `~~~application/graphql` for the document block.
- GraphQL responses still use the normal `body`, `header`, and `status` accessors.
- `graphql.data...` and `graphql.errors...` are aliases for the corresponding JSON body paths.
- Callback environments receive `GRAPHQL_DOCUMENT`, `GRAPHQL_OPERATION`, and `GRAPHQL_VARIABLES` for GraphQL requests.
## MCP Requests
```text
Connect MCP
protocol = mcp
session = app
POST https://example.com/mcp
* Authorization = Bearer [[ mcp_token ]]
call = initialize
^ & body.result.serverInfo.name == "fixture-mcp"
---
List resources
protocol = mcp
session = app
call = resources/list
^ & body.result.resources[0].name == "Fixture Resource"
---
Call tool
protocol = mcp
session = app
call = tools/call
tool = search
arguments = {"query":"hedgehog"}
^ & body.result.content[0].text == "search result"
```
- Supported `call = ...` values in the current slice are `initialize`, `tools/list`, `resources/list`, and `tools/call`.
- `call = initialize` defaults `protocol_version` to Hen's preferred MCP version, `client_name` to `hen`, `client_version` to the running Hen build version, and `capabilities` to `{}` unless you override them.
- MCP request bodies are generated automatically as JSON-RPC envelopes, so explicit body blocks, content-type blocks, and multipart form directives are not allowed.
- MCP authentication currently uses ordinary request headers.
- MCP responses still use the normal `body`, `header`, and `status` accessors, so assertions and captures can target `body.result...` and `body.error...` directly.
- When an MCP tool returns stringified JSON inside a text field, use `json(...)` to decode it before traversing or asserting on nested fields, for example `^ & json(body.result.content[0].text).items[0].id == "123"`.
- Session-backed steps add an implicit dependency on the session-creating request. When a later step omits its method/URL line, Hen inherits the most recent method/URL used for that session.
## SSE Requests
```text
Open price stream
protocol = sse
session = prices
GET https://example.com/prices/stream
^ & status == 200
---
Receive update
session = prices
receive
within = 5s
& sse.id -> $EVENT_ID
^ & sse.event == "price"
^ $EVENT_ID == "evt-1"
^ & body.symbol == "AAPL"
^ & body.price === NUMBER
```
- The opening SSE step establishes the named session and leaves the stream reader running in the background.
- If the opening step omits an explicit `Accept` header, Hen sends `Accept: text/event-stream` automatically.
- `receive` consumes the next event observed for the session within the configured timeout.
- When the event payload is valid JSON, ordinary `body...` JSON paths work for assertions and captures.
- Receive steps can also capture or assert `sse.event` and `sse.id` directly when the server sends them.
- When the payload is plain text, use the normal raw-body comparison or regex assertions.
- Callback environments include `SSE_ACTION`, `SSE_SESSION`, and, for receive steps that observed them, `SSE_EVENT` and `SSE_ID`.
- Structured JSON output preserves SSE protocol context such as `action`, `sessionName`, `within`, `event`, and `id` when available.
## WebSocket Requests
```text
Open socket
protocol = ws
session = chat
GET wss://example.com/chat
---
Send hello
session = chat
within = 2s
~~~json
{"type":"hello"}
~~~
& ws.kind -> $KIND
^ $KIND == "text"
^ & ws.kind == "text"
^ & body.type == "ack"
```
- The opening WebSocket step establishes the named session.
- A body block makes a WebSocket step a send step. Plain `~~~` or `~~~text`/`~~~text/plain` sends text, while `~~~json` or `~~~application/json` sends JSON.
- Adding `within = ...` to that body-backed step turns it into an exchange step that waits for the next queued message.
- Later session-backed `send`, `exchange`, and `receive` steps inherit the target line from the session-opening step.
- `receive` and `exchange` reuse the shared `within = ...` timeout grammar.
- `ws.kind` can capture or assert the observed reply frame type directly, while `body` contains the reply payload.
- Callback environments include `WS_ACTION`, `WS_SESSION`, and, for send, exchange, and receive steps, `WS_KIND`.
- Structured JSON output preserves WebSocket protocol context such as `action`, `sessionName`, `within`, and `kind` when available.
To run the repository WebSocket example end to end, use `hen ./examples/ws_protocol.hen all` because the file contains both the session-opening step and the exchange step.
## Declarations
Declarations live in the collection preamble before the first `---`. They are collection-local, and imported fragments brought in with `<<` can contribute reusable declarations.
## Fragments
```text
<< ./fragments/common_schema_types.hen
[ FEATURE_ENABLED == true ] << ./fragments/extra_assertions.hen
```
- `<< path.hen` imports another Hen file into the current collection before parsing.
- Fragment paths are resolved relative to the importing file.
- Imports can contribute reusable declarations or request-local snippets.
- Prefix a fragment import with `[predicate]` to include it only when the guard evaluates to true.
- Guarded fragment imports use the same guard rules described below.
```text
scalar Food = enum("pizza", "taco", "salad")
scalar HANDLE = string & len(3..24) & pattern(/^[a-z][a-z0-9_]*$/)
schema Address {
city: string
postalCode: string
}
schema User {
id: UUID
email: EMAIL
birthday?: DATE?
favoriteFood?: Food
address: Address
}
schema Users = User[]
```
- Built-in scalar targets are always available: `UUID`, `EMAIL`, `NUMBER`, `DATE`, `DATE_TIME`, `TIME`, and `URI`. Those names are reserved and cannot be redefined.
- `scalar` declarations can reference primitive types, built-in scalar targets, or named scalar declarations, then refine them with `enum`, `format`, `len`, `pattern`, and `range` predicates.
- Scalar and schema references are resolved after the full preprocessed collection is loaded, so forward references are allowed across the collection preamble and imported fragments.
- `schema` object fields are open by default in v1: extra fields are ignored during validation.
- `field?: Type` makes a field optional.
- `Type?` makes the value nullable.
- `schema Name = Type[]` defines a root-array schema.
## Response Captures
```text
& body -> $VAR_NAME
&[Dependency Request].body -> $VAR_NAME
& body.json_field -> $VAR_NAME
& body.items[0].name -> $VAR_NAME
& body.[0].id -> $VAR_NAME
& body.jobs[? recipient == "alice@example.com"].status -> $VAR_NAME
& json(body.result.content[0].text).items[0].id -> $VAR_NAME
& header.header_name -> $VAR_NAME
& status -> $VAR_NAME
```
Use `body.field` for object-root JSON responses.
Use `body.items[0].name` for arrays nested under object keys.
Use `body.[0].field` when the response body root is an array.
Use `json(...)` when a field contains JSON encoded as a string and you want to traverse the decoded structure.
Use `&[Request Name].body...`, `&[Request Name].header...`, or `&[Request Name].status` to read from a declared dependency response.
Capture paths support structured field access, numeric indices, and simple array filters of the form `[? path == value]` or `[? path != value]`.
Filter clauses may be combined with `&&`, operate on relative paths within the current array item, and must resolve to exactly one element.
Filters are intentionally narrow in v1: scalar literals or `$VARIABLE` references only, no regex or structural JSON matching inside filters, no `||`, and no jq or JSONPath projections.
Dependency response captures require the referenced request to be declared in `> requires:` so the planner can guarantee the upstream result exists.
## Assertions
`val` can be a variable or a response capture.
```text
^ & body.field == 'value'
^ & body.jobs[? recipient == "alice@example.com"].status == 'succeeded'
^ & sse.event == 'price'
^ & json(body.result.content[0].text).items[0].id == '123'
^ & json(body.result.content[0].text).items[0] ~= {"id":"123"}
^ & body.id === UUID
^ & body.total === NUMBER
^ & body === User
^ &[Login].body === LoginResponse
& body.field -> $VAR
^ $VAR == 'value'
```
```text
^ val == value
^ val != value
^ val ~= /regex/
^ val ~= {"id":7}
^ val ~= [{"id":1},{"id":2}]
^ val === SchemaName
^ val > value
^ val >= value
^ val < value
^ val <= value
```
- Bare `true`, `false`, and `null` are typed literals.
- Quoted values remain strings.
- Prefix an assertion with `[predicate]` to evaluate it conditionally.
- Response body assertions preserve JSON types during evaluation, including arrays, objects, `null`, and missing paths.
- `==` and `!=` compare structured JSON values structurally when both sides resolve to arrays or objects.
- `~=` performs substring or regex matching for string patterns, partial object matching for JSON object literals, unordered membership for array-vs-scalar JSON matches, and unordered subset matching for JSON array literals.
- `===` validates the left-hand JSON value against a built-in scalar, named scalar, or named schema target from the collection preamble.
- The right-hand side of `===` must be a declared target name, not a variable or quoted literal.
- `===` requires a typed JSON left-hand operand, so use response body captures or dependency body captures rather than plain string variables.
- `json(...)` decodes stringified JSON from a response path before the usual JSON traversal and assertion rules apply.
- Use `NUMBER` when you want a concise built-in target for any JSON number. For text validation, the current recommended path is a named scalar built from `string` with `len(...)`, `pattern(...)`, `enum(...)`, or `format(...)` as needed.
- Body capture operands can use the same filtered array selector syntax as response captures, including `$VARIABLE` filter values, with the same exact-one-match requirement.
- JSON object and array literals must be valid JSON with double-quoted keys and string values.
- Ordering operators only succeed for comparable numeric or text values.
## Conditional Guards
```text
[ USERNAME == "foo" ] ^ & body.username == "foo"
[ FEATURE_ENABLED != true ] << ./fragments/legacy_assertions.hen
```
- Guards apply to assertions and fragment imports.
- The guard predicate must appear in `[...]` immediately before the guarded assertion or `<<` import.
- Guard predicates use the same comparison operators and regex matching as ordinary assertions.
- Guard predicates may reference interpolated variables and typed literals such as `true`, `false`, and `null`.
- `===` schema validation is not supported inside guards.
- A false assertion guard marks that assertion as skipped.
- A false fragment guard omits that fragment import entirely.
## Callbacks
```text
! callback code
! ./path/to/callback_file.sh
! some command -> $VARIABLE_NAME
```
Assignments require whitespace around `->` and suppress the command's stdout.
## Dependencies
```text
> requires: Request Name
```