This page is the authoritative syntactic reference for Jaiph: lexical rules, statement shapes, EBNF, and the validator’s error catalog. For step semantics see Language. For the system around the grammar see Architecture.
Scope. Normal modules (.jh) and test modules (*.test.jh). For jaiph run / jaiph test, the compile path is loadModuleGraph → buildScriptsFromGraph(graph, outDir) (per-module validateModule + script emit via emitScriptsForModuleFromGraph). jaiph compile walks the same import closure through collectDiagnostics(graph) and emits no scripts. parsejaiph(source, filePath) is I/O-pure. buildRuntimeGraph consumes an already-loaded graph and never re-runs validation or re-reads .jh sources.
| Element | Rule |
|---|---|
| Identifier | [A-Za-z_][A-Za-z0-9_]*. |
| Reference | IDENT (local) or IDENT.IDENT (module-qualified). |
| Comment | Full-line # comment. Trailing # on a step line is not a comment. |
| Blank line | Preserved between steps inside workflow and rule bodies (as blank_line trivia). jaiph format collapses multiple consecutive body blanks to one and trims trailing blanks before }. Top-level blank lines are not preserved — the formatter emits one blank line between emitted sections. |
| Shebang | A #! first line of the file is ignored by the parser. |
| Single-line string | Double-quoted "…". Single-quoted strings are E_PARSE. Use \", \\, \n, \t. |
| Multiline string | Triple-quoted """…""". The opening """ must end the line; the closing """ must be on its own line. |
| Script body (single-line) | Backtick `…`. Jaiph ${identifier} / ${identifier.field} interpolation is E_PARSE; bash parameter expansion (for example ${var:-default}) passes through. |
| Script body (fenced) | Triple-backtick ``…``. Optional lang tag ``` |
| Required parentheses | All run / ensure call sites require parentheses, including zero-argument calls. Bare run setup is E_PARSE. |
file = { top_level } ;
top_level = config_block
| import_stmt
| import_script_stmt
| channel_decl
| env_decl
| rule_decl
| script_decl
| workflow_decl ;
env_decl is written const in source; the parser stores it under envDecls / module constants. Test modules may also contain test blocks — see Write & run tests.
| Construct | Formatter behaviour |
|---|---|
import, config, channel |
Hoisted to the top, in that order, preserving relative source order within each group. Comments immediately before a hoisted construct move with it. |
const, rule, script, workflow, test |
Keep their original relative source order. Comments before non-hoisted definitions stay in place. |
constenv_decl = "const" IDENT "=" env_value ;
env_value = double_quoted_string | triple_quoted_block | bare_value ;
Values: double-quoted string (single-line; multi-line double-quoted is E_PARSE), triple-quoted multiline, or a bare token (e.g. const N = 42 stores the string "42"). Top-level local is E_PARSE — use const. Top-level const shares the unified per-module namespace with channels, rules, workflows, and scripts.
import_stmt = "import" string "as" IDENT ;
import_script_stmt = "import" "script" string "as" IDENT ;
| Aspect | Rule |
|---|---|
| Module import path | Quoted string. .jh extension auto-appended when omitted. Relative resolution against the importing file’s directory. |
| Library fallback | When relative resolution finds no file and the path contains /, the path is split as <lib-name>/<sub-path> and resolved to <workspace>/.jaiph/libs/<lib-name>/<sub-path>.jh. |
| Script import path | Quoted string. Relative-only (no library fallback). The path refers to a raw script file (no .jh appended). |
| Missing target | E_IMPORT_NOT_FOUND at compile time. |
| Alias collision | Aliases share the unified namespace with channels, rules, workflows, scripts, and top-level const. Duplicates are E_VALIDATE. |
export |
Marks a top-level rule / workflow / script as public. If at least one export exists, only exported names are reachable through the alias (E_VALIDATE: "<name>" is not exported from module "<alias>"). Modules with zero export declarations have implicit-public semantics. |
channel_decl = "channel" IDENT [ "->" REF { "," REF } ] ;
One channel per line. A -> route declaration inside a workflow body is E_PARSE. Routes are stored on ChannelDef. Route targets must be workflows declaring exactly three named parameters (message, channel, sender). Multiple routes drain sequentially.
config_block = "config" "{" { config_line } "}" ;
config_line = config_key "=" config_value ;
config_value = string | "true" | "false" | integer ;
Allowed keys: agent.default_model, agent.command, agent.backend, agent.trusted_workspace, agent.cursor_flags, agent.claude_flags, run.logs_dir, run.debug, run.recover_limit, runtime.docker_image, runtime.docker_network, runtime.docker_timeout_seconds, module.name, module.version, module.description. See Configuration. Workflow-level config permits only agent.* and run.* (runtime.* / module.* are E_PARSE).
The opening line is config followed by { with optional whitespace between them. Duplicate blocks are E_PARSE (duplicate config block …). Unknown keys are E_PARSE listing the allowed keys. Wrong value types are E_PARSE.
Jaiph has two structurally distinct primitive types:
| Type | Operations |
|---|---|
string |
Interpolate (${…}), pass as argument, assign to const, send to prompt. |
script |
Invoke with run. Not interpolatable, not assignable to const by name, not a valid prompt body. |
Crossings (run on a string, prompt on a script, const x = scriptName, ${scriptName}) are E_VALIDATE with the specific message.
rule_decl = [ "export" ] "rule" IDENT "(" [ param_list ] ")" "{" { rule_body_step } "}" ;
script_decl = "script" IDENT "=" script_rhs ;
workflow_decl = [ "export" ] "workflow" IDENT "(" [ param_list ] ")" "{" [ workflow_config ] { workflow_step } "}" ;
param_list = IDENT { "," IDENT } ;
| Aspect | Rule |
|---|---|
| Definition parens | Required even when parameterless (e.g. rule check(), workflow default()). Omitting them is E_PARSE with a fix hint. |
| Parameter names | Identifier syntax, no duplicates, no reserved keywords. |
| Workflow body | May begin with an optional nested config { … } (must precede the first step). |
Script RHS:
script_rhs = backtick_script_body | fenced_script_block ;
backtick_script_body = "`" script_text "`" ;
fenced_script_block = "```" [ LANG_TAG ] newline { script_line newline } "```" ;
LANG_TAG = IDENT ;
The lang tag maps directly to #!/usr/bin/env <tag>. Combining a lang tag with a leading #! shebang in the body is an error. With neither, the emitter writes #!/usr/bin/env bash.
| Fence tag | Resulting shebang |
|---|---|
```bash `` | #!/usr/bin/env bash |
|
```node `` | #!/usr/bin/env node |
|
```python3 `` | #!/usr/bin/env python3 |
|
```ruby `` | #!/usr/bin/env ruby |
|
```perl `` | #!/usr/bin/env perl |
|
```pwsh `` | #!/usr/bin/env pwsh |
|
```deno `` | #!/usr/bin/env deno |
|
```lua `` | #!/usr/bin/env lua |
Any identifier tag is accepted; there is no hardcoded allowlist.
call_ref = REF "(" [ call_args ] ")" ;
call_args = call_arg { "," call_arg } ;
call_arg = double_quoted_string
| IDENT (* bare identifier: in-scope variable *)
| "${" IDENT "}"
| "run" ( call_ref | inline_script ) (* explicit nested managed call *)
| "ensure" call_ref ;
inline_script = backtick_script_body "(" [ call_args ] ")"
| fenced_script_block "(" [ call_args ] ")" ;
| Position | Rule |
|---|---|
| Bare identifier argument | Must reference an in-scope binding (const, capture, parameter). name and "${name}" are both accepted when the variable is in scope. Unknown names are E_VALIDATE. Jaiph keywords are rejected. |
| Nested managed calls | The run / ensure keyword is required. run foo(bar()) / run foo(rule_bar()) / run foo(\echo aaa`()) are E_VALIDATE. Valid: run foo(run bar()), run foo(ensure rule_bar()), run foo(run `echo aaa`())`. Capture-then-pass is always valid. |
| Arity | Workflows and rules: argument count must match the declared parameter list (E_VALIDATE), including () callees (zero arguments required). Scripts accept any argument count (no parameter list to check). |
| Shell redirection / pipes | Trailing >, >>, |, or & after a run / ensure call is E_PARSE. The same operators inside unquoted portions of call arguments are E_VALIDATE. Use a script for shell I/O. |
workflow_step = ensure_stmt | run_stmt | run_catch_stmt | run_recover_stmt | run_async_stmt
| prompt_stmt | const_decl_step | return_stmt
| fail_stmt | log_stmt | logerr_stmt | send_stmt
| match_stmt | if_stmt | for_lines_stmt | comment_line ;
Any line that does not match a managed form becomes a shell step in workflows. Rules forbid raw shell — the validator’s RULE_SCOPE rejects unrecognised shell lines with inline shell steps are forbidden in rules; use explicit script blocks.
runrun_stmt = "run" ( call_ref | inline_script ) ;
run_catch_stmt = "run" ( call_ref | inline_script ) "catch" catch_bindings catch_body ;
run_recover_stmt = "run" ( call_ref | inline_script ) "recover" recover_bindings recover_body ;
run_async_stmt = "run" "async" call_ref [ recover_suffix | catch_suffix ] ;
| Position | Allowed targets |
|---|---|
run in workflow |
Workflow or script. |
run in rule |
Script only. Workflows are E_VALIDATE. |
run async |
Workflows only. Inline scripts not supported. |
Inline script in run |
Both workflows and rules. |
Capture: a workflow callee yields the explicit return value; a script callee yields trimmed stdout.
ensureensure_stmt = "ensure" call_ref [ "catch" catch_bindings catch_body ] ;
Targets a rule. Succeeds when the rule’s exit code is 0. recover is not supported on ensure.
catch / recovercatch_bindings = "(" IDENT ")" ;
catch_body = workflow_step | "{" { workflow_step } "}" ;
recover_bindings = "(" IDENT ")" ;
recover_body = workflow_step | "{" { workflow_step } "}" ;
| Rule | Behaviour |
|---|---|
| Exactly one binding | Required. Bare catch / recover is E_PARSE. |
| Argument placement | All call arguments appear inside () before catch / recover. |
| Body content | Parsed by the same parseBlockStatement that handles top-level statements; rule scope still rejects shell. |
| Mutual exclusion | catch and recover are mutually exclusive on the same run step. |
| Inline-script attachment | catch / recover only attach to a standalone run step. Inline scripts in log / logerr / return / const RHS do not accept them. |
promptprompt_stmt = "prompt" prompt_body [ returns_schema ] ;
prompt_body = double_quoted_string | IDENT | triple_quoted_block ;
returns_schema = "returns" double_quoted_string ;
| Aspect | Rule |
|---|---|
| Body forms | Single-line string, in-scope identifier, or triple-quoted block. Triple-backtick fences in prompt context are E_PARSE. |
| Multiline form | Opening """ must end the line; closing """ must be on its own line. |
returns placement |
After a single-line or identifier body on the same line, or on the line after the closing """, or on the same line as the closing """ (nothing else may follow). |
returns schema |
Flat { field: type, … } with types string, number, boolean. Invalid schemas are E_SCHEMA. |
| Capture requirement | prompt … returns without const capture is E_PARSE. |
| Allowed in | Workflows only. Rules reject prompt and const … = prompt. |
constconst_decl_step = "const" IDENT "=" const_rhs ;
const_rhs = double_quoted_string | triple_quoted_block | bash_value_expr
| "run" ( call_ref | inline_script ) | "run" "async" call_ref
| "ensure" call_ref
| "prompt" prompt_body [ returns_schema ]
| "match" IDENT "{" { match_arm } "}" ;
| Position | Rule |
|---|---|
| Bare RHS | const x = ref(args) is E_PARSE. Use const x = run ref(args), const x = ensure ref(args), or const x = prompt …. |
Assignment without const |
name = run … / name = prompt … are E_PARSE. |
| Forbidden expansions on RHS | $(…), ${var:-fallback}, ${var%%…}, ${var//…}, ${#var} are E_PARSE. |
returnreturn_stmt = "return" return_value ;
return_value = double_quoted_string | triple_quoted_block | "$" IDENT | "${" IDENT "}"
| IDENT
| "run" ( call_ref | inline_script )
| "ensure" call_ref
| "match" IDENT "{" { match_arm } "}" ;
return run helper (no ()) becomes a shell return step — required to write return run helper() / return ensure check() for the managed form. Bare identifiers desugar to return "${ident}". Inline-script form requires the run keyword (return run \echo $1`(“arg”)). Numeric exit codes (return 0, return $?) are rejected in workflow/rule bodies; use them only in opaque script` definition bodies.
sendsend_stmt = IDENT "<-" send_rhs ;
send_rhs = double_quoted_string | triple_quoted_block | "$" IDENT | "${" … "}"
| "run" call_ref
| shell_fragment ;
| Rule | Behaviour |
|---|---|
| RHS required | Bare channel <- is E_PARSE. |
| Shell fragment RHS | A raw shell expression (for example findings <- echo "$payload") parses as a managed shell payload; allowed only on send (E_VALIDATE elsewhere). |
| Bare ref RHS | A bare ref-shaped word that names a workflow / rule / script is E_VALIDATE. Use run ref() or a string. |
run without () |
Does not parse as a managed send RHS. |
| Capture-and-send | const name = channel <- … is E_PARSE. |
| Allowed in | Workflows only. Rules forbid send. |
log / logerrlog_stmt = "log" ( double_quoted_string | triple_quoted_block | IDENT | "run" inline_script ) ;
logerr_stmt = "logerr" ( double_quoted_string | triple_quoted_block | IDENT | "run" inline_script ) ;
Bare identifier form expands to "${ident}". log run \…`(args) and logerr run `…`(args) execute the inline script and log its stdout — the run keyword is required (bare inline scripts in log / logerr are E_PARSE`).
failfail_stmt = "fail" ( double_quoted_string | triple_quoted_block ) ;
Aborts the workflow or rule with a stderr message and non-zero exit.
ifif_stmt = "if" subject_ref if_op if_operand "{" { workflow_step } "}" [ else_clause ] ;
else_clause = "else" "{" { workflow_step } "}" ;
subject_ref = IDENT | IDENT "." IDENT ;
if_op = "==" | "!=" | "=~" | "!~" ;
if_operand = double_quoted_string | "/" regex_source "/" ;
| Rule | Behaviour |
|---|---|
| Subject | Bare identifier or IDENT.IDENT (typed-prompt field access). Async handles resolve before the test. |
| Operator/operand pairing | == / != require a double-quoted string. =~ / !~ require a /regex/. Mixing is E_PARSE. |
else placement |
} else { must be on a single line. else if chaining is not supported — nest if inside the else block or use match. |
| Value production | if is a statement and does not produce a value. Use match for value branching. |
| Allowed in | Workflows and rules. |
matchmatch_stmt = "match" subject_ref "{" { match_arm } "}" ;
match_arm = match_pattern "=>" arm_body NEWLINE ;
match_pattern = double_quoted_string | "/" regex_source "/" | "_" ;
arm_body = double_quoted_string | triple_quoted_block
| IDENT
| "$" IDENT | "${" IDENT "}"
| "fail" double_quoted_string
| "run" call_ref
| "ensure" call_ref ;
| Rule | Behaviour |
|---|---|
| Subject | Bare identifier or IDENT.IDENT. Subject starting with $ / ${} is E_PARSE. |
| Default arm | Exactly one _ wildcard arm is required. |
| Arm delimiter | Newlines. Commas between or after arms are E_PARSE (commas are not allowed in match arms; use one arm per line). |
| Disallowed in arms | return (use return match … { … } at the outer level), inline scripts (use a named script with run), bare unknown identifiers (E_VALIDATE: unknown identifier "…" in match arm body). |
| Expression form | Usable as const x = match … or return match …. |
forfor_lines_stmt = "for" IDENT "in" IDENT "{" { workflow_step } "}" ;
| Rule | Behaviour |
|---|---|
| Source variable | Must already hold a string (const, capture, parameter). Unknown names are E_VALIDATE. |
| Line splitting | Splits on \n (normalises \r\n → \n). If the string ends with a final newline, the trailing empty segment is dropped. Interior empty lines are still yielded. |
| Iterator name | Subject to the same immutable-binding rules as const in the surrounding scope. |
| Allowed in | Workflows and rules. |
inline_script = backtick_script_body "(" [ call_args ] ")"
| fenced_script_block "(" [ call_args ] ")" ;
| Aspect | Rule |
|---|---|
| Allowed positions | run_stmt / run_catch_stmt / run_recover_stmt / log_stmt / logerr_stmt / return_stmt, and const RHS. |
run async |
Not supported with inline scripts. |
| Backtick interpolation | Jaiph ${identifier} / ${identifier.field} forms are E_PARSE. Bash parameter expansion passes through. Use $1, $2, … for positional arguments. |
| Fenced interpolation | All ${…} passes through to the shell (standard parameter expansion). |
catch / recover suffix |
Allowed only on standalone run steps with inline-script body. Forbidden in log / logerr / return / const RHS positions. |
| Emitted name | scripts/__inline_<hash> where <hash> is the first 12 hex digits of sha256(shebang + "\n" + body) (or sha256(body) if no shebang). Deterministic across runs. |
${IDENT} is the only interpolation form accepted in Jaiph orchestration strings. Every identifier must reference a const, capture, or named parameter.
| Form | Status | Where |
|---|---|---|
${varName} |
Primary | All orchestration strings. |
${var.field} |
Typed-prompt field access | All orchestration strings. The base must be a const x = prompt … returns "{ field: type, … }" capture and the field must appear in the schema. |
${run ref(args)} |
Inline capture — executes the call, inlines stdout / return value. | All orchestration strings. |
${ensure ref(args)} |
Inline capture — executes the rule, inlines result. | All orchestration strings. |
$varName (no braces) |
E_PARSE in orchestration strings. |
— |
$1, $2 |
Positional args | script bodies only. |
${var:-fallback} / ${var%%…} / ${var//…} / ${#var} |
E_PARSE in orchestration strings; passes through in script bodies (backtick and fenced). |
— |
$(…) |
E_PARSE in orchestration strings. |
— |
If an inline capture fails, the enclosing step fails. Nested inline captures (${run foo(${run bar()})}) are rejected — extract the inner call to a const.
Every step produces three distinct outputs — status, capture value, and logs.
| Step | Status | Capture value | Logs |
|---|---|---|---|
ensure rule |
rule exit code | explicit return value |
rule artifacts |
run workflow |
workflow exit code | explicit return value |
workflow artifacts |
run script (named) |
script exit code | trimmed stdout | script .out / .err |
run `…`() (inline) |
script exit code | trimmed stdout | script .out / .err |
prompt |
prompt exit code | final assistant answer | transcript artifacts |
log / logerr |
always 0 | empty | live event stream + stdout/stderr |
fail |
non-zero (abort) | empty | message to stderr |
run async |
aggregated | Handle<T> — resolves to return value on read |
async step artifacts |
const |
same as RHS step | empty (binds local) | n/a |
Validator entry points (src/transpile/validate.ts for the outer layer; src/transpile/validate-step.ts for the per-step visitor). The jaiph compile command surfaces all of these via collectDiagnostics.
| Code | Triggers |
|---|---|
E_PARSE |
Duplicate config; duplicate top-level names in the unified namespace; invalid keys/values; $(…) in orchestration strings; Jaiph ${identifier} interpolation in single-line backtick script bodies; prompt … returns without const capture; name = prompt … / non-const capture; bare ref(args) on const RHS (use run / ensure / prompt); top-level local; invalid send RHS; trailing shell redirection after run / ensure; arguments after catch / recover; bare catch / recover without binding; nested inline captures; removed wait keyword; invalid parameter names; missing { on definition line. |
E_SCHEMA |
Invalid returns schema — empty, non-flat, unsupported type. |
E_VALIDATE |
Unknown rule / workflow / script; duplicate import alias; inline shell in rules; ensure on non-rule; run to workflow inside rule; run async in rule; forbidden Jaiph usage inside $(…); dot notation on non-prompt variable or invalid field name; bare identifier argument referencing an unknown variable; ${ident} referencing an unknown variable in orchestration strings; arity mismatch; shell redirection (>, >>, |, &) inside unquoted call-argument text; bare nested managed calls; bare nested inline-script calls; type crossings (prompt on a script, run on a string, const x = scriptName, ${scriptName}). |
E_IMPORT_NOT_FOUND |
Import target does not exist (module or script). |
config block per file and per workflow. Workflow config must precede steps. Workflow config allows only agent.* / run.*.agent.backend must be cursor, claude, or codex.const share one namespace. Duplicate top-level names fail at parse time (E_PARSE); duplicate import aliases fail in validation (E_VALIDATE).ensure targets a rule. run in a workflow targets a workflow or script; run in a rule targets a script only. Same rules apply to return run and return ensure.send must resolve to declared channels. Route targets must be workflows with exactly three parameters. Inline routes in workflow bodies are E_PARSE.catch / recover argument ordering — all call args appear before catch / recover.>, >>, |, &) on run / ensure is rejected — trailing operators are E_PARSE; operators in unquoted call-argument text are E_VALIDATE.E_VALIDATE messages (see Types).run / ensure keywords. Bare nested forms are E_VALIDATE.for iter in source — source must name an in-scope variable.jaiph run and jaiph test do not transpile workflows to shell. buildScripts emits only per-script executable files under scripts/:
| Source form | Emitted artifact |
|---|---|
script name = \…`` (single-line) |
scripts/<name> with #!/usr/bin/env bash (or the fence-tag / manual shebang). |
script name = \`` |
scripts/<name> with #!/usr/bin/env <tag> or the manual #! line. |
run `body`(args) / runlang body(args) |
scripts/__inline_<12-hex> with the deterministic name from inlineScriptName. |
import script "path" as name |
No emission; the runtime resolves the existing file at runtime (shebangs preserved). |
Workflows, rules, prompts, channels, and control flow are interpreted by NodeWorkflowRuntime from the AST. There is no workflow-level shell emission. Script subprocesses inherit the runner’s process.env plus Jaiph metadata.
jaiph format — formatter rules and idempotence.