hen 0.17.0

Run protocol-aware API request collections from the command line or through MCP.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
# Syntax Reference

## Variables
<!-- markdownlint-disable MD014 -->
```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]
```
<!-- markdownlint-enable MD014 -->

- 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
~~~
```

## 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`.
- `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
```