Harn language specification

Version: tracks the workspace 0.8.x series; derived from the implementation and updated alongside it. The language is still pre-1.0 — surface-level breaking changes are possible between minor releases. See the changelog for what changed and when, and the Stability column in subsections below for per-feature guarantees.

Harn is a pipeline-oriented programming language for orchestrating AI agents. It is implemented as a Rust workspace with a lexer, parser, type checker, tree-walking VM, tree-sitter grammar, and CLI/runtime tooling. Programs consist of named pipelines containing imperative statements, expressions, and calls to registered builtins that perform I/O, LLM calls, and tool execution.

This file is the canonical language specification. The hosted docs page docs/src/language-spec.md is generated from it by scripts/sync_language_spec.harn.

Lexical rules

Whitespace

Spaces (' '), tabs ('\t'), and carriage returns ('\r') are insignificant and skipped between tokens. Newlines ('\n') are significant tokens used as statement separators. The parser skips newlines between statements but they are preserved in the token stream. Semicolons (';') are also accepted as optional statement separators in statement-list contexts (top-level items, block statements, tool bodies, and skill fields), but they are non-canonical input syntax. harn fmt normalizes them back to newline-separated form.

Backslash line continuation

A backslash (\) immediately before a newline joins the current line with the next. Both the backslash and the newline are removed from the token stream, so the two physical lines are treated as a single logical line by the lexer.

let total = 1 + 2 \
  + 3 + 4
// equivalent to: let total = 1 + 2 + 3 + 4

This is useful for breaking long expressions that do not involve a binary operator eligible for multiline continuation (see "Multiline expressions").

Comments

// Line comment: everything until the next newline is ignored.

/* Block comment: can span multiple lines.
   /* Nesting is supported. */
   Still inside the outer comment. */

Block comments track nesting depth, so /* /* */ */ is valid. An unterminated block comment produces a lexer error.

Keywords

The following identifiers are reserved:

KeywordToken
pipeline.pipeline
extends.extends
override.overrideKw
let.letKw
const.constKw
var.varKw
if.ifKw
else.elseKw
for.forKw
in.inKw
match.matchKw
retry.retry
parallel.parallel
defer.defer
return.returnKw
import.importKw
true.trueKw
false.falseKw
nil.nilKw
try.tryKw
catch.catchKw
throw.throwKw
finally.finally
fn.fnKw
emit.emit
spawn.spawnKw
while.whileKw
type.typeKw
enum.enum
eval_pack.evalPack
struct.struct
interface.interface
pub.pub
from.from
to.to
tool.tool
skill.skill
exclusive.exclusive
guard.guard
require.require
deadline.deadline
yield.yield
mutex.mutex
break.break
continue.continue
select.select
impl.impl
request_approval.requestApproval
dual_control.dualControl
ask_user.askUser
escalate_to.escalateTo

Identifiers

An identifier starts with a letter or underscore, followed by zero or more letters, digits, or underscores:

identifier ::= [a-zA-Z_][a-zA-Z0-9_]*

Number literals

int_literal   ::= digit+
float_literal ::= digit+ '.' digit+

A number followed by . where the next character is not a digit is lexed as an integer followed by the . operator (enabling 42.method).

Duration literals

A duration literal is an integer followed immediately (no whitespace) by a time-unit suffix:

duration_literal ::= digit+ ('ms' | 's' | 'm' | 'h' | 'd' | 'w')
SuffixUnitEquivalent
msmilliseconds--
sseconds1000 ms
mminutes60 s
hhours60 m
ddays24 h
wweeks7 d

Duration literals evaluate to an integer number of milliseconds. They can be used anywhere an expression is expected:

sleep(500ms)
deadline 30s { /* ... */ }
let one_day = 1d       // 86400000
let two_weeks = 2w     // 1209600000

String literals

Single-line strings

string_literal ::= '"' (char | escape | interpolation)* '"'
escape         ::= '\' ('n' | 'r' | 't' | '0' | '\\' | '"' | '
) interpolation ::= '${' expression '}'

A string cannot span multiple lines. An unescaped newline inside a string is a lexer error.

If the string contains at least one ${...} interpolation, it produces an interpolatedString token containing a list of segments (literal text and expression source strings). Otherwise it produces a plain stringLiteral token.

Escape sequences: \n (newline), \r (carriage return), \t (tab), \0 (NUL), \\ (backslash), \" (double quote), \$ (dollar sign). Any other character after \ produces a literal backslash followed by that character.

Raw string literals

raw_string_literal ::= 'r"' char* '"'
                     | 'r' '#'+ '"' char* '"' '#'+

Raw strings use the r"..." prefix. No escape processing or interpolation is performed inside a raw string -- backslashes, dollar signs, and other characters are taken literally. Raw strings cannot span multiple lines.

Raw strings are useful for regex patterns and file paths where backslashes are common:

let pattern = r"\d+\.\d+"
let path = r"C:\Users\alice\docs"

To embed a literal double quote, use the hashed form r#"..."#. The literal ends at the first " followed by the same number of # as the opening delimiter, so the body may contain any " that is not followed by that many #. Add more # (r##"..."##, etc.) when the body itself contains a "# sequence. This mirrors Rust's raw string syntax and keeps quote-heavy patterns escape-free:

// A regex matching a double-quoted string body, with no backslash soup:
let caps = regex_captures(r#""([^"\\]*)""#, "name=\"value\"")
log("first body: ${caps[0].groups[0]}")

Multi-line strings

multi_line_string ::= '"""' newline? content '"""'

Triple-quoted strings can span multiple lines. The optional newline immediately after the opening """ is consumed. Common leading whitespace is stripped from all non-empty lines. A trailing newline before the closing """ is removed.

Multi-line strings support ${expression} interpolation with automatic indent stripping. If at least one ${...} interpolation is present, the result is an interpolatedString token; otherwise it is a plain stringLiteral token. Use \${ for a literal ${ sequence inside a multi-line string.

let name = "world"
let doc = """
  Hello, ${name}!
  Today is ${timestamp()}.
"""

Operators

Two-character operators (checked first)

OperatorTokenDescription
==.eqEquality
!=.neqInequality
&&.andLogical AND
||.orLogical OR
|>.pipePipe
??.nilCoalNil coalescing
**.powExponentiation
?..questionDotOptional property/method chaining
->.arrowArrow
<=.lteLess than or equal
>=.gteGreater than or equal
+=.plusAssignCompound assignment
-=.minusAssignCompound assignment
*=.starAssignCompound assignment
/=.slashAssignCompound assignment
%=.percentAssignCompound assignment

Single-character operators

OperatorTokenDescription
=.assignAssignment
!.notLogical NOT
..dotMember access
+.plusAddition / concatenation
-.minusSubtraction / negation
*.starMultiplication / string repetition
/.slashDivision
<.ltLess than
>.gtGreater than
%.percentModulo
?.questionTernary / Result propagation
|.barUnion types
&.ampIntersection types

Keyword operators

OperatorDescription
inMembership test (lists, dicts, strings, sets)
not inNegated membership test

Delimiters

DelimiterToken
{.lBrace
}.rBrace
(.lParen
).rParen
[.lBracket
].rBracket
,.comma
:.colon
;.semicolon
@.at (attribute prefix)

Special tokens

TokenDescription
.newlineLine break character
.eofEnd of input

Grammar

The grammar is expressed in EBNF. Newlines between statements are implicit separators (the parser skips them with skipNewlines()). Semicolons are accepted as alternate separators in statement-list contexts only. The consume() helper also skips newlines before checking the expected token.

Top-level

program            ::= top_level_list
top_level_list     ::= (NEWLINE)* [top_level (top_level_sep top_level)* [top_level_sep]] (NEWLINE)*
top_level_sep      ::= NEWLINE+ | ';' NEWLINE*
top_level          ::= import_decl
                     | attributed_decl
                     | pipeline_decl
                     | statement

attributed_decl    ::= attribute+ (pipeline_decl | fn_decl | tool_decl
                                  | skill_decl | eval_pack_decl | struct_decl
                                  | enum_decl | type_decl | interface_decl
                                  | impl_block)
attribute          ::= '@' IDENTIFIER ['(' attr_arg (',' attr_arg)* [','] ')']
attr_arg           ::= [IDENTIFIER ':'] attr_value
attr_value         ::= STRING_LITERAL | RAW_STRING | INT_LITERAL
                     | FLOAT_LITERAL | 'true' | 'false' | 'nil'
                     | IDENTIFIER | '-' INT_LITERAL | '-' FLOAT_LITERAL

import_decl        ::= ['pub'] 'import' STRING_LITERAL
                     | ['pub'] 'import' '{' IDENTIFIER (',' IDENTIFIER)* '}'
                       'from' STRING_LITERAL
                     | ['pub'] 'import' IDENTIFIER ('::' IDENTIFIER)* '::'
                       '{' IDENTIFIER (',' IDENTIFIER)* '}'

pipeline_decl      ::= ['pub'] 'pipeline' IDENTIFIER '(' param_list ')'
                       ['->' type_expr]
                       ['extends' IDENTIFIER] '{' block '}'

param_list         ::= (IDENTIFIER (',' IDENTIFIER)*)?
block              ::= statement_list
statement_list     ::= (NEWLINE)* [statement (statement_sep statement)* [statement_sep]] (NEWLINE)*
statement_sep      ::= NEWLINE+ | ';' NEWLINE*

fn_decl            ::= ['pub'] 'fn' IDENTIFIER [generic_params]
                       '(' fn_param_list ')' ['->' type_expr]
                       [where_clause] '{' block '}'
type_decl          ::= 'type' IDENTIFIER '=' type_expr
enum_decl          ::= ['pub'] 'enum' IDENTIFIER [generic_params] '{'
                       (enum_variant | ',' | NEWLINE)* '}'
enum_variant       ::= IDENTIFIER ['(' fn_param_list ')']
struct_decl        ::= ['pub'] 'struct' IDENTIFIER [generic_params]
                       '{' struct_field* '}'
struct_field       ::= IDENTIFIER ['?'] ':' type_expr
impl_block         ::= 'impl' IDENTIFIER '{' (fn_decl | NEWLINE)* '}'
interface_decl     ::= 'interface' IDENTIFIER [generic_params] '{'
                       (interface_assoc_type | interface_method)* '}'
interface_assoc_type ::= 'type' IDENTIFIER ['=' type_expr]
interface_method   ::= 'fn' IDENTIFIER [generic_params]
                       '(' fn_param_list ')' ['->' type_expr]

Standard library modules

Imports starting with std/ load embedded stdlib modules:

  • import "std/text" — text processing (extract_paths, parse_cells, filter_test_cells, truncate_head_tail, detect_compile_error, has_got_want, format_test_errors, int_to_string, float_to_string, parse_int_or, parse_float_or, truncate_text, truncate_middle, single_line_or, prefix_lines, indent)
  • import "std/ansi" — terminal styling helpers (ansi_enabled, ansi_style, ansi_color, ansi_strip, ansi_visible_len, ansi_link, ansi_success, ansi_warn, ansi_error)
  • import "std/table" — deterministic plain-text and Markdown table rendering (render_table, render_markdown_table, render_kv_table)
  • import "std/diff" — line diff, unified diff, colored diff, diff stat, and host-backed structural review helpers (diff_lines, unified_diff, colorize_diff, diff_summary, render_diff_stat, structural_diff)
  • import "std/edit" — pure old/new text patch helpers (edit_apply_old_new_patch, edit_splice_lines, edit_changed_regions, edit_validate_changed_regions)
  • import "std/artifact/web" — safe helpers for small generated HTML/CSS/JS artifacts (web_artifact_extract, web_artifact_text_fallback, web_artifact_validate, web_artifact_apply_patch)
  • import "std/ui_resource" — MCP Apps-compatible ui:// resource envelopes, text/structured fallbacks, host capability negotiation, and message envelopes (ui_resource, ui_tool_meta, ui_tool_meta_to_mcp, ui_text_fallback, ui_structured_fallback, ui_tool_result, ui_tool_result_validate, ui_host_capabilities, ui_host_supports_apps, ui_select_for_host, ui_tool_call_envelope, ui_context_update_envelope, ui_resource_csp_header, ui_resource_sandbox_attr)
  • import "std/tui" — terminal presentation helpers (page, terminal_width, rule, clear)
  • import "std/collections" — collection utilities (filter_nil, store_stale, store_refresh)
  • import "std/fs" — file-system convenience helpers built on host primitives (ensure_parent_dir, read_json, write_json, read_yaml, write_yaml, read_toml, write_toml, write_lines, append_line, touch, find_files, relative_path, is_file, is_dir, file_size)
  • import "std/os" — environment and host diagnostic helpers (os_info, env_bool, env_int, env_list, require_env, which, command_exists)
  • import "std/gha" — GitHub Actions workflow command helpers (gha_escape_data, gha_annotation, gha_notice, gha_warning, gha_error, gha_env_block, gha_write_output, gha_write_env, gha_append_summary)
  • import "std/async" — polling and retry helpers (wait_for, retry_until, retry_predicate_with_backoff, circuit_call)
  • import "std/signal" — cooperative process interruption helpers (on_interrupt, off_interrupt, interrupted, with_interrupt)
  • import "std/vision" — deterministic OCR helpers (ocr(image, options?))
  • import "std/web" — deterministic web-source and grounding helpers (web_fetch, web_search, verify_imports, web_grounding_tools, web_parse_html, robots_allowed, sitemap_urls)
  • import "std/io" — terminal IO helpers (is_tty, read_line, read_password, write_stderr)
  • import "std/prompt_library" — reusable prompt fragments, cache metadata, tenant-scoped k-means hotspot proposals, and review-queue records
  • import "std/agent_state" — durable session-scoped state helpers (agent_state_init, agent_state_resume, agent_state_write, agent_state_read, agent_state_list, agent_state_delete, agent_state_handoff)
  • import "std/agent/progress" — agent progress narration and task-list helpers (agent_progress, agent_progress_tool)
  • import "std/agent/scratchpad" — live session-local agent working memory helpers (agent_scratchpad_options, agent_scratchpad_init, agent_scratchpad_recitation_fragment, agent_scratchpad_reorganize, agent_scratchpad_reorganize_if_due)
  • import "std/agent/fact" — typed fact envelopes over durable memory (store_fact, recall_facts, invalidate_facts)
  • import "std/agent/probe" — probe-first verification primitive: run a snippet (eval/typecheck) and record the outcome as an Observation fact (probe, probe_eval, probe_typecheck)
  • import "std/memory" — append-only durable memory helpers (memory_store, memory_recall, memory_summarize, memory_forget)
  • import "std/trust" — TrustGraph query and policy helpers (query, record, score, policy_for, verify_chain)
  • import "std/corrections" — replay-for-teaching correction records (query, record)
  • import "std/postgres" — Postgres persistence helpers (pg_pool, pg_connect, pg_query, pg_query_one, pg_execute, pg_transaction, pg_close, pg_stmt_cache_clear, pg_mock_pool, pg_mock_calls)
  • import "std/llm/handlers" — LLM call-handler middleware (with_circuit_breaker)
  • import "std/llm/prompts" — deterministic prompt builders and system prompt fragment helpers (system_prompt_part, system_preamble, system_appendix, with_system_prompt_parts, system_prelude)
  • import "std/personas/prelude" — reusable persona orchestration helpers (verify_then_act, bounded_loop, cheap_classify_then_escalate, parallel_sweep_with_circuit_breaker, with_audit_receipt, with_approval_gate)
  • import "std/personas/bulletins" — transparent profile bulletin proposals and host-owned decisions (bulletin_propose, bulletin_emit, bulletin_accept, bulletin_reject, bulletin_expire, bulletin_supersede, bulletin_decide, bulletin_apply_decisions, bulletin_partition, bulletin_active, bulletin_render_for_prompt, bulletin_dedupe)

These modules are compiled into the interpreter binary and require no filesystem access.

Statements

statement          ::= let_binding
                     | const_binding
                     | var_binding
                     | if_else
                     | for_in
                     | match_expr
                     | while_loop
                     | retry_block
                     | parallel_block
                     | parallel_each
                     | parallel_settle
                     | defer_block
                     | return_stmt
                     | throw_stmt
                     | override_decl
                     | try_catch
                     | fn_decl
                     | enum_decl
                     | struct_decl
                     | impl_block
                     | interface_decl
                     | type_decl
                     | guard_stmt
                     | require_stmt
                     | deadline_block
                     | mutex_block
                     | select_expr
                     | break_stmt
                     | continue_stmt
                     | expression_statement

let_binding        ::= 'let' binding_pattern [':' type_expr] '=' expression
const_binding      ::= 'const' IDENTIFIER [':' type_expr] '=' expression
var_binding        ::= 'var' binding_pattern [':' type_expr] '=' expression
if_else            ::= 'if' expression '{' block '}'
                       ['else' (if_else | '{' block '}')]
for_in             ::= 'for' binding_pattern 'in' expression '{' block '}'
match_expr         ::= 'match' expression '{' match_arm* '}'
match_arm          ::= expression ['if' expression] '->' '{' block '}'
while_loop         ::= 'while' expression '{' block '}'
retry_block        ::= 'retry' ['(' expression ')'] expression? '{' block '}'
parallel_block     ::= 'parallel' '(' expression ')' [parallel_options] '{' [IDENTIFIER '->'] block '}'
parallel_each      ::= 'parallel' 'each' expression [parallel_options] '{' IDENTIFIER '->' block '}'
parallel_settle    ::= 'parallel' 'settle' expression [parallel_options] '{' IDENTIFIER '->' block '}'
parallel_options   ::= 'with' '{' 'max_concurrent' ':' expression '}'
defer_block        ::= 'defer' '{' block '}'
return_stmt        ::= 'return' [expression]
throw_stmt         ::= 'throw' expression
override_decl      ::= 'override' IDENTIFIER '(' param_list ')' '{' block '}'
try_catch          ::= 'try' '{' block '}'
                       ['catch' [('(' IDENTIFIER [':' type_expr] ')') | IDENTIFIER]
                         '{' block '}']
                       ['finally' '{' block '}']
try_star_expr      ::= 'try' '*' unary_expr
guard_stmt         ::= 'guard' expression 'else' '{' block '}'
require_stmt       ::= 'require' expression [',' expression]
deadline_block     ::= 'deadline' primary '{' block '}'
mutex_block        ::= 'mutex' '{' block '}'
select_expr        ::= 'select' '{'
                         (IDENTIFIER 'from' expression '{' block '}'
                         | 'timeout' expression '{' block '}'
                         | 'default' '{' block '}')+
                       '}'
break_stmt         ::= 'break'
continue_stmt      ::= 'continue'

generic_params     ::= '<' generic_param (',' generic_param)* '>'
generic_param      ::= ['in' | 'out'] IDENTIFIER
where_clause       ::= 'where' IDENTIFIER ':' IDENTIFIER
                       (',' IDENTIFIER ':' IDENTIFIER)*

fn_param_list      ::= (fn_param (',' fn_param)*)? [',' rest_param]
                     | rest_param
fn_param           ::= IDENTIFIER [':' type_expr] ['=' expression]
rest_param         ::= '...' IDENTIFIER
type_expr          ::= IDENTIFIER
                     | IDENTIFIER '<' type_expr (',' type_expr)* '>'
                     | '[' type_expr ']'
                     | 'fn' '(' [type_expr (',' type_expr)*] ')' '->' type_expr
                     | '{' shape_field (',' shape_field)* '}'
                     | type_expr '|' type_expr
                     | type_expr '?'
                     | STRING_LITERAL
                     | INT_LITERAL

Postfix `?` is sugar for `T | nil`: `int?` is identical to `int | nil`,
including narrowing rules. `?` binds tighter than `&` and `|`, so
`A & B?` parses as `A & (B | nil)` and `A | B?` flattens to
`A | B | nil`. Note that `??` is the nil-coalescing operator and is
lexed as a single token, so writing two `?`s in a row never stacks
optionals; redundancies in the surface form (`T? | nil`) are
deduplicated to `T | nil` during parsing. The formatter and the
`prefer-optional-shorthand` lint rule rewrite the explicit
`T | nil` form into `T?` whenever the inner type prints as a type
primary (i.e. it isn't itself a union, intersection, or `fn(...)
-> ...` type).

A rest parameter (`...name`) must be the last parameter in the list. At call
time, any arguments beyond the positional parameters are collected into a list
and bound to the rest parameter name. If no extra arguments are provided, the
rest parameter is an empty list. A type annotation on a rest parameter describes
each extra argument, and the binding inside the function has the corresponding
list type: `...nums: int` accepts only integer extras and binds `nums` as
`list<int>`.

```harn
fn sum(...nums) {
  var total = 0
  for n in nums {
    total = total + n
  }
  return total
}
sum(1, 2, 3)  // 6

fn log(level, ...parts) {
  log("[${level}] ${join(parts, " ")}")
}
log("INFO", "server", "started")  // [INFO] server started
expression_statement ::= expression
                       | assignable '=' expression
                       | assignable ('+=' | '-=' | '*=' | '/=' | '%=') expression

assignable         ::= IDENTIFIER
                     | postfix_property
                     | postfix_subscript

binding_pattern    ::= IDENTIFIER
                     | '{' dict_pattern_fields '}'
                     | '[' list_pattern_elements ']'

dict_pattern_fields   ::= dict_pattern_field (',' dict_pattern_field)*
dict_pattern_field    ::= '...' IDENTIFIER
                        | IDENTIFIER [':' IDENTIFIER]

list_pattern_elements ::= list_pattern_element (',' list_pattern_element)*
list_pattern_element  ::= '...' IDENTIFIER
                        | IDENTIFIER

The expression_statement rule handles both bare expressions (function calls, method calls) and assignments. An assignment is recognized when the left-hand side is an identifier followed by =.

Expressions (by precedence, lowest to highest)

expression         ::= pipe_expr
pipe_expr          ::= range_expr ('|>' range_expr)*
range_expr         ::= ternary_expr ['to' ternary_expr ['exclusive']]
ternary_expr       ::= logical_or ['?' ternary_expr ':' ternary_expr]
logical_or         ::= logical_and ('||' logical_and)*
logical_and        ::= equality ('&&' equality)*
equality           ::= comparison (('==' | '!=') comparison)*
comparison         ::= additive
                       (('<' | '>' | '<=' | '>=' | 'in' | 'not in') additive)*
additive           ::= nil_coal_expr (('+' | '-') nil_coal_expr)*
nil_coal_expr      ::= multiplicative ('??' multiplicative)*
multiplicative     ::= unary (('*' | '/' | '%') unary)*
unary              ::= ('!' | '-') unary | power_expr
power_expr         ::= postfix ['**' unary]
postfix            ::= primary (member_access
                               | optional_member_access
                               | subscript_access
                               | optional_subscript_access
                               | slice_access
                               | call
                               | try_unwrap)*
member_access      ::= '.' IDENTIFIER ['(' arg_list ')']
optional_member_access
                    ::= '?.' IDENTIFIER ['(' arg_list ')']
subscript_access   ::= '[' expression ']'
optional_subscript_access
                    ::= '?[' expression ']'
slice_access       ::= '[' [expression] ':' [expression] ']'
call               ::= [type_args] '(' arg_list ')'
                       (* only when postfix base is an identifier *)
type_args          ::= '<' type_expr (',' type_expr)* '>'
try_unwrap         ::= '?'                 (* expr? on Result *)

Primary expressions

primary            ::= STRING_LITERAL
                     | INTERPOLATED_STRING
                     | INT_LITERAL
                     | FLOAT_LITERAL
                     | DURATION_LITERAL
                     | 'true' | 'false' | 'nil'
                     | IDENTIFIER
                     | '(' expression ')'
                     | list_literal
                     | dict_or_closure
                     | parallel_block
                     | parallel_each
                     | parallel_settle
                     | retry_block
                     | if_else
                     | match_expr
                     | deadline_block
                     | 'spawn' '{' block '}'
                     | 'fn' '(' fn_param_list ')' '{' block '}'
                     | 'try' '{' block '}'

```text
list_literal       ::= '[' (list_element (',' list_element)*)? ']'
list_element       ::= '...' expression | expression

dict_or_closure    ::= '{' '}'
                     | '{' closure_param_list '->' block '}'
                     | '{' dict_entries '}'
closure_param_list ::= fn_param_list

dict_entries       ::= dict_entry (',' dict_entry)*
dict_entry         ::= (IDENTIFIER | STRING_LITERAL | '[' expression ']')
                       ':' expression
                     | '...' expression
arg_list           ::= (arg_element (',' arg_element)*)?
arg_element        ::= '...' expression | expression

Dict keys written as bare identifiers are converted to string literals (e.g., {name: "x"} becomes {"name": "x"}). String-literal keys may contain dots or other non-identifier characters; the formatter keeps identifier string keys bare and keeps non-identifier string keys quoted (for example, {"a.b.c": "x", k: "y"}). Computed keys use bracket syntax: {[expr]: value}.

Operator precedence table

From lowest to highest binding:

PrecedenceOperatorsAssociativityDescription
1|>LeftPipe
2? :RightTernary conditional
3||LeftLogical OR
4&&LeftLogical AND
5== !=LeftEquality
6< > <= >= in not inLeftComparison / membership
7+ -LeftAdditive
8??LeftNil coalescing
9* / %LeftMultiplicative
10! - (unary)Right (prefix)Unary
11**RightExponentiation
12. ?. [] ?[] [:] () ?LeftPostfix

Exponentiation binds more tightly than a unary prefix on its left operand, so -2 ** 2 parses as -(2 ** 2) (-4), matching Python, Ruby, and ordinary math notation rather than the spreadsheet (-2) ** 2 reading. The right (exponent) operand still accepts a unary prefix, so 2 ** -3 is 2 ** (-3), and chained ** remains right-associative (2 ** 3 ** 2 is 2 ** (3 ** 2)).

Multiline expressions

Binary operators |>, ||, &&, ==, !=, <, >, <=, >=, ??, +, *, /, %, **, and the . / ?. member access operators can span multiple lines. The operator at the start of a continuation line causes the parser to treat it as a continuation of the previous expression rather than a new statement.

Note: - does not support multiline continuation because it is also a unary negation prefix. Keyword operators in, not in, and to also require an explicit backslash continuation.

let result = items
  .filter({ x -> x > 0 })
  .map({ x -> x * 2 })

let msg = "hello"
  + " "
  + "world"

let ok = check_a()
  && check_b()
  || fallback()

Pipe placeholder (_)

When the right side of |> contains _ identifiers, the expression is automatically wrapped in a closure where _ is replaced with the piped value:

"hello world" |> split(_, " ")     // desugars to: |> { __pipe -> split(__pipe, " ") }
[3, 1, 2] |> _.sort()             // desugars to: |> { __pipe -> __pipe.sort() }
items |> len(_)                    // desugars to: |> { __pipe -> len(__pipe) }

Without _, the pipe passes the value as the sole argument to the callable on the right side. Use _ whenever the piped value should be placed inside a larger expression or a specific argument position.

Scope rules

Harn uses lexical scoping with a parent-chain environment model.

Environment

Each HarnEnvironment has:

  • A values dictionary mapping names to HarnValue
  • A mutable set tracking which names were declared with var
  • An optional parent reference

Variable lookup

env.get(name) checks the current scope's values first, then walks up the parent chain. Returns nil (which becomes .nilValue) if not found anywhere.

Variable definition

  • let name = value -- defines name as immutable in the current scope.
  • const NAME = value -- defines NAME as immutable, with the initializer additionally folded at compile time by the bounded const-evaluator. Only pure expressions are accepted: literal arithmetic, string concatenation, literal lists/dicts, ternary / if-else, subscript access, and calls into a small whitelist of pure stdlib builtins (len, format, min, max, abs, floor, ceil, round, lowercase, uppercase, trim, concat, join). Any reference to harness.*, runtime constructs (spawn, parallel, select, try, yield, emit, await, …), user-defined functions, loops, or assignment is rejected with a HARN-MET-001, HARN-CST-001, HARN-CST-002, HARN-CST-003, or HARN-CST-004 diagnostic depending on the failure mode. Issue #1791 carries the full design and rationale.
  • var name = value -- defines name as mutable in the current scope.
  • var name = nil -- leaves name widenable until the first non-nil assignment, which fixes the slot to T | nil. The explicit form var name: T | nil = nil remains valid when you want to pin T up front.
  • let _ = value / var _ = value -- evaluate value and discard it without introducing a variable into scope. _ can be reused any number of times in the same scope.

Variable assignment

name = value walks up the scope chain to find the binding. If the binding is found but was declared with let, throws HarnRuntimeError.immutableAssignment. If not found in any scope, throws HarnRuntimeError.undefinedVariable.

Scope creation

New child scopes are created for:

  • Pipeline bodies
  • for loop bodies (loop variable is mutable)
  • while loop iterations
  • parallel, parallel each, and parallel settle task bodies (isolated interpreter per task)
  • try/catch blocks (catch body gets its own child scope with optional error variable)
  • Closure invocations (child of the captured environment, not the call site)
  • block nodes

Control flow statements (if/else, match) execute in the current scope without creating a new child scope.

Destructuring patterns

Destructuring binds multiple variables from a dict or list in a single let, var, or for-in statement.

Dict destructuring

let {name, age} = {name: "Alice", age: 30}
// name == "Alice", age == 30

Each field name in the pattern extracts the value for the matching key. If the key is missing from the dict, the variable is bound to nil. Use _ as a discard binding when you want to ignore an extracted field:

let {name, debug: _} = {name: "Alice", debug: true}
// name == "Alice"; `_` is not bound

Default values

Pattern fields can specify default values with = expr syntax. The default expression is evaluated when the extracted value is nil (i.e. when the key is missing from the dict or the index is out of bounds for a list):

let { name = "workflow", system = "" } = { name: "custom" }
// name == "custom" (key exists), system == "" (default applied)

let [a = 10, b = 20, c = 30] = [1, 2]
// a == 1, b == 2, c == 30 (default applied)

Defaults can be combined with field renaming:

let { name: displayName = "Unknown" } = {}
// displayName == "Unknown"

Default expressions are evaluated fresh each time the pattern is matched (they are not memoized). Rest patterns (...rest) do not support default values.

List destructuring

let [first, second, third] = [10, 20, 30]
// first == 10, second == 20, third == 30

Elements are bound positionally. If there are more bindings than elements in the list, the excess bindings receive nil (unless a default value is specified). Use _ to discard positions without creating a binding:

let [_, second, _] = [10, 20, 30]
// second == 20; `_` is not bound

Field renaming

A dict pattern field can be renamed with key: alias syntax:

let {name: user_name} = {name: "Bob"}
// user_name == "Bob"

Rest patterns

A ...rest element collects remaining items into a new list or dict:

let [head, ...tail] = [1, 2, 3, 4]
// head == 1, tail == [2, 3, 4]

let {name, ...extras} = {name: "Carol", age: 25, role: "dev"}
// name == "Carol", extras == {age: 25, role: "dev"}

If there are no remaining items, the rest variable is bound to [] for list patterns or {} for dict patterns. The rest element must appear last in the pattern.

For-in destructuring

Destructuring patterns work in for-in loops to unpack each element:

let entries = [{name: "X", val: 1}, {name: "Y", val: 2}]
for {name, val} in entries {
  log("${name}=${val}")
}

let pairs = [[1, 2], [3, 4]]
for [a, b] in pairs {
  log("${a}+${b}")
}

_ is also a discard binding in loop patterns, so for [_, value] in ... or for (_, value) in ... drops the ignored element instead of binding it.

Var destructuring

var destructuring creates mutable bindings that can be reassigned:

var {x, y} = {x: 1, y: 2}
x = 10
y = 20

Discard bindings remain non-bindings under var as well: var [_, value] = still only introduces value.

Type errors

Destructuring a non-dict value with a dict pattern or a non-list value with a list pattern produces a runtime error. For example, let {a} = "hello" throws "dict destructuring requires a dict value".

Evaluation order

Program entry

  1. All top-level nodes are scanned. Pipeline declarations are registered by name. Import declarations are processed (loaded and evaluated).
  2. The entry pipeline is selected: the pipeline named "default" if it exists, otherwise the first pipeline in the file.
  3. The entry pipeline's body is executed.

If no pipeline is found in the file, all top-level statements are compiled and executed directly as an implicit entry point (script mode). This allows simple scripts to work without wrapping code in a pipeline block.

Pipeline parameters

If the pipeline parameter list includes task, it is bound to context.task. If it includes project, it is bound to context.projectRoot. A context dict is always injected with keys task, project_root, and task_type.

Pipeline return type

Pipelines may declare a return type with the same -> TypeExpr syntax as functions:

pipeline ghost_text(task) -> {text: string, code: int} {
  return {text: "hello", code: 0}
}

The type checker verifies every return <expr> statement against the declared type. Mismatches are reported as return type doesn't match errors.

A declared return type is the typed contract that a host or bridge (ACP, A2A) can rely on when consuming the pipeline's output.

Public pipelines (pub pipeline) without an explicit return type emit the pipeline-return-type lint warning; explicit return types on the Harn→ACP boundary will be required in a future release.

Pipeline inheritance

pipeline child(x) extends parent { ... }:

  • If the child body contains override declarations, the resolved body is the parent's body plus any non-override statements from the child. Override declarations are available for lookup by name.
  • If the child body contains no override declarations, the child body entirely replaces the parent body.

Statement execution

Statements execute sequentially. The last expression value in a block is the block's result, though this is mostly relevant for closures and parallel bodies.

Import resolution

import "path" resolves in this order:

  1. If path starts with std/, loads embedded stdlib module (e.g. std/text)
  2. Relative to current file's directory; auto-adds .harn extension
  3. .harn/packages/<path> directories rooted at the nearest ancestor package root (the search walks upward and stops at a .git boundary). Harn materializes this tree from harn.lock before import-aware commands run.
  4. Package manifest [exports] mappings under .harn/packages/<package>/harn.toml
  5. Package directories with lib.harn entry point

Package manifests can publish stable module entry points without forcing consumers to import the on-disk file layout directly:

[exports]
capabilities = "runtime/capabilities.harn"
providers = "runtime/providers.harn"

With the example above, import "acme/capabilities" resolves to the declared file inside the installed acme package.

Selective imports: import { name1, name2 } from "module" imports only the specified functions. Functions marked pub are exported by default; if no pub functions exist, all functions are exported.

Scoped selective imports are shorthand for slash-delimited module paths: import std::personas::prelude::{verify_then_act} is equivalent to import { verify_then_act } from "std/personas/prelude".

Public re-exports: prefixing any import with pub re-exports the imported symbols as part of the importing module's public surface, so downstream importers see them as if they were declared there directly:

  • pub import "module" — re-export every name the target module exports. Equivalent to wildcard re-export.
  • pub import { name } from "module" — re-export only the listed names. Other names from the source module remain private to the importing module.

Re-exports compose: a facade module that pub imports from another facade transitively forwards every reachable name. Two re-exports of the same name from different sources — or a re-export that shadows a local pub declaration — are reported by harn check as a re-export conflict naming every contributing module.

Imported pipelines are registered for later invocation. Non-pipeline top-level statements (fn declarations, let bindings) are executed immediately.

Static cross-module resolution

harn check, harn run, harn bench, and the LSP build a module graph from the entry file that transitively loads every import-reachable .harn module. The graph drives:

  • Typechecker: when every import in a file resolves, call targets that are not builtins, not local declarations, not struct constructors, not callable variables, and not introduced by an import produce a call target ... is not defined or imported error (not a lint warning). This catches typos and stale imports before the VM loads.
  • Linter: wildcard imports are resolved via the same graph; the undefined-function rule can now check against the actual exported name set of imported modules rather than silently disabling itself.
  • LSP go-to-definition: cross-file navigation walks the graph's definition_of lookup, so any reachable symbol (through any number of transitive imports) can be jumped to.

Resolution conservatively degrades to the pre-v0.7.12 behavior when any import in the file is unresolved (missing file, parse error, non-existent package directory), so a single broken import does not avalanche into a sea of false-positive undefined-name errors. The unresolved import itself still surfaces via the runtime loader.

Runtime values

TypeSyntaxDescription
string"text"UTF-8 string
bytesbuiltin-producedImmutable byte buffer
int42Platform-width integer
float3.14Double-precision float
number42 / 3.14Built-in alias for int | float
booltrue / falseBoolean
nilnilNull value
list[1, 2, 3]Ordered collection
dict{key: value}String-keyed map
setset(1, 2, 3)Unordered collection of unique values
closure{ x -> x + 1 }First-class function with captured environment
enumColor.RedEnum variant, optionally with associated data
structPoint({x: 3, y: 4})Struct instance with named fields
taskHandle(from spawn)Opaque handle to an async task
Generator<T>regular fn containing yieldExisting synchronous generator value
Stream<T>gen fn containing emitLazy, single-pass stream value
Iter<T>x.iter() / iter(x)Lazy, single-pass, fused iterator. See Iterator protocol
Pair<K, V>pair(k, v)Two-element value; access via .first / .second

Truthiness

ValueTruthy?
bool(false)No
nilNo
int(0)No
float(0)No
string("")No
bytes(b"")No
list([])No
dict([:])No
set() (empty)No
Everything elseYes

Equality

Values are equal if they have the same type and same contents, with these exceptions:

  • int and float are compared by converting int to float
  • Two closures are never equal
  • Two task handles are equal if their IDs match

Comparison

Only int, float, and string support ordering (<, >, <=, >=). Comparison between other types returns 0 (equal).

Binary operator semantics

Arithmetic (+, -, *, /)

LeftRight+-*/
intintintintintint (truncating)
floatfloatfloatfloatfloatfloat
intfloatfloatfloatfloatfloat
floatintfloatfloatfloatfloat
stringstringstring (concatenation)TypeErrorTypeErrorTypeError
stringintTypeErrorTypeErrorstring (repetition)TypeError
intstringTypeErrorTypeErrorstring (repetition)TypeError
listlistlist (concatenation)TypeErrorTypeErrorTypeError
dictdictdict (merge, right wins)TypeErrorTypeErrorTypeError
otherotherTypeErrorTypeErrorTypeErrorTypeError

Division by zero depends on the result type. Integer division by zero (int / int) raises a runtime error that propagates like any other thrown value (catchable with try/catch). Float division by zero follows IEEE-754: it yields ±inf (or NaN for 0.0 / 0.0) and never raises — this applies whenever either operand is a float, since the result is a float. string * int repeats the string; negative or zero counts return "".

Type mismatches that are not listed as valid combinations above produce a TypeError at runtime. The type checker reports these as compile-time errors when operand types are statically known. Use to_string() or string interpolation ("${expr}") for explicit type conversion.

Modulo (%)

% is numeric-only. int % int returns int; any case involving a float returns float. Modulo by a zero divisor always raises a runtime error, regardless of operand types (unlike float division, it never yields NaN).

Exponentiation (**)

** is numeric-only and right-associative, so 2 ** 3 ** 2 evaluates as 2 ** (3 ** 2).

  • int ** int returns int for non-negative exponents that fit in u32, using wrapping integer exponentiation.
  • Negative or very large integer exponents promote to float.
  • Any case involving a float returns float.
  • Non-numeric operands raise TypeError.
  • A unary minus on the base binds looser than **, so -2 ** 2 is -(2 ** 2) (-4), not (-2) ** 2 (4). The exponent still accepts a unary prefix, so 2 ** -3 is 2 ** (-3).

Logical (&&, ||)

Short-circuit evaluation:

  • &&: if left is falsy, returns false without evaluating right.
  • ||: if left is truthy, returns true without evaluating right.

Nil coalescing (??)

Short-circuit: if left is not nil, returns left without evaluating right. ?? binds tighter than additive/comparison/logical operators but looser than multiplicative operators, so xs?.count ?? 0 > 0 parses as (xs?.count ?? 0) > 0.

Pipe (|>)

a |> f evaluates a, then:

  1. If f evaluates to a closure, invokes it with a as the single argument.
  2. If f is an identifier resolving to a builtin, calls the builtin with [a].
  3. If f is an identifier resolving to a closure variable, invokes it with a.
  4. Otherwise raises a type error.

Ternary (? :)

condition ? trueExpr : falseExpr evaluates condition, then evaluates and returns either trueExpr (if truthy) or falseExpr.

Ranges (to, to … exclusive)

a to b evaluates a and b (both must be integers) and produces a list of consecutive integers. The form is inclusive by default — 1 to 5 is [1, 2, 3, 4, 5] — because that matches how the expression reads aloud.

Add the trailing modifier exclusive to get the half-open form: 1 to 5 exclusive is [1, 2, 3, 4].

ExpressionValueShape
1 to 5[1, 2, 3, 4, 5][a, b]
1 to 5 exclusive[1, 2, 3, 4][a, b)
0 to 3[0, 1, 2, 3][a, b]
0 to 3 exclusive[0, 1, 2][a, b)

If b < a, the result is the empty list. The range(n) / range(a, b) stdlib builtins always produce the half-open form, for Python-compatible indexing.

Control flow

if/else

if condition {
  // then
} else if other {
  // else-if
} else {
  // else
}

else if chains are parsed as a nested ifElse node in the else branch.

for/in

for item in iterable {
  // body
}

If iterable is a list, iterates over elements. If iterable is a dict, iterates over entries sorted by key, where each entry is {key: "...", value: ...}. The loop variable is mutable within the loop body.

while

while condition {
  // body
}

Maximum 10,000 iterations (safety limit). Condition is re-evaluated each iteration.

match

match is an expression. It can be used as a standalone statement or in expression position; the selected arm evaluates to the value of its block's last expression.

match value {
  pattern1 -> { body1 }
  pattern2 if condition -> { body2 }
}

Patterns are expressions. Each pattern is evaluated and compared to the match value using valuesEqual. An arm may include an if guard after the pattern; when present, the arm only matches if the pattern matches and the guard expression evaluates to a truthy value. The first matching arm executes.

If no arm matches, a runtime error is thrown (No match arm matched the value). This makes non-exhaustive matches a hard failure rather than a silent nil.

let x = 5
let label = match x {
  1 -> { "one" }
  n if n > 3 -> { "big: ${n}" }
  _ -> { "other" }
}
// label == "big: 5"

List patterns

A list pattern [p0, p1, …] matches a list of exactly that length and binds each identifier element to the value at its position (literals are compared, _ discards). A trailing ...rest element makes the pattern match a list of at least the leading arity and binds the remainder as a new list (..._ matches the tail without binding it):

match coords {
  [x, y] -> { "2d: ${x},${y}" }          // matches ONLY length 2
  [x, y, z] -> { "3d" }                   // matches ONLY length 3
  [first, ...rest] -> { "${first}+${rest}" } // length >= 1; rest is a list
  [] -> { "empty" }
  _ -> { "other" }
}

When the matched value is a list<T>, the leading bindings have type T and a ...rest binding has type list<T>. Only one ...rest is allowed and it must be last.

retry

retry 3 {
  // body that may throw
}

Executes the body up to N times. If the body succeeds (no error), returns immediately. If the body throws, catches the error and retries. return statements inside retry propagate out (are not retried). After all attempts are exhausted, returns nil (does not re-throw the last error).

Concurrency

Runtime context

runtime_context() returns the current logical runtime context as a dict. task_current() is an alias. This is Harn's stable task/thread identity surface; OS thread IDs are not exposed as the primary abstraction.

The stable fields are always present, with unavailable values represented as nil: task_id, parent_task_id, root_task_id, task_name, task_group_id, scope_id, workflow_id, run_id, stage_id, worker_id, agent_session_id, parent_agent_session_id, root_agent_session_id, agent_name, trigger_id, trigger_event_id, binding_key, tenant_id, provider, trace_id, span_id, scheduler_key, runner, capacity_class, context_values, cancelled, and debug.

spawn, parallel, parallel each, parallel each ... as stream, and parallel settle create child logical tasks. A child task receives a deterministic task_id, its parent_task_id is the creating task, and its root_task_id is inherited from the root task. parallel siblings share a task_group_id.

Task-local values are managed with runtime_context_values(), runtime_context_get(key, default?), runtime_context_set(key, value), and runtime_context_clear(key). Children inherit a snapshot of the parent's task-local values. Later child writes do not mutate the parent, and later parent writes do not affect already-created children.

parallel

parallel(count) { i ->
  // body executed count times concurrently
}

Creates count concurrent tasks. Each task gets an isolated interpreter with a child environment. The optional variable i is bound to the task index (0-based). Returns a list of results in index order.

parallel each

parallel each list { item ->
  // body for each item
}

Maps over a list concurrently. Each task gets an isolated interpreter. The variable is bound to the current list element. Returns a list of results in the original order.

parallel each as stream

let results = parallel each list with { max_concurrent: 4 } { item ->
  // body for each item
} as stream

for result in results {
  log(result)
}

Maps over a list concurrently and returns Stream<T> instead of materializing a result list. The stream emits each task result as soon as that task completes, so output order is completion order rather than source order. with { max_concurrent: N } is honored the same way as eager parallel each. If a task throws, the error is raised when the consumer pulls that stream item and remaining tasks are cancelled.

parallel_race(items, callable, options?) is the first-success helper for this pattern. It returns the first plain value or Result.Ok payload produced by callable, cancels remaining tasks, and throws an aggregate error if every task throws or returns Result.Err.

parallel settle

parallel settle list { item ->
  // body for each item
}

Like parallel each, but never throws. Instead, it collects both successes and failures into a result object with fields:

FieldTypeDescription
resultslistList of Result values (one per item), in order
succeededintNumber of Ok results
failedintNumber of Err results

All parallel forms accept with { max_concurrent: N } before the body:

fn fetch_page(cursor) { cursor }
let cursors = ["a", "b", "c"]
let pages = parallel settle cursors with { max_concurrent: 4 } { cursor ->
  fetch_page(cursor)
}

max_concurrent caps simultaneous in-flight child tasks. Missing, zero, and negative values mean unlimited. Results are still returned in source order.

defer

defer {
  // cleanup body
}

Registers a block to run when the enclosing lexical scope exits — on normal fallthrough, on return, on break / continue out of an enclosing loop, or on an uncaught throw. Multiple defer blocks in the same scope execute in LIFO (last-registered, first-executed) order, similar to Zig's defer. The deferred block runs in the scope where it was declared.

Scope here is the innermost { ... }, not the enclosing function. A defer inside an if block fires when control leaves that if. A defer inside a for body fires at the end of each iteration as well as on break / continue.

fn open(path) { path }
fn close(f) { log("closing ${f}") }
let f = open("data.txt")
defer { close(f) }
// ... use f ...
// close(f) runs automatically on scope exit

owned<T> and drop()

let ch: owned<channel> = channel("log", 64)
// implicit: defer { drop(ch) } registered at this `let`

owned<T> marks a binding as carrying sole ownership of a drop-able stdlib handle. The compiler emits an implicit defer { drop(<binding>) } at the let / var so the resource closes deterministically at lexical scope exit — no manual close_channel / mcp_disconnect / etc. and no reliance on GC finalisation. drop() is a builtin that dispatches on the runtime value tag: channels close, sync permits release, future handle types add their own hooks; unknown values are a silent no-op.

owned<T> is transparent for type compatibility: an owned<channel> value flows into a parameter declared channel, and a channel value satisfies an owned<channel> annotation. The marker only influences scope-exit codegen and the ownership-escape lint below.

Returning an owned<T> binding by name from a function whose declared return type is not also owned<T> defeats the auto-drop and fires HARN-OWN-003. To transfer ownership to the caller, declare the return type as owned<T>:

fn open_log() -> owned<channel> {
  let ch: owned<channel> = channel("log", 64)
  return ch                              // ownership transfers to caller
}

spawn/await/cancel

let handle = spawn {
  // async body
}
let result = await(handle)
cancel(handle)
let outcome = cancel_graceful(handle, 2s)

spawn launches an async task and returns a taskHandle. await (a built-in interpreter function, not a keyword) blocks until the task completes and returns its result. cancel force-aborts the task. cancel_graceful requests cooperative cancellation, waits up to the given duration, and then force-aborts the task if it is still running. is_cancelled() reports whether the current task has observed a cancellation request.

Cancellation is structured at the VM task boundary. Spawned tasks inherit the parent's runtime context snapshot and cancellation token. The VM checks cancellation between bytecode instructions and while awaiting interruptible async operations, so CPU-bound Harn loops and blocked async calls both unwind. When a VM exits, any un-awaited spawned tasks it owns are cancelled and aborted.

std/signal

import "std/signal"

let registration = on_interrupt({ ->
  agent_session_close(session, {status: "interrupted"})
}, {signals: ["SIGINT", "SIGTERM"], once: true})

defer { off_interrupt(registration) }

std/signal exposes cooperative process interruption for scripts run by harn run. on_interrupt(handler, options?) registers a callable for SIGINT, SIGTERM, or SIGHUP; matching handlers run in LIFO order at the next VM interrupt checkpoint. The default is {signals: ["SIGINT"], once: true}. graceful_timeout_ms bounds handler execution at VM interrupt checkpoints; an expired handler throws kind:interrupted:handler_timeout. off_interrupt(handle) removes a handler, and interrupted() remains true after the VM has observed an interrupt so tight loops can poll and return through normal defer cleanup. with_interrupt(handler, body, options?) registers a handler for the dynamic extent of body and always unregisters it before returning or rethrowing.

If no matching handler is registered, process interruption falls back to normal VM cancellation. A second process signal terminates harn run immediately so a wedged handler cannot trap the operator.

deadline

deadline 30s {
  // work must complete within 30 seconds
}

deadline applies a timeout scope to the block. If the deadline expires, Harn throws "Deadline exceeded", interrupts the active opcode or async wait, and cancels child tasks owned by that scope's VM. The error can be caught with try/catch.

Synchronization

Harn synchronization primitives are workflow-level parking primitives, not low-level OS locks or spinlocks. The initial scope is process-local: spawned and parallel child VMs inherit the same synchronization runtime, while durable EventLog-backed variants are reserved for explicit future primitives.

mutex { ... } acquires a fair process-local mutex keyed by the lexical call-site of the block. Re-entering the same block serializes on the same key; different bare mutex { ... } blocks do not contend with each other. The permit is released when the block's scope exits, including throw, return, break, continue, and caught runtime errors.

Named primitives return a permit value or nil on timeout:

let lock = sync_mutex_acquire("state:customer-42", 250ms)
let slot = sync_semaphore_acquire("connector:notion", 4, 1, 2s)
let gate = sync_gate_acquire("workflow-runner", 8, 5s)
  • sync_mutex_acquire(key?, timeout?) acquires one permit from a named FIFO mutex. Omitting key uses "__default__".
  • sync_semaphore_acquire(key, capacity, permits?, timeout?) acquires a weighted permit from a named FIFO semaphore.
  • sync_gate_acquire(key, limit, timeout?) acquires one fair-admission slot from a named FIFO gate.
  • sync_release(permit) releases a named permit and returns true only for the first release.
  • Permits returned by sync_*_acquire are also owned by the current scope or frame. They are released automatically on scope exit, return, and throw; explicit sync_release is for earlier release and remains idempotent.
  • sync_metrics(kind?, key?) returns observability counters for matching primitives. A concrete (kind, key) returns a dict; partial or empty filters return a list.

Metrics include acquisition_count, timeout_count, cancellation_count, release_count, current_held, current_queue_depth, max_queue_depth, total_wait_ms, and total_held_ms.

Acquisition is cancellable: a graceful task cancellation while waiting throws kind:cancelled:VM cancelled by host. Timeouts are deterministic and return nil instead of throwing so authors can choose the fallback policy.

Scoped shared state

Child tasks created by spawn, parallel, parallel each, and parallel settle execute in isolated interpreter instances. Normal values are copied into the child at creation time. Later assignment in one task does not mutate another task's binding. Explicit handles are the shared-data boundary: channels, atomics, synchronization permits/runtimes, shared cells/maps, and mailboxes are shared by every task that receives or resolves the handle.

agent_loop does not share mutable transcript internals with tasks. A named session_id shares durable transcript history through the agent session store. spawn_agent workers have independent worker/session lineage; use explicit mailboxes, shared state handles, agent_state_*, or host storage for data exchange outside the transcript.

Named sessions may also carry a small scratchpad dict plus a monotonic scratchpad_version. The scratchpad is live session-local working memory, not a replayable transcript message. agent_session_scratchpad(id), agent_session_set_scratchpad(id, scratchpad, opts?), and agent_session_clear_scratchpad(id, opts?) are the direct state boundary. Updates append compact agent_scratchpad transcript events that include action, version, source, reason, counts, and caller metadata without copying the full scratchpad into the event. Snapshots expose scratchpad and scratchpad_version; final transcripts also mirror the current values under metadata.agent_scratchpad and metadata.agent_scratchpad_version.

agent_session_seed_from_jsonl(path, opts?) creates a new session from a replayable LLM transcript sidecar. Exact replay uses prompt-visible message events or full request snapshots; provider-response-only sidecars are assistant-response best effort and require validate: false. Options include truncate_to_last, drop_tool_calls, rename_session, validate, provider, and model.

Delegated workers accept carry.transcript_mode to define continuation semantics across send_input(...), retriggered workers, and resumed snapshots. inherit carries the completed worker transcript into the next cycle. fork copies the completed transcript under a fresh transcript id and records the source id in metadata.parent_transcript_id. reset starts the next cycle without a carried transcript. compact compacts the completed worker transcript before persistence and subsequent inheritance. Parent-facing worker_result artifacts are compact summary artifacts: their data.payload must omit nested full transcript and artifacts payloads by default, while preserving request, provenance, execution profile, compact result fields, and the ids of artifacts produced by the worker.

agent_loop, sub_agent_run, and spawn_agent accept a permissions option that scopes one agent below the ambient capability policy. permissions.allow and permissions.deny are tool-name glob lists or dicts keyed by tool-name glob; dict values may be argument pattern lists or pure Harn predicates of the tool args. Dict values may also be path_scope matchers, which check configured path argument keys against the active session workspace_anchor and can include mounted roots by mount mode. Deny rules win. If a call is denied and on_escalation is present, the runtime calls it with a PermissionRequest dict. The callback may return false, true, {grant: "once"}, or {grant: "session"}. Session grants are memoized for the same tool and argument payload. Child permissions are still intersected with parent policy ceilings and cannot widen them. Permission decisions append PermissionGrant, PermissionDeny, and PermissionEscalation transcript events; escalation grants from shadow or suggest trigger contexts append a trust.promote OpenTrustGraph record at act_with_approval.

approval_policy is the declarative host-approval policy. It accepts legacy auto_approve, auto_deny, require_approval, and write_path_allowlist fields plus rules, a compact allow/ask/deny DSL. A rule may be written as {allow: {...}}, {ask: {...}}, {deny: {...}}, or {action: "ask", match: {...}}. Match dimensions include tool, tool_kind, side_effect, path, command, command_identity, url, domain, method, mcp_server, mcp_tool, agent, persona, mode, capability, and repeat_count_gte. Dimensions inside a rule are ANDed; string fields accept glob patterns. Deny beats ask, ask beats allow, and unmatched tools are approved.

When an approval policy is active, sensitive path strings such as .env, private keys, and credential files are denied by default unless allow_sensitive_paths: true is set. Declared host-absolute paths outside the workspace are denied unless external_roots covers the path or allow_external_paths: true is set. ask decisions call the host via session/request_permission and fail closed when no host bridge is attached. Each approval decision produces a harn.permission_policy_decision.v1 receipt containing the matched rule, risk labels, normalized context, and rationale; host approval payloads and permission transcript events carry that receipt for audit and replay.

let budget = shared_cell({scope: "task_group", key: "tokens", initial: 0})

parallel 10 { i ->
  var updated = false
  while !updated {
    let snap = shared_snapshot(budget)
    updated = shared_cas(budget, snap, snap.value + 1)
  }
}

Process-local scopes are explicit:

ScopeMeaning
taskCurrent logical task
task_groupCurrent parallel sibling group, or root task outside a group
workflow_runCurrent workflow run when available
agent_sessionCurrent agent session when available
tenantCurrent tenant id, or tenant_id from options
processCurrent VM process

Durable and external state are not implicit. Use store_* or agent_state_* for file/EventLog-backed state and host/connector APIs for external stores.

Cells:

  • shared_cell(key_or_options, initial?) opens a scoped cell. Options support scope, key, initial, and tenant_id.
  • shared_get(cell) reads the value.
  • shared_snapshot(cell) returns {value, version} for versioned CAS.
  • shared_set(cell, value) writes with last-write-wins behavior and returns the previous value.
  • shared_cas(cell, expected_or_snapshot, value) writes only when the current value matches the expected value, and when a snapshot is supplied, the version still matches. It returns true on success and false on conflict.

Maps:

  • shared_map(key_or_options, initial?) opens a scoped map.
  • shared_map_get(map, key, default?), shared_map_set(map, key, value), shared_map_delete(map, key), and shared_map_entries(map) are the last-write-wins map operations.
  • shared_map_snapshot(map, key) and shared_map_cas(map, key, expected_or_snapshot, value) provide versioned conflict checks.

shared_metrics(handle) reports read_count, write_count, cas_success_count, cas_failure_count, stale_read_count, and version for cells and maps.

Use named synchronization around multi-step updates:

let memo = shared_map({scope: "workflow_run", key: "memo"})
let lock = sync_mutex_acquire("memo:customer-42", 250ms)
guard lock != nil else { throw "state lock timeout" }
try {
  shared_map_set(memo, "customer-42", "summary")
} finally {
  sync_release(lock)
}

Actor mailboxes

Mailboxes are scoped, named inboxes for actor-style communication between tasks and long-lived workers. They provide targeted messages without using transcript mutation as the transport.

let inbox = mailbox_open({scope: "task_group", name: "reviewer", capacity: 32})
spawn {
  mailbox_send("reviewer", {kind: "work", path: "src/main.rs"})
}
let msg = mailbox_receive(inbox)
  • mailbox_open(name_or_options, capacity?) opens or creates an inbox.
  • mailbox_lookup(name_or_handle) returns a handle or nil.
  • mailbox_send(target, value) returns false when the mailbox is absent or closed.
  • mailbox_receive(target) blocks until a message arrives, the mailbox closes, or the task is cancelled.
  • mailbox_try_receive(target) is non-blocking.
  • mailbox_close(target) closes the inbox to new messages.
  • mailbox_metrics(target) reports depth, capacity, sent_count, received_count, failed_send_count, and closed.

Supervisor trees

Supervisors manage named long-lived child loops and restart them according to a small workflow-oriented policy surface. Children are closures, so the same primitive can wrap connector streams, continuous personas, actor/mailbox loops, workflow runners, and generic Harn task groups without new syntax.

fn poll_connector(name) {
  name
}

let sup = supervisor_start({
  name: "ops",
  strategy: "one_for_one",
  children: [
    {
      name: "github-stream",
      kind: "connector_stream",
      restart: {
        mode: "on_failure",
        max_restarts: 5,
        window_ms: 60000,
        backoff_ms: 250,
        max_backoff_ms: 30000,
        factor: 2,
        jitter_ms: 100,
        circuit_open_ms: 300000,
      },
      task: { ctx -> poll_connector(ctx.child_name) },
    },
  ],
})

let _state = supervisor_state(sup)

let _events = supervisor_events(sup)
supervisor_stop(sup, 2s)

strategy supports one_for_one, one_for_all, rest_for_one, and escalate_to_parent. Restart modes are never, on_failure, and always. Restart policies support deterministic restart caps, rolling windows, exponential backoff, deterministic jitter, and circuit-open delay before a suppressed child is eligible to restart again. Child status includes running, waiting, circuit_open, stopped, failed, and suppressed.

  • supervisor_start(spec) starts a supervisor and returns a supervisor handle.
  • supervisor_state(handle_or_id) returns children, status, restart count, last error, current wait reason, active lease, next restart time, and metrics.
  • supervisor_events(handle_or_id) returns lifecycle events for child started, stopped, failed, restarted, suppressed, escalated, and supervisor shutdown.
  • supervisor_metrics(handle_or_id) returns lifecycle counters.
  • supervisor_stop(handle_or_id, timeout?) requests cooperative child cancellation, waits for drain, then force-aborts any remaining children.

runtime_context().debug.supervisors exposes the same state for runtime introspection tooling. Supervisor lifecycle events are also appended to the active EventLog topic supervisor.lifecycle when an EventLog is installed.

Channels

Channels provide typed message-passing between concurrent tasks.

let ch = channel("name", 10)   // buffered channel with capacity 10
send(ch, "hello")               // send a value, returns true
let msg = receive(ch)           // blocking receive

Channel iteration

A for-in loop over a channel asynchronously receives values until the channel is closed and drained:

let ch = channel("stream", 10)
spawn {
  send(ch, "a")
  send(ch, "b")
  close_channel(ch)
}
for item in ch {
  log(item)    // prints "a", then "b"
}
// loop exits after channel is closed and all items are consumed

When the channel is closed, remaining buffered items are still delivered. The loop exits once all items have been consumed.

close_channel(ch)

Closes a channel. After closing, send raises ChannelClosed and no new values are accepted. Buffered items can still be received. Direct receive raises ChannelClosed once the channel is closed and drained.

try_receive(ch)

Non-blocking receive. Returns the next value from the channel, or nil if the channel is empty (regardless of whether it is closed).

select

Multiplexes across multiple channels, executing the body of whichever channel receives a value first:

select {
  msg from ch1 {
    log("ch1: ${msg}")
  }
  msg from ch2 {
    log("ch2: ${msg}")
  }
}

Each case binds the received value to a variable (msg) and executes the corresponding body. Only one case fires per select.

timeout case

fn handle(msg) { log(msg) }
let ch1 = channel("events")
select {
  msg from ch1 { handle(msg) }
  timeout 5s {
    log("timed out")
  }
}

If no channel produces a value within the duration, the timeout body runs.

default case (non-blocking)

fn handle(msg) { log(msg) }
let ch1 = channel("events")
select {
  msg from ch1 { handle(msg) }
  default {
    log("nothing ready")
  }
}

If no channel has a value immediately available, the default body runs without blocking. timeout and default are mutually exclusive.

select() builtin

The statement form desugars to the select(ch1, ch2, ...) async builtin, which returns {index, value, channel}. The builtin can be called directly for dynamic channel lists.

Streams

Streams provide script-level lazy production of values. A stream producer is declared with the contextual gen fn modifier and returns Stream<T>:

gen fn numbers(start: int, end: int) -> Stream<int> {
  var n = start
  while n < end {
    emit n
    n = n + 1
  }
}

gen is contextual in this position; existing identifiers named gen remain valid. emit expr is valid only inside a gen fn. It emits one value to the consumer and resumes when the consumer pulls the next item. The existing yield form keeps its current semantics and is not used for streams.

Streams are single-pass. They can be consumed directly in for loops:

gen fn numbers(start: int, end: int) -> Stream<int> {
  var n = start
  while n < end {
    emit n
    n = n + 1
  }
}

for n in numbers(1, 4) {
  log(n)
}

They also support .next(), which returns {value, done}. When the stream is exhausted, done is true and value is nil.

Stream<T> is distinct from Generator<T> in the type checker. Regular functions that contain yield keep producing Generator<T>; gen fn produces Stream<T>. Throws inside a stream body propagate to the consumer at the pull site (for, .next(), or .iter()).

Durable agent channels

Durable agent channels (epic #1870) are distinct from the in-process channel(...) primitive above. Where in-process channels are typed mailboxes between concurrent tasks inside one VM, durable channels are a typed pub/sub primitive that writes to the active EventLog and fans each emit out to every matching channel.emit trigger binding. They survive process restarts, feed the replay oracle, and show up in the action graph alongside webhook and cron events.

Emit

The runtime exposes two builtins. emit_channel(name, payload, options?) appends one event to the channel's EventLog topic and returns a ChannelEmitReceipt. channel_events(name, options?) reads the topic oldest-first for tests and diagnostics. Reference shapes are in docs/src/builtins.md; full prose is in docs/src/agent-channels.md.

A channel name resolves into the canonical form <scope>:<scope_id>:<name>. Bare names default to tenant scope (tenant:<current-tenant-or-default>:<name>). Prefixes select an explicit scope: session:<name>, pipeline:<name>, tenant:<name>, tenant:<tenant_id>:<name>. The org:<org_id>:<name> prefix is reserved but currently rejected with HARN-CHN-002 until org grants ship.

ChannelScope

The scope enum is:

ChannelScope ::= "session" | "pipeline" | "tenant" | "org"
  • session events live in a per-process in-memory log; they are cleared by reset_channel_state() and do not cross process boundaries.
  • pipeline and tenant events use the active durable EventLog; the resolver returns HARN-CHN-001 if pipeline: is used without an active pipeline context.
  • org is reserved; v1 always returns HARN-CHN-002.

Cross-scope isolation is automatic: distinct tenant_id, session_id, or pipeline_id values resolve to distinct topics, so readers against the wrong scope id see an empty view. Explicit options.session_id or options.pipeline_id that conflict with the active runtime context fail with HARN-CHN-004 rather than silently overriding.

Idempotency and signed timestamps

options.id makes the emit idempotent. Re-emitting the same (name_resolved, id) pair returns the original event_id with duplicate: true. The receipt's emitted_at field is a signed timestamp: HMAC-SHA-256 over (at_ms, event_id, name_resolved, scope, scope_id, emitted_by) with a per-process salt and algorithm: "hmac-sha256". The replay oracle uses this to detect timestamp tampering across runs.

channel.emit trigger source

Trigger bindings subscribe to channel events with provider: "channel", kind: "channel.emit", and match.events: ["channel:<selector>"]. The selector accepts the same scope prefixes as emit_channel:

ChannelSelector ::= "channel:" Name
                  | "channel:session:" Name
                  | "channel:pipeline:" Name
                  | "channel:tenant:" TenantId ":" Name

The dispatched TriggerEvent carries the emit's payload verbatim at event.provider_payload.payload; event.batch is nil for ordinary single-event dispatches and populated for batched bindings (next section).

batch { count, window, key?, expire_action? } (BatchFilter)

A trigger binding may declare aggregation:

BatchFilter ::= {
  count: int (> 0),
  window: DurationString,
  key?: string,                  // dotted JSON path into payload
  expire_action?: "fire_partial" | "discard"   // default "fire_partial"
}

Semantics:

  • Each filter-passing event increments a per-(binding, partition_key) counter. Reaching count invokes the handler with event.batch populated to the buffered list and resets the counter.
  • key partitions counters by the stringified value at that JSON path into the channel payload. Missing paths fall back to one shared bucket (matches SpawnToPool's "missing path = default" pattern).
  • When the window elapses without reaching count, expire_action decides: fire_partial invokes the handler with whatever was buffered; discard drops the buffer silently.
  • Buffers are per-process thread-local and capped at 1024 events per partition. Overflow drops the oldest entries with a structured triggers.aggregation.buffer_overflow warning.
  • Window expiration is driven by an implicit sweep at every emit and by the explicit flush_trigger_aggregations() builtin; the latter is the test affordance and pairs with mock_time(...) / advance_time(...).

Malformed configs (missing fields, non-positive count, unknown expire_action, wrong-typed key) raise HARN-CHN-005.

ReminderInjectHandler

ReminderInject(options) (from std/triggers) constructs a handler- variant dict that, on match, injects a SystemReminder event (see docs/src/system-reminders.md) into the target session's transcript at the next turn boundary. It is the orthogonal counterpart to SpawnToPool — no spawn, no resume, no signal; the target session keeps running and receives the typed context at the next post-turn lifecycle pass.

ReminderInjectHandler ::= {
  kind: "reminder_inject",
  target: "current" | "parent" | SessionId | (event -> SessionId?),
  body: PromptTemplate,                    // .harn.prompt
  tags?: list<string>,
  ttl_turns?: int,
  dedupe_key?: string,
  propagate?: "none" | "session",
  role_hint?: "user" | "developer",
  preserve_on_compact?: bool
}

body is rendered against {{ event }} (the full TriggerEvent), {{ match }} (matched_at), and {{ batch }} (the constituent list when batching is in effect). Missing target sessions are dropped gracefully with a triggers.reminder_inject.audit audit entry rather than failing dispatch.

Observability

Channel activity is published on three EventLog topics:

TopicSchemaPurpose
lifecycle.channel.auditharn.channel_emit_receipt.v1, harn.channel_match_receipt.v1, harn.channel_guardrail_audit.v1Durable replay/audit. One receipt per emit, per match, and per guardrail verdict.
transcript.channel.lifecycletranscript.channel.emit, transcript.channel.matchPer-emit and per-match transcript events for live rendering.
channels.<scope>.<scope_id>.<name>StoredChannelEventThe events themselves, addressable via event_log.subscribe(...).

Every emit opens an OTel channel.emit <name_resolved> span; every match opens channel.match <name_resolved> and links back to the emit span via trace/span ids propagated on the trigger event headers (this propagation passes through batch aggregation, so a batched match links to all constituent emit spans).

Replay determinism

The replay oracle consumes lifecycle.channel.audit to detect:

  • HARN-REP-CHN-001 — replay matched an event_id with no recorded receipt.
  • HARN-REP-CHN-002 — payload hash drift on a replayed emit (producer- side nondeterminism).
  • HARN-REP-CHN-003 — batched match constituent ids differ across runs.

See docs/src/observability/replay-benchmarks.md for the oracle interface. The user-facing reference is docs/src/agent-channels.md; runnable patterns live in docs/src/cookbooks/channels.md.

Agent pools

Agent pools (epic #1883) are named, concurrency-bounded worker pools for agent work. They sit between parallel each ... with { max_concurrent: N } (a local, call-site bound) and host worker tiers (a deployment-time bound), filling the "many independent submitters share one budget" gap. Pools are exposed through std/lifecycle/pool and the host-call group registered at crates/harn-vm/src/stdlib/pool/mod.rs. On a multi-thread Tokio runtime, pool workers run as tokio::spawned child-VM isolates that inherit the VM's pool registry handle and move Send VM values across the worker boundary.

Pool

PoolHandle ::= {
  _type: "pool",
  id: string,                 // deterministic = sha256(scope || scope_id || name)
  name: string,
  max_concurrent: int (>= 1),
  scope: PoolScope,
  created_at: ISO8601String,
  submit: (closure, options?) -> PoolTaskHandle,
  size: () -> int,
  snapshot: () -> PoolSnapshot,
}

PoolScope ::= "session" | "pipeline" | "tenant" | "org"

pool_create(options?) allocates a handle; pool_get(name_or_id) returns an existing one or nil; pool_list() enumerates the runtime registry. Names are unique within a live VM registry, so callers use pool_get to reuse an existing pool. Pipeline-scope pools use a deterministic (scope, scope_id, name) id so re-creating the pool after process restart binds to the persisted state.

PoolTaskHandle

PoolTaskHandle ::= {
  _type: "pool_task",
  id: string,
  pool: string,                       // pool name
  pool_id: string,
  status: "queued" | "running" | "completed" | "failed" | "rejected",
  submitted_at: ISO8601String,
  priority: int,
  key: string | nil,
  // terminal-only:
  result: any,
  error: string | nil,
  rejection_reason: string | nil,
  rejection_policy: string | nil      // "fail_fast" | "fail_submitter" | "drop_oldest" | "drop_newest"
}

pool_wait(handle_or_handles) blocks until terminal and returns the final snapshot (or list of snapshots). wait_agent(handle) from std/agent/workers accepts pool task handles transparently by matching on _type == "pool_task".

Queue strategies

pool_create({queue: <descriptor>}) selects how the pool dequeues work when a worker slot frees:

QueueStrategy ::= {_type: "queue_strategy", kind: "fifo" | "priority" | "lifo" | "fair_round_robin", key?: string}

Semantics:

  • fifo — oldest queued first, ignoring submit priority.
  • priority (default) — highest submit priority first, FIFO tiebreak.
  • lifo — newest queued first.
  • fair_round_robin{key} — partition queued tasks by the stringified value at options.<key> on each submit. Missing field shares one default partition. The runtime walks distinct partitions in stable order, dequeuing one task per visit; FIFO inside a partition.

Factories (std/lifecycle/pool): fifo(), priority(), lifo(), fair_round_robin(key = "key"). QueueStrategy() returns the four as a dict for dotted access.

Backpressure

pool_create({backpressure: <descriptor>}) bounds the queue and selects the overflow policy:

Backpressure ::= {_type: "backpressure", kind: "queue" | "fail_fast" | "ring_buffer", max_depth?: int, on_full?: OnFullPolicy, capacity?: int}

OnFullPolicy ::= "block_submitter" | "drop_oldest" | "drop_newest" | "fail_submitter"

Semantics:

  • queue{max_depth, on_full} — bound queued tasks at max_depth. block_submitter (default) parks the submitting fiber until a slot frees. drop_oldest evicts the oldest queued task as a rejected handle and accepts the new submit. drop_newest rejects the new submit. fail_submitter raises HARN-POL-001 at the submit call site.
  • fail_fast{} — raise HARN-POL-002 synchronously when no worker slot is immediately free; no queue is retained.
  • ring_buffer{capacity} — retain the newest capacity queued tasks by evicting the oldest queued task on overflow.

Drop policies (drop_oldest, drop_newest, ring_buffer) emit a pool_drop audit on lifecycle.pool.audit with the pool name, evicted task id, policy, queue depth, and max depth. The submitter sees a task handle whose terminal snapshot is {status: "rejected", rejection_reason, rejection_policy}.

Scopes and durability

ScopeStorageSurvivesNotes
sessionVM-scoped in-memory registry.VM/session lifetime.Default. Zero I/O.
pipelineJSONL append-log under .harn/pools/<pipeline_id>__<pool_name>.jsonl.Process restart, within one pipeline.Terminal and stale task metadata reload on next pool_create({scope: "pipeline", ...}).
tenant, orgHost-managed registry.Tenant / org lifetime.Currently rejected by the in-process runtime unless an embedding host implements tenant/org pool routing.

On pipeline-scope reload, persisted Queued or Running task records are restored as failed stale markers because the in-memory closure body cannot be reconstructed after process death. Callers that need retry semantics resubmit with an idempotency_key; the stale marker preserves the audit trail and allows the fresh submit to execute as a new task. stale_after_ms (default 30000 ms; configurable via opts.stale_after_ms) is retained as the freshness knob for future host backends. The store compacts opportunistically when max_concurrent + |queue| + |terminal-since-compaction| exceeds an internal threshold. pool_simulate_restart() drops the in-process registry without touching disk and is the conformance affordance for reload tests.

Idempotency

pool.submit(closure, {idempotency_key: K, ...}) short-circuits when (pool_id, K) already exists in the idempotency index. The second call returns the same task handle (id matches the first) and, if the first task is terminal, the terminal snapshot. Pipeline-scope pools persist the index so resubmit across restart preserves the contract.

spawn_to_pool trigger handler

A trigger binding may route matched events into a named pool by declaring a SpawnToPool handler (from std/triggers):

SpawnToPoolHandler ::= {
  kind: "spawn_to_pool",
  pool: string,                             // pool name or id
  task_factory: (event -> closure),         // builds the per-event closure
  priority_from?: string,                   // dotted JSON path; missing -> 0
  key_from?: string                         // dotted JSON path; missing -> default partition
}

The dispatcher resolves pool via pool_get, invokes task_factory(event), and submits the resulting closure under the pool's queue strategy + backpressure policy. The resulting pool task id is recorded on the trigger match receipt so the replay oracle can verify the same event maps to the same task across runs. A missing pool name lands in trigger.dlq.

Observability

Pool activity is published on one EventLog topic:

TopicRecords
lifecycle.pool.auditpool_submit, pool_dequeue, pool_drop

Every submit opens an OTel pool.submit <pool_name> span; every dequeue opens pool.dequeue <pool_name> and links back to the submit span so async-boundary traces remain stitched.

Pool tasks surface in pipeline lifecycle drains: harness.unsettled_state().pool_pending_tasks lists tasks blocking pipeline finalization (see docs/src/stdlib/lifecycle.md).

The user-facing reference is docs/src/agent-pools.md; the stdlib reference is docs/src/stdlib/lifecycle-pool.md; runnable patterns live in docs/src/cookbooks/pools.md.

Pipeline lifecycle

Pipelines do not end the moment their declared steps return. Between the last statement of the pipeline body and the value the host sees, the runtime fires a fixed sequence of lifecycle gates and one user callback. The same callback shape — fn(harness, return_value) -> return_value — threads through every gate, so presets and combinators that work for on_finish also work for hook handlers, resume_by callbacks, and any custom drain logic.

Lifecycle event order

When a pipeline's declared steps complete the runtime walks this sequence on the main VM:

  1. PreFinish — last chance to inject a reminder before the pipeline value is captured. Rejects {block: true}; the runtime surfaces a runtime error pointing at OnFinish.block_until_settled.
  2. The registered on_finish callback. Default behavior (no registration) is identical to on_finish_abandon.
  3. OnUnsettledDetected — fires after the callback if any bucket in harness.unsettled_state() is non-empty. Accepts {block: true, reason} to delay finish until the host explicitly drains and {modify: payload} to amend the snapshot.
  4. PostFinish — advisory; observe the final value, push telemetry.
  5. The value is returned to the host.

Each step records hook_call / hook_returned / hook_vetoed events on the active session transcript so replay reproduces the same control flow.

Pipeline.on_finish semantic

pipeline_on_finish(callback) is a stdlib builtin that registers a fn(harness, return_value) closure into a thread-local one-shot slot (PIPELINE_ON_FINISH). The slot is last-write-wins inside one run. Vm::execute consumes the registered callback via take_pipeline_on_finish exactly once, between the PreFinish and OnUnsettledDetected gates. The callback's return value replaces the pipeline's return value before PostFinish fires. On the error exit path the slot is cleared so a failed pipeline cannot leak its registration into the next run.

Harness type

The single argument to every lifecycle callback is the harness. The read-side surface is unsettled_state(): UnsettledStateSnapshot, which returns a stable JSON-shaped dict with five lists: suspended_subagents, queued_triggers, partial_handoffs, in_flight_llm_calls, and pool_pending_tasks. The is_empty, counts, and summary derived methods accept either an already-taken snapshot (for callback-consistent decisions) or no argument (fresh snapshot per call). Producers populate buckets from live VM registries (suspended subagents, partial handoffs, in-flight LLM calls, pool pending tasks) and from event-log records (queued trigger inbox + worker queue items).

Capability sub-handles are exposed by field access on the harness:

FieldTypePrimary methods
harness.stdioHarnessStdioprint, println, eprint, eprintln, read_line, prompt
harness.termHarnessTermwidth, height, read_password
harness.clockHarnessClocknow_ms, timestamp, monotonic_ms, elapsed, sleep_ms
harness.fsHarnessFsread_text, write_text, append_text, exists, list_dir
harness.envHarnessEnvget, set, unset, list
harness.randomHarnessRandomgen_u64, uuid, bytes
harness.netHarnessNetget, post
harness.systemHarnessSystemplatform, arch, cpu, memory, cwd, pid

Write-side actions:

MethodEffect
resume_subagent(handle, input?)Resume a suspended worker; falls back to send-input for awaiting retriggerables.
cancel_subagent(handle, reason?)Close a suspended worker via __host_worker_close.
handoff_to(target_pipeline, payload?)Record a PartialHandoffEnvelope in the thread-local registry; returns {status: "queued", envelope}.
acknowledge_trigger(id)Settle a queued inbox or worker-queue item with the existing ack record.
defer_trigger(id, target_pipeline?)Ack the trigger and record a partial-handoff envelope (default target deferred-triggers).
acknowledge_handoff(envelope_id, decision?)Remove a partial envelope from the registry; emit handoff_acknowledged audit.
wait_for_any_settlement(max_duration?)Snapshot + return {status, timed_out, state}.
emit_audit(kind, payload?)Append a LifecycleAuditEntry to the per-run log and (when an EventLog is installed) the pipeline.lifecycle.audit topic.
finalize(disposition?)Stamp the run's final disposition; emit pipeline_finalized.
spawn_settlement_agent(unsettled, return_value)Hand off to the bounded settlement-agent drain loop.
current_pipeline_id()Run id from the current mutation session, else session id, else nil.

DrainAgent constrained tool surface + ordering enforcement

The settlement-agent loop (harness.spawn_settlement_agent) walks the unsettled snapshot in a fixed canonical order: suspended subagents → queued triggers → partial handoffs → in-flight LLM calls → pool pending. Each item receives a default disposition:

BucketDefault disposition
suspended_subagentsCancel via harness.cancel_subagent.
queued_triggersAcknowledge via harness.acknowledge_trigger.
partial_handoffsAcknowledge as deferred via harness.acknowledge_handoff.
in_flight_llm_callsDrain via the LLM call registry.
pool_pending_tasksDefer via the pool registry.

The loop is bounded by a per-call budget — default 5, configurable via the third arg to spawn_settlement_agent, hard-capped at 20. On exhaustion a drain_unsettled_remaining audit captures the remainder. Each disposition records a drain_decision lifecycle audit and fires the OnDrainDecision hook chain (Allow / Block / Modify) before persisting, so VM-side hooks observe the disposition before it lands.

Ordering enforcement: harness.acknowledge_trigger and harness.acknowledge_handoff reject out-of-order calls with HARN-DRN-001. A caller (the settlement-agent loop or a future LLM-driven settlement variant) cannot finalize a later category while earlier work is still pending. __host_settlement_agent_active() returns true when the constrained drain tool surface is in scope so conformance fixtures and IDE hosts can observe the loop boundary.

Lifecycle event taxonomy

The runtime exposes 40 hook events. Registration surfaces: register_tool_hook (tool events), register_persona_hook (persona events), register_worker_hook (worker events), and register_session_hook (session events).

EventCategoryReminder effects
PreToolUse, PostToolUsetoolsupported
PreAgentTurn, PostAgentTurnpersonasupported
WorkerSpawned, WorkerProgressed, WorkerWaitingForInput, WorkerSuspended, WorkerResumed, WorkerCompleted, WorkerFailed, WorkerCancelledworkerrejected (HARN-RMD-002)
PreStep, PostSteppersonasupported
OnBudgetThresholdpersonasupported
OnApprovalRequested, OnHandoffEmittedpersonasupported
OnPersonaPaused, OnPersonaResumedpersonasupported
SessionStart, SessionEndsessionsupported
UserPromptSubmitsessionsupported, accepts {block, reason}
PreCompact, PostCompactsessionsupported
PostTurnsessionsupported
PermissionAsked, PermissionRepliedsessionaccepts {decision: "allow"|"deny"|"ask", reason}
FileEditedsessionsupported (drained per-turn)
SessionError, SessionIdlesessionsupported
PreFinishsessionsupported; rejects {block: true}
PostFinishsessionsupported (advisory)
OnUnsettledDetectedsessionaccepts {block, reason} and {modify: payload}
PreSuspend, PostSuspend, PreResume, PostResumesessionsuspend / resume gates
PreDrain, PostDrain, OnDrainDecisionsessiondrain-loop gates

Veto-capable events accept {block: true, reason}. Lifecycle-gate events that support payload rewriting also accept {modify: payload} to amend the dispatched event before resuming the lifecycle step. Hook returns also accept a reminder effect ({reminder: {...}} or a bare reminder spec) on every event whose supports_reminder_effects() is true. Worker events reject reminder effects with diagnostic HARN-RMD-002 because the worker dispatcher does not own a session transcript.

Replay determinism rules

Every lifecycle decision is reproducible on a replay:

  1. Cached resume input. harness.resume_subagent(handle, input) persists the input snapshot on the resume event so the replay oracle feeds the same value back into the same worker without re-reading host state.
  2. Memoized drain decisions. Each drain_decision audit captures the bucket, item id, and disposition. The replay oracle consumes the audit log instead of re-walking the snapshot, so a non-deterministic on_drain_decision hook (one that consults wall-clock or external state) cannot drift the second run.
  3. Signed timestamps. harness.emit_audit stamps entries with a per-run monotonic LIFECYCLE_SEQ counter rather than wall-clock time. Wall-clock fields (queued_at_ms, age_ms) come from clock_mock-aware sources and respect mock_time(...) / advance_time(...) in tests.
  4. One-shot registration. pipeline_on_finish(callback) is last-write-wins; the slot is consumed exactly once per run via take_pipeline_on_finish. The error exit path clears the slot alongside the audit log, partial-handoff registry, disposition slot, and seq counter, so a failed run cannot leak in-progress state into the next run.

The user-facing reference is docs/src/pipeline-lifecycle.md; the stdlib reference is docs/src/stdlib/lifecycle.md; runnable patterns live in docs/src/cookbooks/lifecycle.md.

Error model

throw

throw expression

Evaluates the expression and throws it as HarnRuntimeError.thrownError(value). Any value can be thrown (strings, dicts, etc.).

try/catch/finally

try {
  // body
} catch (e) {
  // handler
} finally {
  // cleanup — always runs
}

If the body throws:

  • A thrownError(value): e is bound to the thrown value directly.
  • Any other runtime error: e is bound to the error's localizedDescription string.

return inside a try block propagates out of the enclosing pipeline (is not caught).

The error variable (e) is optional: catch { ... } is valid without it.

try { ... } catch (e) { ... } is also usable as an expression: the value of the whole form is the tail value of the try body when it succeeds, and the tail value of the catch handler when an error is caught. This means the natural let v = try { risky() } catch (e) { fallback } binding is supported directly, without needing to restructure through Result helpers. When a typed catch (catch (e: AppError) { ... }) does not match the thrown error's type, the throw propagates past the expression unchanged — the surrounding let never binds. See the Try-expression section below for the Result-wrapping behavior when catch is omitted.

try* (rethrow-into-catch)

try* EXPR is a prefix operator that evaluates EXPR and rethrows any thrown error so an enclosing try { ... } catch (e) { ... } can handle it, instead of forcing the caller to manually convert thrown errors into a Result and then guard is_ok / unwrap. The lowered form is:

{ let _r = try { EXPR }
  guard is_ok(_r) else { throw unwrap_err(_r) }
  unwrap(_r) }

On success try* EXPR evaluates to EXPR's value with no Result wrapping. The rethrow runs every finally block between the rethrow site and the innermost catch handler exactly once, matching the finally exactly-once guarantee for plain throw.

fn fetch(prompt) {
  // Without try*: try { llm_call(prompt) } / guard is_ok / unwrap
  let response = try* llm_call(prompt)
  return parse(response)
}

let outcome = try {
  let result = fetch(prompt)
  Ok(result)
} catch (e: ApiError) {
  Err(e.code)
}

try* requires an enclosing function (fn, tool, or pipeline) so the rethrow has a body to live in — using it at module top level is a compile error. The operand is parsed at unary-prefix precedence, so try* foo.bar(1) parses as try* (foo.bar(1)) and try* a + b parses as (try* a) + b. Use parentheses to combine try* with binary operators on its operand. try* is distinct from the postfix ? operator: ? early-returns Result.Err(...) from a Result-returning function, while try* rethrows a thrown value into an enclosing catch.

finally

The finally block is optional and runs regardless of whether the try body succeeds, throws, or the catch body re-throws. Supported forms:

try { ... } catch e { ... } finally { ... }
try { ... } finally { ... }
try { ... } catch e { ... }

return, break, and continue inside a try body with a finally block will execute the finally block before the control flow transfer completes.

The finally block's return value is discarded — the overall expression value comes from the try or catch body.

Functions and closures

fn declarations

fn name(param1, param2) {
  return param1 + param2
}

Declares a named function. Equivalent to let name = { param1, param2 -> ... }. The function captures the lexical scope at definition time.

Default parameters

Parameters may have default values using = expr. Required parameters must come before optional (defaulted) parameters. Defaults are evaluated fresh at each call site (not memoized at definition time). Any expression is valid as a default — not just literals.

fn greet(name, greeting = "hello") {
  log("${greeting}, ${name}!")
}
greet("world")           // "hello, world!"
greet("world", "hi")     // "hi, world!"

fn config(host = "localhost", port = 8080, debug = false) {
  // all params optional
}

let add = { x, y = 10 -> x + y }  // closures support defaults too

Explicit nil counts as a provided argument (does NOT trigger the default). Arguments are positional — fill left to right, only trailing defaults can be omitted.

tool declarations

tool read_text(path: string, encoding: string = "utf-8") -> string {
  description "Read a file from the filesystem"
  read_file(path)
}

tool search_files(query: string, file_glob: string = "*.py") -> string {
  description "Search files matching an optional glob"
  "..."
}

Declares a named tool and registers it with a tool registry. The body is compiled as a closure and attached as the tool's handler. An optional description metadata string may appear as the first statement in the body.

Annotated tool parameter and return types are lowered into the same schema model used by runtime validation and structured LLM I/O. Primitive types map to their JSON Schema equivalents, while nested shapes, list<T>, dict<string, V>, and unions produce nested schema objects. Parameters with default values are emitted as optional schema fields (required: false) and include their default value in the generated tool registry entry.

The result of a tool declaration is a tool registry dict (the return value of tool_define). Multiple tool declarations accumulate into separate registries; use tool_registry() and tool_define(...) for multi-tool registries.

Like fn, tool may be prefixed with pub.

Tool execution backend (executor)

Every entry registered through tool_define declares its execution backend. The runtime uses the declared executor to dispatch the call and to populate tool_call_update.executor on the ACP wire so clients can route badges and latency by transport.

executorRequired companion fieldBackend
"harn" (alias "harn_builtin")handler (closure)In-VM Harn handler. The VM stdlib short-circuits read_file / list_directory even without a registered handler.
"host_bridge"host_capability: "cap.op"Host shell builtin_call bridge. harn check validates the binding against the host capability manifest.
"mcp_server"mcp_server: "<server_name>"Configured MCP server. Tools sourced from mcp_list_tools carry the equivalent _mcp_server annotation.
"provider_native"(none)Provider-side server tools (e.g. OpenAI Responses-API). Never dispatched locally.

tool_define rejects invalid combinations (executor: "host_bridge" plus a handler, executor: "harn" without a handler outside the VM-stdlib short-circuit set, missing host_capability / mcp_server, unknown executor strings) at definition time. When executor is omitted, the registration is interpreted as "harn" if a handler is present, and rejected otherwise — eliminating the historical [builtin_call] unhandled: <name> runtime failure.

agent_loop re-validates at startup and refuses to run if any tool in the bound registry has no executable backend. The error message names the offending tool and the documented set of executors.

let registry = tool_registry()
registry = tool_define(registry, "ask_user", "Ask the user", {
  parameters: {prompt: "string"},
  executor: "host_bridge",
  host_capability: "interaction.ask",
})

Registries exposed through mcp_tools(registry) should declare MCP tool annotations on each tool_define entry. The MCP server forwards recognized annotations fields (title, readOnlyHint, destructiveHint, idempotentHint, and openWorldHint) plus top-level title and icons metadata into the tools/list response. harn lint warns when a registry passed to mcp_tools contains a tool_define entry without annotations.

registry = tool_define(registry, "repo.status", "Read repository status", {
  parameters: {},
  title: "Repository Status",
  handler: { _args -> git.status() },
  annotations: {
    title: "Repository Status",
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
  },
  icons: [{src: "https://example.com/repo-status.png", mimeType: "image/png"}],
})

Tool surface validation

tool_surface_validate(surface, options?) validates a tool-calling surface before a model call. The surface may include tools, policy, approval_policy, system / prompt / prompts, and tool_search. It returns {valid, diagnostics} where each diagnostic has a stable code, severity, and message.

agent_loop runs the validator at startup. Warning diagnostics are logged; error diagnostics abort the loop before the first model call. workflow_validate and workflow_policy_report include the same diagnostics for workflow and stage surfaces.

Agent loop tool narrowing

agent_loop narrows the model-visible tool surface between turns by default. The tool_surface_narrowing option accepts false to disable, true to use defaults, or a dict with enabled, window_turns, hard_keep, mode, prune_classes, keep_classes, and unknown_tool_policy. After window_turns observed turns, tools in prunable classes that were not attempted in that rolling window are removed from the next model call unless listed in hard_keep. The default window is five turns.

Narrowing is usage-based only; it does not inspect file extensions, languages, or project metadata. The default mode: "safe" prunes only read_only tools and keeps mutating, approval, session_control, progress, result_polling, and unknown tools by class. Host/custom tools with missing side-effect annotations are therefore kept by default. Set mode: "aggressive" to opt into usage-only pruning across tool classes, or override prune_classes, keep_classes, and unknown_tool_policy for a custom policy. Host surfaces should annotate tools with annotations.side_effect_level (none, read_only, workspace_write, process_exec, or network) and annotations.kind so narrowing can classify them intentionally.

Explicit skill activation widens back to the skill-scoped surface, after which narrowing may run again. Every narrowing step emits a skill_narrow agent event with reason, removed_tools, remaining_tools, policy, removed_tool_details, and kept_tool_details so replayers and hosts can explain why a tool did or did not remain visible.

Agent loop completion gates

agent_loop may gate a pending stop with verify_completion, verify_completion_judge, or done_judge. verify_completion is a Harn closure hook. verify_completion_judge runs the built-in structured judge for any natural stop. done_judge runs after a native-tool loop naturally completes or after the model emits the configured done sentinel. The structured judge returns verdict: "done" | "continue" plus optional reasoning and next_step, and injects judge feedback before continuing when the verdict rejects completion. Each built-in judge call emits JudgeDecision {session_id, iteration, verdict, reasoning, next_step, judge_duration_ms, trigger?}. The optional trigger is "stalled" when a done_judge.cadence.when: "stalled" judge fires from an agent_loop_stall_warning; a done verdict stops the loop with stalled_done_judge before the repeated tool call dispatches, and a continue verdict leaves the stall feedback path in place.

Agent progress events

std/agent/progress exposes agent_progress(input) and agent_progress_tool(registry?, options?) for live agent status. The agent_progress input must contain a non-empty message or entries. entries is a list of task-list dicts with content, status, and optional priority; replace defaults to true, and metadata defaults to {}.

At runtime agent_progress emits the progress_reported agent event for the current agent session. ACP bridges encode task-list entries as canonical session/update payloads with sessionUpdate: "plan" and full-replacement entries; message-only reports use Harn progress narration. A2A bridges encode progress reports as non-terminal TaskStatusUpdateEvent messages with status.state = "working" and no push-delivery side effect.

agent_progress_tool installs a model-facing tool that calls agent_progress. agent_loop(..., {progress_tool: true}) installs the default tool; dict form may override name, description, and system_prompt_nudge. Progress reports are intended for meaningful sub-step completion or plan changes, not fixed timer ticks.

Agent scratchpad

agent_loop(..., {scratchpad: true}) initializes a session-local harn.agent_scratchpad.v1 value with goals, open_items, facts, and refs. The current scratchpad is recited as a prompt-tail fragment every turn and, by default, reorganized every three continuing turns through a structured-output LLM call. Dict form may set enabled, recite, reorganize_every, max_recent_messages, schema_retries, initial, and reorganizer; the reorganizer options may select a different provider/model from the worker.

Reorganization is required to keep heavy content by reference. Returned facts must cite source_ref values present in the current scratchpad or recent session messages; a reorganization that invents refs is rejected and the prior scratchpad remains active. Successful and failed reorganization attempts emit agent_scratchpad_reorganization events with status and count metadata.

agent_turn(prompt, options?) is the high-level wrapper for a single complete agent request. It uses agent_loop, folds options.system into the system prompt with generic progress guidance, defaults to explicit loop_until_done completion, and requires done_judge (omitted means the default judge).

Execute tools that can emit large result artifacts declare this in ToolAnnotations:

annotations: {
  kind: "execute",
  side_effect_level: "process_exec",
  emits_artifacts: true,
  result_readers: ["read_command_output"],
}

Set inline_result: true instead of result_readers when the tool always returns complete inline output. Prompt scans ignore fenced code blocks and support harn-tool-surface: ignore-line, harn-tool-surface: ignore-next-line, and harn-tool-surface: ignore-start / ignore-end comments for historical examples.

Command execution policy hooks

Command execution policy is a first-class Harn value created with command_policy(config). A policy can be installed directly with command_policy_push(policy) / command_policy_pop() or scoped to agent_loop with command_policy: policy or policy: { command_policy: policy }. While installed, host_call("process.exec", ...) builds a normalized command context, runs deterministic risk classification, executes the policy pre-hook before spawning, and records decisions in the returned command envelope under command_policy.

command_policy config fields:

FieldDescription
toolsAgent-visible tool names the policy is intended to guard when threaded through a workflow or harness
workspace_rootsWorkspace roots used for outside-workspace classification
default_shell_modePolicy hint such as "argv_only" for harness/tool authors
deny_patternsGlob/substring patterns that block before spawn
require_approvalRisk labels that block with a require-approval decision unless the host grants approval
preClosure receiving normalized command context; returns nil, {deny}, {require_approval}, {rewrite}, {dry_run}, or {explain_only}
postClosure receiving command context plus the result envelope; may return {result}, {feedback}, or audit annotations
allow_recursiveAllows policy hooks to call the command runner recursively; default is false

The pre-hook context includes the resolved request (mode, argv, command, shell, cwd, redacted env diff, stdin size/hash, timeout), active cwd, workspace roots, policy ceiling, tool annotations, redacted transcript slots, caller metadata, and deterministic scan results. Rewrite is constrained to command-runner request fields such as argv, command, cwd, env, timeout_ms, shell, and capture settings.

Standard deterministic risk labels include destructive, write_intent, outside_workspace, curl_pipe_shell, credential_file_read, network_exfil, sudo, package_install, git_force_push, and process_kill. command_llm_risk_scan(ctx, options?) returns the same structured shape (risk_labels, confidence, rationale, recommended_action) with redacted scan options; it is safe to use in tests without making a network call.

Deferred tool loading (defer_loading)

A tool registered through tool_define may set defer_loading: true in its config dict. Deferred tools keep their schema out of the model's context on each LLM call until a tool-search call surfaces them.

fn admin(token) { log(token) }

let registry = tool_registry()
registry = tool_define(registry, "rare_admin_action", "...", {
  parameters: {token: {type: "string"}},
  defer_loading: true,
  handler: { args -> admin(args.token) },
})

defer_loading is validated as a bool at registration time — typos like defer_loading: "yes" raise at tool_define rather than silently falling back to eager loading.

Deferred tools are only materialised on the wire when the call opts into tool_search (see the llm_call option of the same name and docs/src/llm-and-agents.md). Harn supports two native backends plus a provider-agnostic client fallback:

  • Anthropic Claude Opus/Sonnet 4.0+ and Haiku 4.5+ — Harn emits defer_loading: true on each deferred tool and prepends the tool_search_tool_{bm25,regex}_20251119 meta-tool. Anthropic keeps deferred schemas in the API prefix (prompt caching stays warm) but out of the model's context.
  • OpenAI GPT 5.4+ (Responses API) — Harn emits defer_loading: true on each deferred tool and prepends {"type": "tool_search", "mode": "hosted"} to the tools array. OpenRouter, Together, Groq, DeepSeek, Fireworks, HuggingFace, and local vLLM inherit the capability when their routed model matches gpt-5.4+.
  • Everyone else (and any of the above on older models) — Harn injects a synthetic __harn_tool_search tool and runs the configured client strategy in Harn/VM space, promoting matching deferred tools into the next turn's schema list. Built-in client strategies are bm25, regex, and hybrid; custom scorer closures and {handler} strategy objects can replace the search step completely.

Tool entries may also set namespace: "<label>" to group deferred tools for the OpenAI meta-tool's namespaces field. The field is a harmless passthrough on Anthropic — ignored by the API, preserved in replay.

mode: "native" refuses to silently downgrade and errors when the active (provider, model) pair is not natively capable; mode: "client" forces the fallback everywhere; mode: "auto" (default) picks native when available.

The per-provider / per-model capability table that gates native tool_search, defer_loading, prompt caching, and typed thinking modes is a shipped TOML matrix overridable per-project via [[capabilities.provider.<name>]] in harn.toml. Scripts query the effective matrix at runtime with:

let caps = provider_capabilities("anthropic", "claude-opus-4-7")
// {
//   provider, model, native_tools, text_tool_wire_format_supported,
//   preferred_tool_format: "native" | "text",
//   tool_mode_parity: "interchangeable" | "unknown" | "native_unreliable" | "text_unreliable" | "native_only" | "text_only" | "unsupported",
//   tool_mode_parity_notes: string | nil,
//   tools, defer_loading,
//   tool_search: [string], max_tools: int | nil,
//   prompt_caching, thinking, thinking_modes: [string],
//   reasoning_effort_supported, reasoning_none_supported,
//   message_wire_format, native_tool_wire_format,
//   prefers_xml_scaffolding, prefers_markdown_scaffolding,
//   structured_output_mode: "native_json" | "delimited" | "xml_tagged" | "none",
//   supports_assistant_prefill, prefers_role_developer, prefers_xml_tools,
//   thinking_block_style: "none" | "thinking_blocks" | "reasoning_summary" | "inline",
// }

The provider_capabilities_install(toml_src) and provider_capabilities_clear() builtins let scripts install and revert overrides in-process for cases where editing the manifest is awkward (runtime proxy detection, conformance test setup). See docs/src/llm/providers.md#capability-matrix--harntoml-overrides for the rule schema.

skill declarations

pub skill deploy {
  description "Deploy the application to production"
  when_to_use "User says deploy/ship/release"
  invocation "explicit"
  paths ["infra/**", "Dockerfile"]
  allowed_tools ["bash", "git"]
  model "claude-opus-4-7"
  effort "high"
  prompt "Follow the deployment runbook."

  on_activate fn() {
    log("deploy skill activated")
  }
  on_deactivate fn() {
    log("deploy skill deactivated")
  }
}

Declares a named skill and registers it with a skill registry. A skill bundles metadata, tool references, MCP server lists, system-prompt fragments, and auto-activation rules into a typed unit that hosts can enumerate, select, and invoke.

Body entries are <field_name> <expression> pairs separated by newlines or semicolons. The field name is an ordinary identifier (no keyword is reserved), and the value is any expression — string literal, list literal, identifier reference, dict literal, or fn-literal (for lifecycle hooks). The compiler lowers the decl to:

skill_define(skill_registry(), NAME, { field: value, ... })

and binds the resulting registry dict to NAME, parallel to how tool NAME { ... } works.

skill_define performs light value-shape validation on known keys: description, when_to_use, prompt, invocation, model, effort must be strings; paths, allowed_tools, mcp must be lists. Mistyped values fail at registration rather than at use. Unknown keys pass through unchanged to support integrator metadata.

Like fn and tool, skill may be prefixed with pub to export it from the module. The registry-dict value is bound as a module-level variable.

Skill registry operations

let reg = skill_registry()
let reg = skill_define(reg, "review", {
  description: "Code review",
  invocation: "auto",
  paths: ["src/**"],
})
skill_count(reg)           // int
skill_find(reg, "review")  // dict | nil
skill_list(reg)            // list (closure hooks stripped)
skill_select(reg, ["review"])
skill_remove(reg, "review")
skill_describe(reg)        // formatted string

skill_list strips closure-valued fields (lifecycle hooks) so its output is safe to serialize. skill_find returns the full entry including closures.

@acp_skill attribute

Functions can be promoted into skills via the @acp_skill attribute:

@acp_skill(name: "deploy", when_to_use: "User says deploy", invocation: "explicit")
pub fn deploy_run() { ... }

Attribute arguments populate the skill's metadata dict, and the annotated function is registered as the skill's on_activate lifecycle hook. Like @acp_tool, @acp_skill only applies to function declarations; using it on other kinds of item is a compile error.

Closures

let f = { x -> x * 2 }
let g = { a, b -> a + b }

First-class values. When invoked, a child environment is created from the captured environment (not the call-site environment), and parameters are bound as immutable bindings.

Spread in function calls

The spread operator ... expands a list into individual function arguments. It can be used in both function calls and method calls:

fn add(a, b, c) {
  return a + b + c
}

let args = [1, 2, 3]
add(...args)           // equivalent to add(1, 2, 3)

Spread arguments can be mixed with regular arguments:

fn add(a, b, c) { return a + b + c }

let rest = [2, 3]
add(1, ...rest)        // equivalent to add(1, 2, 3)

Multiple spreads are allowed in a single call, and they can appear in any position:

fn add(a, b, c) { return a + b + c }

let first = [1]
let last = [3]
add(...first, 2, ...last)   // equivalent to add(1, 2, 3)

At runtime the VM flattens all spread arguments into the argument list before invoking the function. If the total number of arguments does not match the function's parameter count, the usual arity error is produced.

Return

return value inside a function/closure unwinds execution via HarnRuntimeError.returnValue. The closure invocation catches this and returns the value. return inside a pipeline terminates the pipeline.

Enums

Enums define a type with a fixed set of named variants, each optionally carrying associated data.

Enum declaration

enum Color {
  Red,
  Green,
  Blue
}

enum Shape {
  Circle(float),
  Rectangle(float, float)
}

Variants without data are simple tags. Variants with data carry positional fields specified in parentheses.

Enum construction

Variants are constructed using dot syntax on the enum name:

let c = Color.Red
let s = Shape.Circle(5.0)
let r = Shape.Rectangle(3.0, 4.0)

Pattern matching on enums

Enum variants are matched using EnumName.Variant(binding) patterns in match expressions:

match s {
  Shape.Circle(radius) -> { log("circle r=${radius}") }
  Shape.Rectangle(w, h) -> { log("rect ${w}x${h}") }
}

A match on an enum must be exhaustive: a missing variant is a hard error, not a warning. Add the missing arm or end with a wildcard _ -> { … } arm to opt out. if/elif/else chains stay intentionally partial; opt into exhaustiveness by ending the chain with unreachable("…").

Built-in result enum

Harn provides a built-in generic Result<T, E> enum with two variants:

  • Result.Ok(value) -- represents a successful result
  • Result.Err(error) -- represents an error

Shorthand constructor functions Ok(value) and Err(value) are available as builtins, equivalent to Result.Ok(value) and Result.Err(value).

let ok = Ok(42)
let err = Err("something failed")
let typed_ok: Result<int, string> = ok

// Equivalent long form:
let ok2 = Result.Ok(42)
let err2 = Result.Err("oops")

Result helper functions

FunctionDescription
is_ok(r)Returns true if r is Result.Ok
is_err(r)Returns true if r is Result.Err
unwrap(r)Returns the Ok value, throws if r is Err
unwrap_or(r, default)Returns the Ok value, or default if r is Err
unwrap_err(r)Returns the Err value, throws if r is Ok

The ? operator (result propagation)

The postfix ? operator unwraps a Result.Ok value or propagates a Result.Err from the current function. It is a postfix operator with the same precedence as ., [], and ().

fn divide(a, b) {
  if b == 0 {
    return Err("division by zero")
  }
  return Ok(a / b)
}

fn compute(x) {
  let result = divide(x, 2)?   // unwraps Ok, or returns Err early
  return Ok(result + 10)
}

let r1 = compute(20)   // Result.Ok(20)
let r2 = compute(0)    // would propagate Err from divide

The ? operator requires its operand to be a Result value. Applying ? to a non-Result value produces a type error at runtime.

Disambiguation: when the parser sees expr?, it distinguishes between the postfix ? (Result propagation) and the ternary ? : operator by checking whether the token following ? could start a ternary branch expression. For expr?[...], optional subscript is used unless the ? is followed by a valid branch expression and a top-level ternary :. For example, repo ? ["--repo", repo] : [] parses as a ternary without extra parentheses.

Pattern matching on result

match result {
  Result.Ok(val) -> { log("success: ${val}") }
  Result.Err(err) -> { log("error: ${err}") }
}

Try-expression

The try keyword used without a catch block acts as a try-expression. It evaluates the body and wraps the result in a Result:

  • If the body succeeds, returns Result.Ok(value).
  • If the body succeeds with an existing Result, returns that Result unchanged instead of nesting it as Result.Ok(Result.Ok(...)).
  • If the body throws an error, returns Result.Err(error).
let result = try { json_parse(raw_input) }
// result is Result.Ok(parsed_data) or Result.Err("invalid JSON: ...")

let checked = try { schema_check(data, schema) }
// checked is schema_check's Result directly, not Result.Ok(Result.Ok(...))

The try-expression is the complement of the ? operator: try enters Result-land by catching errors, while ? exits Result-land by propagating errors. Together they form a complete error-handling pipeline:

fn safe_divide(a, b) {
  let result = try { a / b }
  return result
}

fn compute(x) {
  let val = safe_divide(x, 2)?  // unwrap Ok or propagate Err
  return Ok(val + 10)
}

No catch or finally block is needed for the Result-wrapping form. When catch or finally follow try, the form is a handled try/catch expression whose value is the try or catch body's tail value (see try/catch/finally); only the bare try { ... } form wraps in Result.

Result in pipelines

The ? operator works naturally in pipelines:

fn fetch_and_parse(url) {
  let response = http_get(url)?
  let data = json_parse(response)?
  return Ok(data)
}

Structs

Structs define named record types with typed fields. Structs may also be generic.

Struct declaration

struct Point {
  x: int
  y: int
}

struct User {
  name: string
  age: int
}

struct Pair<A, B> {
  first: A
  second: B
}

Fields are declared with name: type syntax, one per line.

Struct construction

Struct instances can be constructed with the struct name followed by a named-field body:

struct Point { x: int, y: int }
struct User { name: string, age: int }
struct Pair<A, B> { first: A, second: B }
let p = Point { x: 3, y: 4 }
let u = User { name: "Alice", age: 30 }
let pair: Pair<int, string> = Pair { first: 1, second: "two" }

Field access

Struct fields are accessed with dot syntax, the same as dict property access:

log(p.x)    // 3
log(u.name) // "Alice"

Impl blocks

Impl blocks attach methods to a struct type.

Syntax

impl TypeName {
  fn method_name(self, arg) {
    // body -- self refers to the struct instance
  }
}

The first parameter of each method must be self, which receives the struct instance the method is called on.

Method calls

Methods are called using dot syntax on struct instances:

struct Point {
  x: int
  y: int
}

impl Point {
  fn distance(self) {
    return sqrt(self.x * self.x + self.y * self.y)
  }
  fn translate(self, dx, dy) {
    return Point { x: self.x + dx, y: self.y + dy }
  }
}

let p = Point { x: 3, y: 4 }
log(p.distance())           // 5.0
let p2 = p.translate(10, 20)
log(p2.x)                   // 13

When instance.method(args) is called, the VM looks up methods registered by the impl block for the instance's struct type. The instance is automatically passed as the self argument.

Interfaces

Interfaces define a set of method signatures that a struct type must implement. Harn uses Go-style implicit satisfaction: a struct satisfies an interface if its impl block contains all the required methods with compatible signatures. There is no implements keyword. Interfaces may also declare associated types.

Interface declaration

interface Displayable {
  fn display(self) -> string
}

interface Serializable {
  fn serialize(self) -> string
  fn byte_size(self) -> int
}

interface Collection {
  type Item
  fn get(self, index: int) -> Item
}

Each method signature lists parameters (the first must be self) and an optional return type. Associated types name implementation-defined types that methods can refer to. The body is omitted -- interfaces only declare the shape of the methods.

Implicit satisfaction

A struct satisfies an interface when its impl block has all the methods declared by the interface, with matching parameter counts:

struct Dog {
  name: string
}

impl Dog {
  fn display(self) -> string {
    return "Dog(${self.name})"
  }
}

Dog satisfies Displayable because it has a display(self) -> string method. No extra annotation is needed.

Using interfaces as type annotations

Interfaces can be used as parameter types. At compile time, the type checker verifies that any struct passed to such a parameter satisfies the interface:

fn show(item: Displayable) {
  log(item.display())
}

let d = Dog({name: "Rex"})
show(d)  // OK: Dog satisfies Displayable

Generic constraints with interfaces

Interfaces can be used as generic constraints via where clauses:

fn process<T>(item: T) where T: Displayable {
  log(item.display())
}

The type checker verifies at call sites that the concrete type passed for T satisfies Displayable. Passing a type that does not satisfy the constraint produces a compile-time error. Generic parameters must bind consistently across all arguments in the call, and container bindings such as list<T> propagate the concrete element type instead of collapsing to an unconstrained generic.

Subtyping and variance

Harn's subtype relation is polarity-aware: each compound type has a declared variance per slot that determines whether widening (e.g. int <: float) is allowed in that slot, prohibited entirely, or applied with the direction reversed.

Type parameters on user-defined generics may be marked with in or out:

type Reader<out T> = fn() -> T          // T appears only in output position
interface Sink<in T> { fn accept(v: T) -> int }
fn map<in A, out B>(value: A) -> B { ... }
MarkerMeaningWhere T may appear
out Tcovariantoutput positions only
in Tcontravariantinput positions only
(none)invariant (default)anywhere

Unannotated parameters default to invariant. This is strictly safer than implicit covariance — Box<int> does not flow into Box<float> unless Box declares out T and the body uses T only in covariant positions.

Built-in variance

ConstructorVariance
iter<T>covariant in T (read-only)
list<T>invariant in T (mutable: push, index assignment)
dict<K, V>invariant in both K and V (mutable)
Result<T, E>covariant in both T and E
fn(P1, ...) -> Rparameters contravariant, return covariant
Shape { field: T, ... }covariant per field (width subtyping)

The numeric widening int <: float only applies in covariant positions. In invariant or contravariant positions it is suppressed — that is what makes list<int> to list<float> a type error.

Function subtyping

For an actual fn(A) -> R' to be a subtype of an expected fn(B) -> R, B must be a subtype of A (parameters are contravariant) and R' must be a subtype of R (return is covariant). A callback that accepts a wider input or produces a narrower output is always a valid substitute.

let wide = fn(x: float) { return 0 }
let cb: fn(int) -> int = wide   // OK: float-accepting closure stands in for int-accepting

let narrow = fn(x: int) { return 0 }
let bad: fn(float) -> int = narrow   // ERROR: narrow cannot accept the float a caller may pass

Declaration-site checking

When a type parameter is marked in or out, the declaration body is checked: each occurrence of the parameter must respect the declared variance. Mismatches are caught at definition time, not at each use:

type Box<out T> = fn(T) -> int
// ERROR: type parameter 'T' is declared 'out' (covariant) but appears
// in a contravariant position in type alias 'Box'

Attributes

Attributes are declarative metadata attached to a top-level declaration with the @ prefix. They compile to side-effects (warnings, runtime registrations) at the attached declaration, and stack so a single decl can carry multiple. Arguments are restricted to compile-time values: strings, numbers, booleans, nil, bare or dotted identifiers, lists, dicts, and simple call-shaped sentinels such as schedule("..."). There is no runtime evaluation.

Syntax

attribute    ::= '@' IDENTIFIER ['(' attr_arg (',' attr_arg)* [','] ')']
attr_arg     ::= [IDENTIFIER ':'] attr_value
attr_value   ::= literal | dotted_ident | attr_list | attr_dict | attr_call
dotted_ident ::= IDENTIFIER ('.' IDENTIFIER)*
attr_list    ::= '[' [attr_value (',' attr_value)* [',']] ']'
attr_dict    ::= '{' [attr_key ':' attr_value (',' attr_key ':' attr_value)* [',']] '}'
attr_key     ::= IDENTIFIER | STRING
attr_call    ::= dotted_ident '(' [attr_value (',' attr_value)* [',']] ')'
@deprecated(since: "0.8", use: "compute_v2")
@test
pub fn compute(x: int) -> int { return x + 1 }

Attributes attach to the immediately following declaration — either pipeline, fn, tool, struct, enum, type, interface, or impl. Attaching to anything else (a let, a statement) is a parse error.

Standard attributes

@deprecated

@deprecated(since: "0.8", use: "new_fn")
pub fn old_fn() -> int { ... }

Emits a type-checker warning at every call site of the attributed function. Both arguments are optional; when present they are folded into the warning message.

ArgumentTypeMeaning
sincestringVersion that introduced the deprecation
usestringReplacement function name (rendered as a help line)

@test

@test
pipeline test_smoke(task) { ... }

Marks a pipeline as a test entry point. The conformance / harn test runner discovers attributed pipelines in addition to the legacy test_* naming convention. Both forms continue to work.

Durable persona annotations

Durable persona metadata can be declared directly on a function, tool, or pipeline with @persona, @step, @trigger, @handoff, and @budget:

@persona(
  triggers: [github.pr_opened, schedule("*/30 * * * *")],
  tools: [github, ci, linear],
  autonomy: act_with_approval,
  budget: {daily_usd: 20, frontier_escalations: 3},
  handoffs: [review_captain, human_maintainer],
  receipts: required,
)
@trigger(github.check_failed)
@handoff(target: review_captain, reason: "risky diff")
@budget(daily_usd: 20, max_tokens: 100000)
fn merge_captain(ctx: HandlerCtx) { ... }

@step(
  name: "plan",
  model: "gpt-5.4-mini",
  approval: optional,
  receipt: audit,
  error_boundary: fail,
  retry: {max_attempts: 2},
)
fn plan_step(ctx: HandlerCtx) { ... }

The annotations are first-class parser and type-checker metadata. The attached declaration remains an ordinary callable declaration at runtime unless a host or packaging layer consumes the metadata. Existing manifest and dict-style trigger/persona metadata remains valid Harn data and is not rewritten by this syntax.

AttributeArguments
@personaNamed metadata: triggers, schedules, tools, autonomy, budget, handoffs, context_packs, evals, receipts, model, owner, name, description
@stepNamed metadata: name, model, approval (required/optional), receipt (audit/none), error_boundary (fail/continue/escalate), retry ({max_attempts: N})
@triggerPositional trigger specs (github.pr_opened, "github.pr_opened", schedule("cron")) or named id, provider, kind, event, when, schedule, budget
@handoffNamed target/to, reason, schema, artifact
@budgetNamed numeric fields: daily_usd, hourly_usd, run_usd, max_tokens, frontier_escalations, max_autonomous_decisions_per_hour, max_autonomous_decisions_per_day; string/symbol exhaustion policy via on_exhausted or on_budget_exhausted

For @persona functions, harn lint reports persona-body-must-call-steps when the body directly calls a non-stdlib function that is not declared with @step. Projects may allow specific legacy helpers with [lint].persona_step_allowlist.

@command(name?, description?, hint?)

@command(name: "review", description: "Review the diff", hint: "(optional focus area)")
pipeline review_branch(task) { ... }

Marks a top-level pipeline as an ACP slash-command. The Harn ACP adapter (harn serve --pipeline ...) discovers @command-tagged pipelines from the loaded source and advertises them to clients via the available_commands_update session notification. When a client invokes /<name> args, the named pipeline is compiled as the entry point and the post-name text is passed to the pipeline as the prompt global.

Arguments are all optional and string/symbol-typed:

  • name — slash-command name advertised to the client. Defaults to the pipeline's declared name.
  • description — human-readable summary shown next to the command.
  • hint — placeholder text the client displays before the user types arguments. Maps to ACP's UnstructuredCommandInput.hint.

The attribute is compile-time metadata only; outside of an ACP session the attached pipeline runs unchanged. Slash-command discovery refreshes on every prompt, so editor changes to the pipeline file propagate without restarting the agent.

@complexity(allow)

@complexity(allow)
pub fn classify(x: int) -> string {
  if x == 1 { return "one" }
  ...
}

Suppresses the cyclomatic-complexity lint warning on the attached function. The bare allow identifier is the only currently accepted form. Use it for functions whose branching is intrinsic (parsers, tier dispatchers, tree-sitter adapters) rather than accidental.

The rule fires when a function's cyclomatic score exceeds the default threshold of 25. Projects can override the threshold in harn.toml:

[lint]
complexity_threshold = 15   # stricter for this project

Cyclomatic complexity counts each branching construct (if/else, guard, match arm, for, while, try/catch, ternary, select case, retry) and each short-circuit boolean operator (&&, ||). Nesting, guard-vs-if, and De Morgan rewrites are all score-preserving — the only way to reduce the count is to extract helpers or mark the function @complexity(allow).

@acp_tool

@acp_tool(name: "edit", kind: "edit", side_effect_level: "mutation")
pub fn apply_edit(path: string, content: string) -> EditResult { ... }

Compiles to the same runtime registration as an imperative tool_define(tool_registry(), name, "", { handler, annotations }) call, with the function bound as the tool's handler and every named attribute argument (other than name) lifted into the annotations dict. name defaults to the function name when omitted.

ArgumentTypeMeaning
namestringTool name (defaults to fn name)
kindstringOne of read, edit, delete, move, search, execute, think, fetch, other
side_effect_levelstringnone, read, mutation, destructive

Other named arguments pass through to the annotations dict unchanged, so additional ToolAnnotations fields can be added without a parser change.

@invariant

@invariant("fs.writes", "src/**")
tool write_patch() {
  write_file("src/out.txt", "ok")
}

Attaches one or more compile-time capability invariants to a fn, tool, or pipeline. Invariants are only evaluated when the user opts into them with harn check --invariants; plain harn check keeps the baseline type-check + preflight behavior. Each attributed handler is lowered into a small control-flow graph plus simple data-flow summaries, then the selected invariant checks run against that IR.

InvariantConfigurationMeaning
@invariant("fs.writes", "src/**")One or more allowed globs, passed positionally or as path_glob: / glob: / allow:Every reachable file-system write must target a literal path proven to stay within one of the declared globs.
@invariant("budget.remaining", target: "remaining")Optional target: variable name, default budget.remainingAssignments to the tracked budget value may only preserve it, decrement it, or refresh it from llm_budget_remaining().
@invariant("approval.reachability")No extra argsEvery reachable side-effecting call must be gated by a prior request_approval(...) or enclosed inside a dual_control(...) approval scope.
@invariant("capability.policy", allow: "fs.write,llm.model", ...)allow: capability list; optional workspace: globs; optional require_approval:, require_budget:, require_autonomy:, require_execution_policy:, require_command_policy:, require_egress_policy:, and require_approval_policy: capability listsEvery reachable capability effect must be declared, and selected capabilities must be guarded by the requested policy/gate on every path.

The capability-policy lattice recognizes these canonical capabilities: fs.write, process.exec, network.access, mcp.connector, llm.model, worker.dispatch, human.approval, and autonomy.policy. Common aliases such as workspace.write, command, connector, llm, and worker normalize to those names. The checker classifies direct builtins plus bridge calls such as mcp_call(...), host_tool_call(...), and host_call("capability.operation", ...). Calls like with_execution_policy(...), with_command_policy(...), with_approval_policy(...), with_autonomy_policy(...), with_dynamic_permissions(...), egress_policy(...), request_approval(...), dual_control(...), and budget-bearing llm_call(..., {budget: ...}) satisfy the corresponding gates.

Invariant violations surface through harn check --invariants, harn explain --invariant <name> <handler> <file>, and the LSP. Each diagnostic carries a concrete CFG path so editors and the CLI can show how the violating call or assignment is reached.

Unknown attributes

Unknown attribute names produce a type-checker warning so that misspellings surface at check time. The attribute itself is otherwise ignored — code still compiles.

Type annotations

Harn has an optional, gradual type system. Type annotations are checked at compile time but do not affect runtime behavior. Omitting annotations is always valid.

Basic types

let name: string = "Alice"
let age: int = 30
let rate: float = 3.14
let ok: bool = true
let nothing: nil = nil

The never type

never is the bottom type — the type of expressions that never produce a value. It is a subtype of all other types.

Expressions that infer to never:

  • throw expr
  • return expr
  • break and continue
  • A block where every control path exits
  • An if/else where both branches infer to never
  • Calls to unreachable()

never is removed from union types: never | string simplifies to string. An empty union (all members removed by narrowing) becomes never.

fn always_throws() -> never {
  throw "this function never returns normally"
}

The any type

any is the top type and the explicit escape hatch. Every concrete type is assignable to any, and any is assignable back to every concrete type without narrowing. any disables type checking in both directions for the values it flows through.

fn passthrough(x: any) -> any {
  return x
}

let s: string = passthrough("hello")  // any → string, no narrowing required
let n: int    = passthrough(42)

Use any deliberately, when you want to opt out of checking — for example, a generic dispatcher that forwards values through a runtime protocol you don't want to describe statically. Prefer unknown (see below) for values from untrusted boundaries where callers should be forced to narrow.

The unknown type

unknown is the safe top type. Every concrete type is assignable to unknown, but an unknown value is not assignable to any concrete type without narrowing. This is the correct annotation for values arriving from untrusted boundaries (parsed JSON, LLM responses, dynamic dicts) where callers should be forced to validate the shape before use.

fn describe(v: unknown) -> string {
  // Direct use of `v` as a concrete type is a compile-time error.
  // Narrow via type_of/schema_is first.
  if type_of(v) == "string" {
    return "string: ${v.upper()}"
  }
  if type_of(v) == "int" {
    return "int: ${v + 1}"
  }
  return "other"
}

Narrowing rules for unknown:

  • type_of(x) == "T" narrows x to T on the truthy branch (where T is one of the type-of protocol names: string, int, float, bool, nil, list, dict, closure, bytes).
  • schema_is(x, Shape) narrows x to Shape on the truthy branch.
  • guard type_of(x) == "T" else { ... } narrows x to T in the surrounding scope after the guard.
  • The falsy branch keeps unknown — subtracting one concrete type from an open top still leaves an open top. The checker still tracks which concrete type_of variants have been ruled out on the current flow path, so an exhaustive chain ending in unreachable() / throw can be validated; see the "Exhaustive narrowing on unknown" subsection of "Flow-sensitive type refinement".
  • These rules apply equally when x is a reference path whose type is unknown (e.g. an unknown-typed field reached via o.data), not only a bare unknown variable — including the ruled-out tracking for the exhaustiveness check.

Interop between any and unknown:

  • unknown is assignable to any (upward to the full escape hatch).
  • any is assignable to unknown (downward — the any escape hatch lets it flow into anything, including unknown).

When to pick which:

  • No annotation — "I haven't annotated this." Callers get no checking. Use for internal, unstable code.
  • unknown — "this value could be anything; narrow before use." Use at untrusted boundaries and in APIs that hand back open-ended data. This is the preferred annotation for LLM / JSON / dynamic dict values.
  • any — "stop checking." A last-resort escape hatch. Prefer unknown unless you have a specific reason to defeat checking bidirectionally.

Member access and nil safety

Three syntactic forms dereference a receiver value at runtime — property read (obj.field), subscript (obj[key]), and method call (obj.method(..)). All three fail identically when the receiver is nil or is not the kind of value the access expects, so the checker applies one consistent set of diagnostics to all three rather than treating property access as special:

Receiver typeobj.field / obj[key] / obj.m()obj?.field / obj?[key] / obj?.m()
statically nilerror — known nil hereallowed (the ? short-circuits)
T | nil (nilable)error — may be nil at runtimeallowed
unknownwarning — narrow or validate firstwarning? only guards nil, not a non-shape value
anyno diagnostic (checking opted out)no diagnostic
concrete (struct, shape, list, …)field/index/method checked against the typeunnecessary-? lint if the receiver can't be nil

The fix for a nil / nilable receiver is always one of: the matching optional operator (?., ?[…], or ?.m()), a != nil guard that narrows the value, or a ?? default. For an unknown receiver, narrow with is_a / type_of, validate with assert_shape / schema_is, or add a shape annotation. any is the deliberate escape hatch and is never diagnosed — see the any vs unknown guidance above.

Two narrowings keep this rule ergonomic. A guard on an optional-access chain narrows the base identifier: inside if o?.field != nil { … } the value o is non-nil (if o were nil, o?.field would be nil), so a plain o.field read in that branch is allowed. And value ?? default drops the nil arm of value even when value's type is a named alias that expands to a nilable union (type Opts = {…} | nil), so the common let opts = options ?? {} option-defaulting idiom yields a non-nil value.

These diagnostics fire only when the receiver's type comes from a real contract — a written annotation, a named struct / alias / enum, a call-return, or any non-identifier expression. The ambient dict-literal idiom (let d = {a: 1}; d.missing) stays loose and returns nil at runtime, matching the gradual-typing affordance for one-off glue.

Open records and row polymorphism

A shape type may end with one or more row tails... followed by a type. A row tail stands for "any other fields," so the shape is open:

// `x` is any record that has at least a string `id`; `rest` captures the
// other fields, whatever they are.
fn needs_id(x: {id: string, ...rest}) -> string {
  return x.id
}

needs_id({id: "u1", name: "Ann", age: 3})   // ok — extra fields allowed
needs_id({name: "Ann"})                      // error — required `id` missing

rest is a row variable: an ordinary generic type parameter that happens to appear in tail position. A function generic over rows types record merge precisely — the result carries every field of both inputs, with the right-hand fields overriding the left on overlap (matching the runtime of merge / {...a, ...b} / a + b):

fn merge<R1, R2>(a: {...R1}, b: {...R2}) -> {...R1, ...R2} {
  return a + b
}

let m = merge({a: 1, b: 2}, {b: "x", c: true})
// m : {a: int, b: string, c: bool}   (b overridden by the right side)

Rules:

  • Subtyping checks only the explicit fields of the expected side against the actual's known fields (Harn shapes are width-subtyped, so extra actual fields are always allowed and the row tail absorbs them). A required field absent from the actual's known fields is accepted only when the actual has a gradual tail (dict, any); an abstract row variable is not assumed to supply it.
  • Binding is one-sided: a row variable binds to the actual record's leftover fields (the fields not matched by the explicit ones). With no explicit fields, {...R} binds R to the whole record.
  • Override on overlap is right-biased; the result field is required if either side is required, and its type is the right field's type (or the union of both when the right field is optional).
  • Gradual interop: dict is the dynamic row — spreading or merging a dict-typed value yields a dict rather than a falsely-precise closed shape. Precision is monotone: you never get a more precise result than the inputs justify.

Union types

let value: string | nil = nil
let id: int | string = "abc"

Union members may also be literal types — specific string or int values used to encode enum-like discriminated sets:

type Verdict = "pass" | "fail" | "unclear"
type RetryCount = 0 | 1 | 2 | 3

let v: Verdict = "pass"

Literal types are assignable to their base type ("pass" flows into string), and a base-typed value flows into a literal union (string into Verdict). Runtime schema_is / schema_expect guards and the parameter-annotation runtime check reject values that violate the literal set.

A match on a literal union must cover every literal or include a wildcard _ arm — non-exhaustive match is a hard error.

Tagged shape unions (discriminated unions)

A union of two or more dict shapes is a tagged shape union when the shapes share a discriminant field. The discriminant is auto-detected: the first field of the first variant that (a) is non-optional in every member, (b) has a literal type (LitString or LitInt), and (c) takes a distinct literal value per variant qualifies. The field can be named anything — kind, type, op, t, etc. — there is no privileged spelling.

type Msg =
  {kind: "ping", ttl: int} |
  {kind: "pong", latency_ms: int}

Matching on the discriminant narrows the value to the matching variant inside each arm; the same narrowing fires under if obj.<tag> == "value" / else:

type Msg =
  {kind: "ping", ttl: int} |
  {kind: "pong", latency_ms: int}

fn handle(m: Msg) -> string {
  match m.kind {
    "ping" -> { return "ttl=" + to_string(m.ttl) }
    "pong" -> { return to_string(m.latency_ms) + "ms" }
  }
}

Such a match must cover every variant or include a wildcard _ arm — non-exhaustive match is a hard error.

Distributive generic instantiation

Generic type aliases distribute over closed-union arguments. Writing Container<A | B> is equivalent to Container<A> | Container<B> so each instantiation independently fixes the type parameter. This is what keeps processCreate: fn("create") -> nil flowing into a list< ActionContainer<Action>> element instead of getting rejected by the contravariance of the function-parameter slot:

type Action = "create" | "edit"
type ActionContainer<T> = {action: T, process_action: fn(T) -> nil}

ActionContainer<Action> resolves to ActionContainer<"create"> | ActionContainer<"edit">, and a literal-tagged shape on the right flows into the matching branch.

Intersection types

type BaseCtx = {request_id: string}
type AuthCtx = {user_id: string}

fn use_ctx(ctx: BaseCtx & AuthCtx) -> string {
  return ctx.request_id + "/" + ctx.user_id
}

A & B requires the value to satisfy every component. The intersection of two shape types behaves like a dict that has every field from each component, so ctx.request_id and ctx.user_id are both accessible above. Shape components may be inline or named aliases; the operator nests freely (A & B & C).

& binds tighter than |, so A & B | C parses as (A & B) | C. Use parentheses to write the union-of-intersections form.

At runtime, an intersection annotation lowers to a JSON-Schema allOf guard. A value that is missing a field required by any component is rejected by the parameter-annotation runtime check just like a single shape mismatch is.

Parameterized types

let numbers: list<int> = [1, 2, 3]
let also_numbers: [int] = [1, 2, 3]
let headers: dict<string, string> = {content_type: "json"}

[T] is shorthand for list<T> in type positions. User-defined functions, structs, enums, interfaces, and type aliases may declare type parameters with <T, U>. Function calls normally infer those parameters from arguments, but callers may pass them explicitly when inference needs help or when the desired instantiation should be documented at the call site:

fn map<T, U>(xs: [T], f: fn(T) -> U) -> [U] { ... }
let labels: [string] = map<int, string>([1, 2, 3], label)

Explicit type arguments are erased at runtime. They are checked statically: the number of supplied type arguments must match the function declaration, and each explicit binding must remain consistent with the concrete argument types.

Structural types (shapes)

Dict shape types describe the expected fields of a dict value. The type checker verifies that dict literals have the required fields with compatible types.

let user: {name: string, age: int} = {name: "Alice", age: 30}

Optional fields use ? and need not be present:

let config: {host: string, port?: int} = {host: "localhost"}

Width subtyping: a dict with extra fields satisfies a shape that requires fewer fields.

fn greet(u: {name: string}) -> string {
  return "hi ${u["name"]}"
}
greet({name: "Bob", age: 25})  // OK — extra field allowed

Option-bag parameters are stricter for literal calls. When a parameter is named like opts, options, or config, or its annotation is a type alias ending in Options or Config, a direct dict literal argument may only use fields declared by the option shape:

type PickOptions = {drop_nil?: bool}

fn pick(_options: PickOptions = {}) {}

pick({drop_nil: true})  // OK

A typo in a direct option literal is a type error:

pick({dropnil: true})   // type error — unknown option `dropnil`

Nested shapes:

let data: {user: {name: string}, tags: list} = {user: {name: "X"}, tags: []}

Shapes are compatible with dict and dict<string, V> when all field values match V.

Type aliases

type Config = {model: string, max_tokens: int}
let cfg: Config = {model: "gpt-4", max_tokens: 100}

A type alias can also drive schema validation for structured LLM output and runtime guards. schema_of(T) lowers an alias to a JSON-Schema dict at compile time:

type GraderOut = {
  verdict: "pass" | "fail" | "unclear",
  summary: string,
  findings: list<string>,
}

// Use the alias directly wherever a schema dict is expected.
let s = schema_of(GraderOut)
let ok = schema_is({verdict: "pass", summary: "x", findings: []}, GraderOut)

let r = llm_call(prompt, nil, {
  provider: "openai",
  output_schema: GraderOut,     // alias in value position — compiled to schema_of(T)
  schema_retries: 2,
})

llm_call can also express routing intent without pinning a single provider/model pair. The route_policy option accepts:

  • "manual" (default): use the normal provider / model / env resolution.
  • "always(id)": pin to a model alias, model id, or provider:model selector.
  • "cheapest_over_quality(t)": select the lowest-cost available catalog candidate whose model tier is at least t.
  • "fastest_over_quality(t)": select the lowest-latency available catalog candidate whose model tier is at least t.

The optional fallback_chain is an ordered list of provider ids to try when the selected provider fails availability or transport. Routing decisions are recorded in LLM transcript events with the selected route plus all considered alternatives so costs can be re-scored later:

let r = llm_call(prompt, nil, {
  route_policy: "cheapest_over_quality(mid)",
  fallback_chain: ["local", "ollama", "openai"],
})

System prompt fragments can be supplied without hand-concatenating the positional system string. system_preamble, system_context, system_prompt_parts, system_appendix, system_prefix, and system_suffix are normalized into the provider's system/developer instruction channel for llm_call and agent_loop. In persistent agent_loop sessions, the composed session-level system prompt is recorded once in transcript metadata and as one leading internal system_prompt fingerprint event; it is not injected into the replayable message list. A later continuation that omits all system prompt fields reuses the stored session prompt for the provider request without writing another transcript event. Internal _system_fragments entries may set bucket: "before" (default) or bucket: "after"; the latter is used for live prompt-tail recitations such as the agent scratchpad.

import { system_preamble, with_system_prompt_parts } from "std/llm/prompts"

let opts = with_system_prompt_parts(
  {provider: "anthropic", session_id: "review-42"},
  [system_preamble("Follow the repository's validation gate before final output.")]
)
let r = agent_loop("Review this change", "You are a code review agent.", opts)

For call sites that want routing policy to be visibly scoped around the work, cost_route installs an inherited LLM routing context for the dynamic extent of its block. Nested llm_call invocations inherit the block's routing and budget options; an explicit option on the call wins for the same key.

let r = cost_route {
  budget_usd: 0.05
  prefer: ["anthropic:claude-haiku-4-5", "openai:gpt-5.4-mini"]
  fallback_strategy: cheapest_first

  llm_call(prompt, nil, {max_tokens: 800})
}

budget_usd is a shorthand for budget.max_cost_usd. prefer is an ordered list of model aliases, model ids, or provider:model selectors. The fallback_strategy value may be prefer_order, cheapest_first, or fastest_first; failures on the selected route are retried against the remaining preferred routes before provider-level fallbacks are considered. Without prefer, fallback_strategy: cheapest_first and fallback_strategy: fastest_first lower to the corresponding cheapest_over_quality(quality) / fastest_over_quality(quality) policy, using quality or min_quality when present and mid otherwise.

For call sites that want Harn-managed response reuse, std/llm/handlers exports with_cache(prompt, system?, options?). It returns the same envelope as llm_call, but first checks a persistent content-addressed cache. The key is sha256: plus canonical JSON over {prompt, system, provider, model, temperature, top_p, max_tokens} after provider/model defaults resolve. Cache storage defaults to a sqlite store under Harn state with namespace llm.with_cache, a 10-minute TTL, and LRU eviction at 256 entries. Calls with options.tools != nil bypass the cache by default because tool results can carry side effects; callers may set skip_when to a bool or predicate closure to override that policy.

import { with_cache } from "std/llm/handlers"

let r = with_cache("Summarize this file", nil, {
  provider: "anthropic",
  model: "claude-haiku-4-5",
  store: {backend: "fs", namespace: "summaries"},
  ttl: "10m",
  max_entries: 256,
})

std/cache exposes the underlying {hit, value?} primitive with cache_get(key, options?), cache_put(key, value, options?), and cache_clear(options?). Cache options accept either store: "namespace" or store: {backend: "sqlite"|"fs", namespace?, path?} plus ttl, ttl_seconds, max_age_seconds, and max_entries.

The emitted schema follows canonical JSON-Schema conventions (objects with properties/required, arrays with items, literal unions as {type, enum}) so it is compatible with structured-output validators and with ACP ToolAnnotations.args schemas. The compile-time lowering applies when the alias identifier appears as:

  • The argument of schema_of(T).
  • The schema argument of schema_is, schema_expect, schema_parse, schema_check, is_type, json_validate.
  • The value of an output_schema: entry in an llm_call options dict.

For aliases not known at compile time (e.g. let T = schema_of(Foo) or dynamic construction), passthrough through the runtime schema_of builtin keeps existing schema dicts working.

Generic inference via Schema<T>

Schema-driven builtins are typed with proper generics so user-defined wrappers pick up the same narrowing.

  • llm_call<T>(prompt, system, options: {output_schema: Schema<T>, ...}) -> {data: T, text: string, ...}
  • llm_completion<T> has the same signature.
  • llm_call_structured<T>(prompt, schema: Schema<T>, options?) -> T
  • llm_call_structured_safe<T>(prompt, schema: Schema<T>, options?) -> {ok: bool, data: T | nil, error: dict | nil}
  • llm_call_structured_result<T>(prompt, schema: Schema<T>, options?) -> {ok: bool, data: T | nil, raw_text: string, error: string, error_category: string | nil, attempts: int, repaired: bool, extracted_json: bool, usage: {input_tokens: int, output_tokens: int, cache_read_tokens: int, cache_write_tokens: int, cache_creation_input_tokens: int, cache_hit_ratio: float, cache_savings_usd: float}, model: string, provider: string}. Never throws on transport / schema failures — callers dispatch on ok / error_category. Recognized error_category values: transport-class categories pass through the underlying enum (rate_limit, timeout, auth, transient_network, ...); JSON / schema failures surface as missing_json, schema_validation, or repair_failed when an optional repair pass was attempted and also failed. Options accept a repair: {enabled: bool, ...llm_call_overrides} block — the repair pass runs a single shot on malformed JSON only and is skipped on transport-layer failures.
  • schema_parse<T>(value: unknown, schema: Schema<T>) -> Result<T, string>
  • schema_check<T>(value: unknown, schema: Schema<T>) -> Result<T, string>
  • schema_expect<T>(value: unknown, schema: Schema<T>) -> T
  • schema_recover<T>(text: string, schema: Schema<T>, options?: {llm_repair?: bool | dict, apply_defaults?: bool, ...llm_call_overrides}) -> {ok: bool, data: T | nil, raw_text: string, error: string, error_category: string | nil, attempts: int, stage: string, repaired: bool}. Best-effort recovery of malformed LLM output against a target schema. Three deterministic stages followed by an optional one-shot LLM repair: parsed (direct serde_json parse) → extracted (lift JSON from prose / code fences) → regex (scrape top-level key: value lines for scalar fields) → llm_repair (single-shot llm_call with schema_retries: 0). stage reports which stage produced the result; failed means every stage exhausted. Set {llm_repair: false} for a fully deterministic recovery pass with no LLM calls. The LLM repair stage accepts the same overrides as llm_call_structured_result's repair.

Schema<T> denotes a runtime schema value whose static shape is T. In a parameter position, matching a Schema<T> against an argument whose value resolves to a type alias (directly, via schema_of(T), or via an inline JSON-Schema dict literal) binds the type parameter. A user-defined wrapper such as

fn grade<T>(prompt: string, schema: Schema<T>) -> T {
  let r = llm_call(prompt, nil,
    {provider: "mock", output_schema: schema, output_validation: "error",
     response_format: "json"})
  return r.data
}

let out: GraderOut = grade("Grade this", schema_of(GraderOut))
log(out.verdict)

narrows out to GraderOut at the call site without any schema_is / schema_expect guard, and without per-wrapper typechecker support.

Schema<T> is a type-level construct. In value positions, the runtime schema_of(T) builtin returns an idiomatic schema dict whose static type is Schema<T>.

Human-in-the-loop primitives

Human-in-the-loop is modeled as first-class typed expression syntax. ask_user, request_approval, dual_control, and escalate_to are reserved keywords with VM-enforced semantics: their names cannot be shadowed or rebound, the result envelopes are produced (and signed) by the runtime, and quorum approval requires distinct principals. Each primitive accepts either named arguments or the legacy positional form; both lower to the same runtime.

let answer = ask_user(prompt: "deploy now?", schema: schema_of(Choice))
let record = request_approval(action: "merge_pr", quorum: 2,
                              reviewers: ["alice", "bob", "carol"])
let merged = dual_control(n: 2, m: 3, action: destructive_step,
                          approvers: ["alice", "bob", "carol"])
let handle = escalate_to(role: "oncall", reason: "deploy failed")

The runtime owns blocking semantics, timeout behavior, event-log records, and replay.

  • ask_user<T>(prompt: string, options?: {schema?: Schema<T>, timeout?: duration, default?: T}) -> T
  • request_approval(action: string, options?: ApprovalRequestOptions) returns {approved: bool, reviewers: list<string>, approved_at: string, reason: string | nil, signatures: list<{reviewer: string, signed_at: string, signature: string}>}. ApprovalRequestOptions is {detail?: any, args?: any, quorum?: int, reviewers?: list<string>, deadline?: duration, principal?: string, evidence_refs?: list<dict>, undo_metadata?: dict, capabilities_requested?: list<string>}.
  • dual_control<T>(n: int, m: int, action: fn() -> T, approvers?: list<string>) -> T
  • escalate_to(role: string, reason: string) returns {request_id: string, role: string, reason: string, trace_id: string, status: string, accepted_at: string | nil, reviewer: string | nil}.
  • hitl_pending(filters?: {since?: string, until?: string, kinds?: list<string>, agent?: string, limit?: int}) returns list<{request_id: string, request_kind: string, agent: string, prompt: string, trace_id: string, timestamp: string, approvers: list<string>, metadata: dict}>.

Normative behavior:

  • ask_user appends hitl.question_asked, then blocks until the host appends a matching response. The default timeout is 24 hours unless timeout is supplied. If schema is present, the answer must satisfy it. If the wait times out, Harn appends hitl.timeout and either returns options.default or throws HumanTimeoutError.
  • request_approval appends hitl.approval_requested and waits for the configured quorum. deadline defaults to 24 hours. Denial raises ApprovalDeniedError. Successful completion returns the approval record, including one signed reviewer timestamp receipt per counted approver.
  • dual_control is an approval-gated wrapper around a closure. The closure is not executed until quorum is satisfied. The runtime appends hitl.dual_control_requested, hitl.dual_control_approved / hitl.dual_control_denied, and hitl.dual_control_executed.
  • escalate_to appends hitl.escalation_issued and blocks until the host appends hitl.escalation_accepted. The request payload includes the active capability policy when one is installed so hosts can resolve the requested role against the same capability ceiling enforced by the VM. If the host does not respond, the dispatch remains paused until manual resume.
  • hitl_pending reads the durable HITL topics via the active event log, returns [] when no event log is attached, filters by since / until / kinds / agent / limit, and omits requests that have already reached a terminal HITL event.

HITL records live in durable event-log topics:

  • hitl.questions
  • hitl.approvals
  • hitl.dual_control
  • hitl.escalations

Replay is event-log-driven. During replay, HITL primitives resolve from the previously recorded HITL response events instead of consulting a live host, so approval reviewer identities, signed timestamps, and signatures remain stable across deterministic replay.

Replay-for-teaching corrections live in corrections.records. std/corrections accepts CorrectionInput, whose from_decision and to_decision fields use the reusable CorrectionDecision shape and whose optional evidence uses list<CorrectionEvidenceRef>. The stored CorrectionRecord captures those decisions plus { reason, applied_by, scope }, with optional actor/action/trace/step metadata. this_persona and all scopes feed CapabilityPolicy derivation by tightening the affected actor to a read-only side-effect ceiling while matching correction records remain applicable.

Function type annotations

Parameters and return types can be annotated:

fn add(a: int, b: int) -> int {
  return a + b
}

Type checking behavior

  • Annotations are optional (gradual typing). Untyped values are None and skip checks.
  • int is assignable to float.
  • Dict literals with string keys infer a structural shape type.
  • Dict literals with computed keys infer as generic dict.
  • Shape-to-shape: all required fields in the expected type must exist with compatible types.
  • Option-bag literal calls reject keys that are not declared by the expected option shape.
  • Shape-to-dict<K, V>: all field values must be compatible with V.
  • Type errors are reported at compile time and halt execution.

Flow-sensitive type refinement

The type checker performs flow-sensitive type refinement (narrowing) on union types based on control flow conditions. Refinements are bidirectional — both the truthy and falsy paths of a condition are narrowed.

Nil checks

x != nil narrows to non-nil in the then-branch and to nil in the else-branch. x == nil applies the inverse.

fn greet(name: string | nil) -> string {
  if name != nil {
    // name is `string` here
    return "hello ${name}"
  }
  // name is `nil` here
  return "hello stranger"
}

type_of() checks

type_of(x) == "typename" narrows to that type in the then-branch and removes it from the union in the else-branch.

fn describe(x: string | int) {
  if type_of(x) == "string" {
    log(x)  // x is `string`
  } else {
    log(x)  // x is `int`
  }
}

Reference paths

Narrowing applies to reference paths — an identifier followed by a chain of constant property accesses and constant subscripts (entry.arguments, cfg.opts.mode, xs[0], m["k"]) — not just bare variables. Every refinement form that narrows a variable also narrows a path: type_of(path) == "T", path != nil, a bare if path (truthiness, removes nil), schema_is(path, S) / path.has("k"), and a tagged-shape-union discriminant (o.msg.kind == "ping" narrows o.msg). A guard on a path narrows later reads of that same path:

fn flags(entry: {arguments: list?}) -> list {
  if type_of(entry.arguments) == "list" {
    return entry.arguments  // entry.arguments is `list` here
  }
  return []
}

Optional (?.) and plain (.) links address the same value, so they share a narrowing — type_of(entry?.arguments) == "list" narrows reads of both entry?.arguments and entry.arguments. A path whose type is the top type (unknown/any, common for json_parse / llm_call boundary fields) narrows to the tested kind, exactly as an unknown-typed variable does.

The narrowing is dropped when the base variable or the path is reassigned, since the reference may then point at a different value. A dynamic subscript (xs[i] with a non-literal index) is deliberately never narrowed: it is not a stable reference, so a later xs[i] may read a different element.

Truthiness

A bare identifier in condition position narrows by removing nil:

fn check(x: string | nil) {
  if x {
    log(x)  // x is `string`
  }
}

Logical operators

  • a && b: combines both refinements on the truthy path.
  • a || b: combines both refinements on the falsy path.
  • !cond: inverts truthy and falsy refinements.
fn check(x: string | int | nil) {
  if x != nil && type_of(x) == "string" {
    log(x)  // x is `string`
  }
}

Guard statements

After a guard statement, the truthy refinements apply to the outer scope (since the else-body must exit):

fn process(x: string | nil) {
  guard x != nil else { return }
  log(x)  // x is `string` here
}

Early-exit narrowing

When one branch of an if/else definitely exits (via return, throw, break, or continue), the opposite refinements apply after the if:

fn process(x: string | nil) {
  if x == nil { return }
  log(x)  // x is `string` — the nil path returned
}

While loops

The condition's truthy refinements apply inside the loop body.

Ternary and if expressions

The condition's refinements apply to the true and false branches respectively — for both the cond ? a : b ternary and an if/else used as an expression for its value:

fn pick(x: string | int) -> string {
  // then-branch sees `x: string`, so the result type is `string`.
  return if type_of(x) == "string" { x } else { "fallback" }
}

Match expressions

When matching a union-typed variable against literal patterns, the variable's type is narrowed in each arm:

fn check(x: string | int) {
  match x {
    "hello" -> { log(x) }  // x is `string`
    42 -> { log(x) }       // x is `int`
    _ -> {}
  }
}

Matching on type_of(subject) narrows the subject (variable or reference path) in each arm to the tested kind — the match counterpart of an if type_of(subject) == "T" chain:

fn describe(o: {val: string | int}) -> string {
  return match type_of(o.val) {
    "string" -> { o.val }  // o.val is `string`
    "int" -> { to_string(o.val) }  // o.val is `int`
    _ -> { "?" }
  }
}

Or-patterns (pat1 | pat2 -> body)

A match arm may list two or more alternative patterns separated by |; the shared body runs when any alternative matches. Each alternative contributes to exhaustiveness coverage independently, so an or-pattern and a single-literal arm compose naturally:

fn verdict(v: "pass" | "fail" | "unclear") -> string {
  return match v {
    "pass" -> { "ok" }
    "fail" | "unclear" -> { "not ok" }
  }
}

Narrowing inside the or-arm refines the matched variable to the union of the alternatives' single-literal narrowings. On a literal union this is a sub-union; on a tagged shape union it is a union of the matching shape variants:

type Msg =
  {kind: "ping", ttl: int} |
  {kind: "pong", latency_ms: int} |
  {kind: "close", reason: string}

fn summarise(m: Msg) -> string {
  return match m.kind {
    "ping" | "pong" -> {
      // m is narrowed to {kind:"ping",…} | {kind:"pong",…};
      // the shared `kind` discriminant stays accessible.
      "live:" + m.kind
    }
    "close" -> { "closed:" + m.reason }
  }
}

Guards apply to the arm as a whole: 1 | 2 | 3 if n > 2 -> … runs the body only when some alternative matched and the guard held. A guard failure falls through to the next arm, exactly like a literal-pattern arm.

Or-patterns are restricted to literal alternatives (string, int, float, bool, nil) in this release. Alternatives that introduce identifier bindings or destructuring patterns are a forward-compatible extension and currently rejected.

.has() on shapes

dict.has("key") narrows optional shape fields to required:

fn check(x: {name?: string, age: int}) {
  if x.has("name") {
    log(x)  // x.name is now required (non-optional)
  }
}

Exhaustiveness checking with unreachable()

The unreachable() builtin acts as a static exhaustiveness assertion. When called with a variable argument, the type checker verifies that the variable has been narrowed to never — meaning all possible types have been handled. If not, a compile-time error reports the remaining types.

fn process(x: string | int | nil) -> string {
  if type_of(x) == "string" { return "string: ${x}" }
  if type_of(x) == "int" { return "int: ${x}" }
  if x == nil { return "nil" }
  unreachable(x)  // compile-time verified: x is `never` here
}

At runtime, unreachable() throws "unreachable code was reached" as a safety net. When called without arguments or with a non-variable argument, no compile-time check is performed.

Exhaustive narrowing on unknown

The checker tracks the set of concrete type_of variants that have been ruled out on the current flow path for every unknown-typed variable. The falsy branch of type_of(v) == "T" still leaves v typed unknown (subtracting one concrete type from an open top still leaves an open top), but the coverage set for v gains "T".

When control flow reaches a never-returning site — unreachable(), a throw statement, or a call to a user-defined function whose return type is never — the checker verifies that the coverage set for every still-unknown variable is either empty or complete. An incomplete coverage set is treated as a failed exhaustiveness claim and triggers a warning that names the uncovered concrete variants:

fn handle(v: unknown) -> string {
  if type_of(v) == "string" { return "s:${v}" }
  if type_of(v) == "int"    { return "i:${v}" }
  unreachable("unknown type_of variant")
  // warning: `unreachable()` reached but `v: unknown` was not fully
  // narrowed — uncovered concrete type(s): float, bool, nil, list,
  // dict, closure, bytes
}

Covering all nine type_of variants (int, string, float, bool, nil, list, dict, closure, bytes) silences the warning. Suppression via an explicit fallthrough return is intentional: a plain return doesn't claim exhaustiveness, so partial narrowing followed by a normal return stays silent. Reaching throw or unreachable() with no prior type_of narrowing also stays silent — the coverage set must be non-empty for the warning to fire, which avoids false positives on unrelated error paths.

Reassigning the variable clears its coverage set, matching the way narrowing is already invalidated on reassignment.

Unreachable code warnings

The type checker warns about code after statements that definitely exit (via return, throw, break, or continue), including composite exits where both branches of an if/else exit:

fn foo(x: bool) {
  if x { return 1 } else { throw "err" }
  log("never reached")  // warning: unreachable code
}

Reassignment invalidation

When a narrowed variable is reassigned, the narrowing is invalidated and the original declared type is restored.

Mutability

Variables declared with let are immutable. Assigning to a let variable produces a compile-time warning (and a runtime error).

Runtime parameter type enforcement

In addition to compile-time checking, function parameters with type annotations are enforced at runtime. When a function is called, the VM verifies that each annotated parameter matches its declared type before executing the function body. If the types do not match, a TypeError is thrown:

TypeError: parameter 'name' expected string, got int (42)

The following types are enforced at runtime: int, float, string, bytes, bool, list, dict, set, nil, and closure. int and float are mutually compatible (passing an int to a float parameter is allowed, and vice versa). Union types, list<T>, dict<string, V>, and nested shapes are also checked at runtime when the parameter annotation can be lowered into a runtime schema.

Runtime shape validation

Shape-annotated function parameters are validated at runtime. When a function parameter has a structural type annotation (e.g., {name: string, age: int}), the VM checks that the argument is a dict (or struct instance) with all required fields and that each field has the expected type.

fn process(user: {name: string, age: int}) {
  log("${user.name} is ${user.age}")
}

process({name: "Alice", age: 30})     // OK
process({name: "Alice"})              // Error: parameter 'user': missing field 'age' (int)
process({name: "Alice", age: "old"})  // Error: parameter 'user': field 'age' expected int, got string

Shape validation works with both plain dicts and struct instances. Extra fields are allowed (width subtyping). Optional fields (declared with ?) are not required to be present.

Built-in methods

String methods

MethodSignatureReturns
count.count (property)int -- character count
empty.empty (property)bool -- true if empty
contains(sub)stringbool
replace(old, new)string, stringstring
split(sep)stringlist of strings
trim()(none)string -- whitespace stripped
starts_with(prefix)stringbool
ends_with(suffix)stringbool
lowercase()(none)string
uppercase()(none)string
substring(start, end?)int, int?string -- character range
chars()(none)list of single-character strings

chars() (also the chars(text) builtin) materializes a string into a list of single-character strings in one linear pass. Because a string is UTF-8, random character access (s[i], s[a:b], .count, substring(...)) is O(n) per call; materialize once with chars() and scan the list with O(1) indexing when writing cursor-style source scanners.

List methods

MethodSignatureReturns
count(property)int
empty(property)bool
first(property)value or nil
last(property)value or nil
map(closure)closure(item) -> valuelist
filter(closure)closure(item) -> boollist
reduce(init, closure)value, closure(acc, item) -> valuevalue
find(closure)closure(item) -> boolvalue or nil
any(closure)closure(item) -> boolbool
all(closure)closure(item) -> boolbool
flat_map(closure)closure(item) -> value/listlist (flattened)

Dict methods

MethodSignatureReturns
keys()(none)list of strings (sorted)
values()(none)list of values (sorted by key)
entries()(none)list of {key, value} dicts (sorted by key)
count(property)int
has(key)stringbool
merge(other)dictdict (other wins on conflict)
map_values(closure)closure(value) -> valuedict
filter(closure)closure(value) -> booldict

Dict property access

dict.name returns the value for key "name", or nil if absent.

Set builtins

Sets are created with the set() builtin and are immutable -- mutation operations return a new set. Sets deduplicate values using structural equality.

FunctionSignatureReturns
set(...)values or a listset -- deduplicated
set_add(s, value)set, valueset -- with value added
set_remove(s, value)set, valueset -- with value removed
set_contains(s, value)set, valuebool
set_union(a, b)set, setset -- all items from both
set_intersect(a, b)set, setset -- items in both
set_difference(a, b)set, setset -- items in a but not b
to_list(s)setlist -- convert set to list

Sets are iterable with for ... in and support len().

Encoding and hashing builtins

FunctionDescription
base64_encode(str)Returns the base64-encoded version of str
base64_decode(str)Returns the decoded string from a base64-encoded str
base64url_encode(str)Returns the URL-safe base64 encoding of str using the RFC 4648 alphabet without padding
base64url_decode(str)Returns the decoded string from a URL-safe base64 str without padding
base32_encode(str)Returns the RFC 4648 base32 encoding of str
base32_decode(str)Returns the decoded string from a base32-encoded str
hex_encode(str)Returns the lowercase hex encoding of str
hex_decode(str)Returns the decoded string from a hex-encoded str
bytes_from_string(str)UTF-8 encodes str into bytes
bytes_to_string(bytes)UTF-8 decodes bytes into string
bytes_to_string_lossy(bytes)Lossy UTF-8 decode of bytes
bytes_from_hex(str)Parses lowercase/uppercase hex into bytes
bytes_to_hex(bytes)Hex-encodes bytes
bytes_from_base64(str)Decodes base64 into bytes
bytes_to_base64(bytes)Encodes bytes as base64
bytes_len(bytes)Returns the length in octets
bytes_concat(a, b)Concatenates two byte buffers
bytes_slice(bytes, start, end)Returns a clamped slice of a byte buffer
bytes_eq(a, b)Constant-time byte equality check
sha256(str)Returns the hex-encoded SHA-256 hash of str
md5(str)Returns the hex-encoded MD5 hash of str
hmac_sha256(key, message)Returns HMAC-SHA256 as lowercase hex
hmac_sha256_base64(key, message)Returns HMAC-SHA256 as standard base64
constant_time_eq(a, b)Constant-time string equality for signatures
signed_url(base, claims, secret, expires_at, options?)Returns a short-lived HMAC-SHA256 signed absolute URL or absolute path
verify_signed_url(url, secret_or_keys, now, options?)Verifies a signed URL/path and returns {valid, reason, signature_valid, expired, expires_at, kid, claims}
jwt_sign(alg, claims, private_key)Signs a compact JWT/JWS with a PEM private key. Supports ES256 and RS256
gzip_encode(bytes_or_string, level?)Gzip-compresses bytes/string into bytes; level defaults to 6 and must be 0..9
gzip_decode(bytes)Gzip-decompresses bytes and returns bytes
zstd_encode(bytes_or_string, level?)Zstd-compresses bytes/string into bytes; level defaults to 3
zstd_decode(bytes)Zstd-decompresses bytes and returns bytes
brotli_encode(bytes_or_string, quality?)Brotli-compresses bytes/string into bytes; quality defaults to 11 and must be 0..11
brotli_decode(bytes)Brotli-decompresses bytes and returns bytes
tar_create(entries)Creates an in-memory tar archive from [{path, content, mode?}] and returns bytes; content may be bytes or string
tar_extract(bytes)Extracts an in-memory tar archive into [{path, content: bytes, mode}]
zip_create(entries)Creates an in-memory deflated zip archive from [{path, content}] and returns bytes; content may be bytes or string
zip_extract(bytes)Extracts an in-memory zip archive into [{path, content: bytes}]
multipart_parse(body, content_type, opts?)Parses a buffered multipart/form-data body from bytes/string plus Content-Type; opts supports max_total_bytes, max_field_bytes, and max_fields
multipart_field_bytes(field)Returns a parsed multipart field's raw bytes
multipart_field_text(field)Decodes a parsed multipart field's bytes as UTF-8, throwing on invalid text
multipart_form_data(fields, opts?)Deterministically builds {content_type, boundary, body} multipart fixtures for tests; field content may be bytes or string
let encoded = base64_encode("hello world")  // "aGVsbG8gd29ybGQ="
let decoded = base64_decode(encoded)        // "hello world"
let jwt = base64url_encode("{\"alg\":\"HS256\"}") // no `=` padding
let text = hex_decode("68656c6c6f")         // "hello"
let hash = sha256("hello")                  // hex string
let md5hash = md5("hello")                  // hex string
let gz = gzip_encode("hello")               // bytes
let hello = bytes_to_string(gzip_decode(gz)) // "hello"

multipart_parse returns {boundary, fields, field_count, total_bytes}. Each field is {name, filename, content_type, headers, bytes, text}. text is nil for non-UTF-8 uploads so binary data remains lossless in bytes.

let fixture = multipart_form_data([
  {name: "title", content: "Quarterly report"},
  {
    name: "upload",
    filename: "report.bin",
    content_type: "application/octet-stream",
    content: bytes_from_hex("000102ff"),
  },
])

let form = multipart_parse(fixture.body, fixture.content_type, {
  max_total_bytes: 1048576,
  max_field_bytes: 262144,
  max_fields: 8,
})

let title = multipart_field_text(form.fields[0])
let uploaded = multipart_field_bytes(form.fields[1])
log(title)
log(bytes_to_hex(uploaded))

signed_url is the canonical helper for short-lived Harn-hosted receipt and artifact links. base may be an absolute URL with a host or an absolute path beginning with /. The helper merges existing query parameters with claims, adds exp, an optional kid, and a sig, then signs a versioned canonical payload with HMAC-SHA256. Query canonicalization percent-encodes each key/value with the RFC 3986 unreserved set left plain and sorts encoded pairs lexicographically. Path canonicalization preserves /, preserves existing %XX escapes with uppercase hex, and percent-encodes other non-unreserved bytes. Signatures use URL-safe base64 without padding. verify_signed_url removes sig, rebuilds the same canonical payload, compares signatures in constant time, applies skew_seconds if provided, and supports key rotation by accepting either one secret string or a {kid: secret} dict. Parameter names can be overridden with signature_param, expires_param, and kid_param.

jwt_sign requires claims to be a dict so it can be serialized as a JSON claims object. ES256 expects a P-256 EC private key in PEM form; RS256 expects an RSA private key in PEM form. Unsupported algorithms, non-dict claims, and invalid PEM keys throw runtime errors.

FunctionDescription
cookie_parse(headers)Parses request Cookie header strings, lists, or header dicts into {cookies, pairs, duplicates, invalid}
cookie_serialize(name, value, options?)Serializes one Set-Cookie header value. Options support HttpOnly, Secure, SameSite, Path, Domain, Max-Age, and Expires through snake_case or header-style keys
cookie_delete(name, options?)Serializes a deletion cookie with Max-Age=0 and an epoch Expires timestamp
cookie_sign(value, secret) / cookie_verify(value, secret)Signs and verifies a string cookie value using HMAC-SHA256 with URL-safe base64 signatures
session_sign(payload, secret) / session_verify(token, secret)Signs and verifies a stateless JSON session payload. Verification returns {ok, payload, error} and does not throw on bad signatures
session_cookie(name, payload, secret, options?)Serializes a signed session cookie with secure defaults: Path=/, HttpOnly, Secure, and SameSite=Lax
session_from_cookies(headers, name, secret)Parses request cookies and verifies the named cookie as a stateless session token
cookie_round_trip(request_cookie?, set_cookie)Test helper that applies response Set-Cookie headers to a request cookie header and returns the next {cookie_header, cookies}

Duplicate request cookies are deterministic: cookies[name] keeps the first valid value in wire order and duplicates[name] records every observed value for that name. Invalid cookie segments are skipped and returned in invalid.

cookie_serialize validates names and values and rejects SameSite=None without Secure. session_cookie uses secure dashboard/operator defaults by default; callers may override them explicitly for local testing.

Stateless signed sessions put the trusted payload in the cookie token. A store-backed session should instead place only an opaque session ID in the cookie and keep mutable state in store_*, shared_map_*, or an application database.

Regex builtins

FunctionDescription
regex_match(pattern, str)Returns match data if str matches pattern, or nil
regex_replace(pattern, str, replacement)Replaces all matches of pattern in str
regex_captures(pattern, str)Returns a list of capture group dicts for all matches

regex_captures

regex_captures(pattern, text) finds all matches of pattern in text and returns a list of dicts, one per match. Each dict contains:

  • match: the full match string
  • groups: a list of positional capture group strings (from (...))
  • Any named capture groups (from (?P<name>...)) as additional keys
let results = regex_captures("(\\w+)@(\\w+)", "alice@example bob@test")
// results == [
//   {match: "alice@example", groups: ["alice", "example"]},
//   {match: "bob@test", groups: ["bob", "test"]}
// ]

let named = regex_captures("(?P<user>\\w+):(?P<role>\\w+)", "alice:admin")
// named == [{match: "alice:admin", groups: ["alice", "admin"], user: "alice", role: "admin"}]

Returns an empty list if there are no matches.

Regex patterns are compiled and cached internally using a thread-local cache. Repeated calls with the same pattern string reuse the compiled regex, avoiding recompilation overhead. This is a performance optimization with no API-visible change.

Connector interop builtins

The orchestrator exposes connector-oriented builtins for manifest-driven provider integrations.

FunctionDescription
connector_call(provider, method, params?)Invoke the active outbound connector client for provider and return JSON-like result data
egress_policy(config)Install a process egress policy for HTTP, SSE, WebSocket, and Rust-backed connector outbound calls. Rules support exact hosts, *.suffix hosts, IP literals/CIDR, optional :port, and default: "deny" allowlist mode. Blocked calls throw {type: "EgressBlocked", category: "egress_blocked", host, port, reason, url}
secret_get(secret_id)Read a secret from the active connector context. Only available while executing a Harn-backed connector export such as normalize_inbound or call
event_log_emit(topic, kind, payload, headers?)Append an event to the active event log from a Harn-backed connector export
metrics_inc(name, amount?)Increment a connector-owned Prometheus counter from a Harn-backed connector export

Harn-backed connector modules are loaded through manifest [[providers]] entries and must export provider_id(), kinds(), and payload_schema(). Inbound providers also export normalize_inbound(raw), which returns a NormalizeResult v1 dict. The top-level type field is one of:

  • "event" with event: {kind, dedupe_key, payload, ...} for one normalized event.
  • "batch" with events: [{kind, dedupe_key, payload, ...}, ...] for multiple normalized events.
  • "immediate_response" with immediate_response: {status, headers?, body?} and optional event or events fields for ack-first webhook responses that may still enqueue normalized events.
  • "reject" with status, headers?, and body? for explicit verification or unsupported-input rejection.

Each normalized event includes kind, dedupe_key, and payload plus optional metadata such as occurred_at, tenant_id, headers, batch, and signature_status. During the transition to NormalizeResult v1, runtimes also accept the legacy direct event dict shape.

Harn connector exports run under an effect policy chosen by export name. normalize_inbound uses the hot-path local class by default: deterministic stdlib work, JSON/base64/body handling, signature verification, secret_get, event_log_emit, and metrics_inc are allowed, while outbound network, connector_call, LLM calls, process execution, host/MCP calls, and ambient filesystem/project access are denied before the effect runs. poll_tick and call use the connector-outbound class, which allows network and connector_call but keeps filesystem, process, host/MCP, and LLM effects denied by default. activate uses the activation class with the same default restrictions as connector-outbound setup. Hosts may override these defaults for trusted private connectors by supplying an explicit export policy.

Provider entries may declare setup OAuth metadata. This metadata is consumed by harn connect <provider> so connector packages can describe their OAuth surface without adding provider-specific Rust code:

[[providers]]
id = "acme"
connector = { harn = ".harn/packages/acme-connector/lib.harn" }
oauth = {
  resource = "https://api.acme.example/",
  authorization_endpoint = "https://auth.acme.example/oauth/authorize",
  token_endpoint = "https://auth.acme.example/oauth/token",
  registration_endpoint = "https://auth.acme.example/oauth/register",
  scopes = "acme.read acme.write",
  token_endpoint_auth_method = "none",
}

resource is required unless the operator supplies --resource. The endpoint fields may be omitted for compliant protected resources that advertise OAuth metadata. client_id, client_secret, and token_endpoint_auth_method are defaults only; CLI flags override them for a single connect run.

Poll-capable providers export poll_tick(ctx). The orchestrator invokes this hook for kind = "poll" bindings using the binding's poll configuration: interval/interval_ms/interval_secs, optional jitter/jitter_ms/jitter_secs, state_key or cursor_state_key, tenant_id, lease_id, and max_batch_size. ctx contains the activated binding, binding_id, RFC3339 tick_at, prior cursor, prior connector state, state_key, optional tenant_id, {id, tenant_id} lease metadata, and optional max_batch_size. poll_tick returns either a list of normalized event dicts or {events, cursor?, state?}. Returned events enter the same post-normalize dedupe and trigger inbox path as connector ingress events, and the returned cursor/state is persisted for the next tick.

Iterator protocol

Harn provides a lazy iterator protocol layered over the eager collection methods. Eager methods (list.map, list.filter, list.flat_map, dict.map_values, dict.filter, etc.) are unchanged — they return eager collections. Lazy iteration is opt-in via .iter() and the iter(x) builtin.

The Iter<T> type

Iter<T> is a runtime value representing a lazy, single-pass, fused iterator over values of type T. It is produced by calling iter(x) or x.iter() on an iterable source (list, dict, set, string, generator, channel) or by chaining a combinator on an existing iter.

iter(x) / x.iter() on a value that is already an Iter<T> is a no-op (returns the iter unchanged).

The Pair<K, V> type

Pair<K, V> is a two-element value used by the iterator protocol for key/value and index/value yields.

  • Construction: pair(a, b) builtin. Combinators such as .zip and .enumerate and dict iteration produce pairs automatically.
  • Access: .first and .second as properties.
  • For-loop destructuring: for (k, v) in iter_expr { ... } binds the .first and .second of each Pair to k and v.
  • Equality: structural (pair(1, 2) == pair(1, 2)).
  • Printing: (a, b).

For-loop integration

for x in iter_expr pulls values one at a time from iter_expr until the iter is exhausted.

for (a, b) in iter_expr destructures each yielded Pair into two bindings. If a yielded value is not a Pair, a runtime error is raised.

for entry in some_dict (no .iter()) continues to yield {key, value} dicts in sorted-key order for back-compat. Only some_dict.iter() yields Pair(key, value).

Semantics

  • Lazy: combinators allocate a new Iter and perform no work; values are only produced when a sink (or for-loop) pulls them.
  • Single-pass: once an item has been yielded, it cannot be re-read from the same iter.
  • Fused: once exhausted, subsequent pulls continue to report exhaustion (never panic, never yield again). Re-call .iter() on the source collection to obtain a fresh iter.
  • Snapshot: lifting a list/dict/set/string Rc-clones the backing storage into the iter, so mutating the source after .iter() does not affect iteration.
  • String iteration: yields chars (Unicode scalar values), not graphemes.
  • Printing: log(it) / to_string(it) renders <iter> or <iter (exhausted)> without draining the iter.

Combinators

Each combinator below is a method on Iter<T> and returns a new Iter without consuming items eagerly.

MethodSignature
.iter()Iter<T> -> Iter<T> (no-op)
.map(f)Iter<T>, (T) -> U -> Iter<U>
.filter(p)Iter<T>, (T) -> bool -> Iter<T>
.flat_map(f)Iter<T>, (T) -> Iter<U> | list<U> -> Iter<U>
.take(n)Iter<T>, int -> Iter<T>
.skip(n)Iter<T>, int -> Iter<T>
.take_while(p)Iter<T>, (T) -> bool -> Iter<T>
.skip_while(p)Iter<T>, (T) -> bool -> Iter<T>
.zip(other)Iter<T>, Iter<U> -> Iter<Pair<T, U>>
.enumerate()Iter<T> -> Iter<Pair<int, T>>
.chain(other)Iter<T>, Iter<T> -> Iter<T>
.chunks(n)Iter<T>, int -> Iter<list<T>>
.windows(n)Iter<T>, int -> Iter<list<T>>

Sinks

Sinks drive the iter to completion (or until a short-circuit) and return an eager value.

MethodSignature
.to_list()Iter<T> -> list<T>
.to_set()Iter<T> -> set<T>
.to_dict()Iter<Pair<K, V>> -> dict<K, V>
.count()Iter<T> -> int
.sum()Iter<T> -> int | float
.min()Iter<T> -> T | nil
.max()Iter<T> -> T | nil
.reduce(init, f)Iter<T>, U, (U, T) -> U -> U
.first()Iter<T> -> T | nil
.last()Iter<T> -> T | nil
.any(p)Iter<T>, (T) -> bool -> bool
.all(p)Iter<T>, (T) -> bool -> bool
.find(p)Iter<T>, (T) -> bool -> T | nil
.for_each(f)Iter<T>, (T) -> any -> nil

Notes

  • .to_dict() requires the iter to yield Pair values; a runtime error is raised otherwise.
  • .min() / .max() return nil on an empty iter.
  • .any / .all / .find short-circuit as soon as the result is determined.
  • Numeric ranges (a to b, range(n)) participate in the lazy iter protocol directly; applying any combinator on a Range returns a lazy Iter without materializing the range.

Method-style builtins

If obj.method(args) is called and obj is an identifier, the interpreter first checks for a registered builtin named "obj.method". If found, it is called with just args (not obj). This enables namespaced builtins like experience_bank.save(...) and negative_knowledge.record(...).

Runtime errors

ErrorDescription
undefinedVariable(name)Variable not found in any scope
undefinedBuiltin(name)No registered builtin or user function with this name
immutableAssignment(name)Attempted = on a let binding
typeMismatch(expected, got)Type assertion failed
returnValue(value?)Internal: used to implement return (not a user-facing error)
retryExhaustedAll retry attempts failed
thrownError(value)User-thrown error via throw

Most undefinedBuiltin errors are now caught statically by the cross-module typechecker (see Static cross-module resolution) — harn check and harn run refuse to start the VM when a file contains a call to a name that is not a builtin, local declaration, struct constructor, callable variable, or imported symbol. The runtime check remains as a backstop for cases where imports could not be resolved at check time.

Stack traces

Runtime errors include a full call stack trace showing the chain of function calls that led to the error. The stack trace lists each frame with its function name, source file, line number, and column:

Error: division by zero
  at divide (script.harn:3:5)
  at compute (script.harn:8:18)
  at default (script.harn:12:10)

Stack traces are captured at the point of the error before unwinding, so they accurately reflect the call chain at the time of failure.

Diagnostic code appendix

Runtime and static diagnostics use stable HARN-<category>-<number> codes. The generated diagnostics catalog is authoritative for the full registry; the language spec records runtime namespaces whose behavior affects portable programs.

Suspend/resume diagnostics (HARN-SUS-*)

CodeDescription
HARN-SUS-001suspend_agent was called on a worker that is not running.
HARN-SUS-002ResumeConditions validation failed.
HARN-SUS-003resume_agent was called on a live worker that is not suspended.
HARN-SUS-004A resume snapshot was missing, stale, unreadable, or version-incompatible.
HARN-SUS-005agent_await_resumption was invoked outside agent_loop structural handling.
HARN-SUS-006A concurrent resume changed the worker before resume completed.
HARN-SUS-007conditions.trigger could not be registered with the trigger dispatcher.
HARN-SUS-008A timeout fired or was configured with an unsupported on_timeout action.
HARN-SUS-009Resume input failed agent_loop input validation.
HARN-SUS-010A worker closed while suspended rejected a later resume.

OAuth diagnostics (HARN-OAU-*)

CodeDescription
HARN-OAU-001A persisted sink redacted an OAuth-shaped token (transcript / receipt / OTel / system reminder). The original value still flows through to the tool — redaction is display-only.
HARN-OAU-002std/oauth/client cannot refresh because no refresh_token is in storage (or the server rejected the refresh). Re-run start_authorization / device_flow.
HARN-OAU-005std/oauth/dynamic_registration rejected a candidate client metadata document under RFC 7591 §2.

Durable agent channels diagnostics (HARN-CHN-*)

CodeDescription
HARN-CHN-001pipeline: scope used outside any active pipeline context.
HARN-CHN-002Cross-tenant emit_channel without a grant, or org: scope (disabled in v1 until org grants are available).
HARN-CHN-003Malformed channel name, scope prefix, or scope id.
HARN-CHN-004Scope ambiguous — explicit options.session_id or options.pipeline_id conflicts with the active runtime context.
HARN-CHN-005Malformed batch config on a trigger_register call (missing count/window, non-positive count, unknown expire_action, or wrong-typed key).

Agent pool diagnostics (HARN-POL-*)

CodeDescription
HARN-POL-001A backpressure_queue(..., "fail_submitter") pool was full at submit time. Drop policies do not raise — they return a rejected task handle and emit a pool_drop audit on lifecycle.pool.audit.
HARN-POL-002A fail_fast() pool had no immediate capacity at submit time. The submitter receives the error synchronously; no task is enqueued.

OAuth

Programs may compose OAuth flows by importing from std/oauth/*. The stack is portable across providers: there is no provider-specific Rust code in the language runtime.

ModulePurpose
std/oauth/providersProvider catalogue (10 built-ins plus custom(...) + github_enterprise(...)). Each record carries auth_url, token_url, device_code_url?, revoke_url?, userinfo_url?, default_scopes, pkce_required, refresh_handling, and documented_quirks.
std/oauth/storageToken-storage trait with five interchangeable backends (memory, file, harn_cloud_session, harn_cloud_org, custom). Each backend exposes get(key) -> TokenSet | nil, set(key, token_set, ttl_seconds = nil) -> nil, and delete(key) -> nil.
std/oauth/clientAuthorization-code grant with mandatory PKCE S256 and CSRF state, transparent refresh at >=75% TTL, RFC 7009 best-effort revoke, and a 401-bounded one-shot retry on request(...). Refreshes that fail emit diagnostic HARN-OAU-002.
std/oauth/device_flowRFC 8628 device authorization grant for headless contexts. Persists the resulting TokenSet into the same storage backend the authorization-code client reads from.
std/oauth/dynamic_registrationRFC 7591 client registration + RFC 8414 authorization-server metadata. Validation errors carry diagnostic HARN-OAU-005.
std/oauth/redactionPer-thread custom regex pattern registration on top of the runtime's default token-shape catalog. Each match emits diagnostic HARN-OAU-001 to the audit ring drained via drain_audit().

Successful exchanges and refreshes emit audit events on the oauth.client.audit, oauth.device_flow.audit, and oauth.dynreg.audit event-log topics. Audit payloads carry presence flags + expiry timestamps and never include access tokens, refresh tokens, device codes, user codes, or registered client_secrets.

The user-facing reference, including per-provider cookbook recipes, is docs/src/oauth.md. The CLI surface (harn connect <provider>) is documented in docs/src/orchestrator/oauth.md.

Persistent store

Six builtins provide a persistent key-value store backed by the resolved Harn state root (default .harn/store.json):

FunctionDescription
store_get(key)Retrieve value or nil
store_set(key, value)Set key, auto-saves to disk
store_delete(key)Remove key, auto-saves
store_list()List all keys (sorted)
store_save()Explicit flush to disk
store_clear()Remove all keys, auto-saves

The store file is created lazily on first mutation. In bridge mode, the host can override these builtins via the bridge protocol. The state root can be relocated with HARN_STATE_DIR.

Checkpoint & resume

Checkpoints enable resilient, resumable pipelines. State is persisted to the resolved Harn state root (default .harn/checkpoints/<pipeline>.json) and survives crashes, restarts, and migration to another machine.

Core builtins

FunctionDescription
checkpoint(key, value)Save value at key; writes to disk immediately
checkpoint_get(key)Retrieve saved value, or nil if absent
checkpoint_exists(key)Return true if key is present (even if value is nil)
checkpoint_delete(key)Remove a single key; no-op if absent
checkpoint_clear()Remove all checkpoints for this pipeline
checkpoint_list()Return sorted list of all checkpoint keys

checkpoint_exists is preferable to checkpoint_get(key) == nil when nil is a valid checkpoint value.

std/checkpoint module

import { checkpoint_stage, checkpoint_stage_retry } from "std/checkpoint"

checkpoint_stage(name, fn) -> value

Runs fn() and caches the result under name. On subsequent calls with the same name, returns the cached result without running fn() again. This is the primary primitive for building resumable pipelines.

import { checkpoint_stage } from "std/checkpoint"

fn fetch_dataset(url) { url }
fn clean(data) { data }
fn run_model(cleaned) { cleaned }
fn upload(result) { log(result) }

pipeline process(task) {
  let url = "https://example.com/data.csv"
  let data    = checkpoint_stage("fetch",   fn() { fetch_dataset(url) })
  let cleaned = checkpoint_stage("clean",   fn() { clean(data) })
  let result  = checkpoint_stage("process", fn() { run_model(cleaned) })
  upload(result)
}

On first run all three stages execute. On a resumed run (pipeline restarted after a crash), completed stages are skipped automatically.

checkpoint_stage_retry(name, max_retries, fn) -> value

Like checkpoint_stage, but retries fn() up to max_retries times on failure before propagating the error. Once successful, the result is cached so retries are never needed on resume.

import { checkpoint_stage_retry } from "std/checkpoint"

fn fetch_with_timeout(url) { url }

let url = "https://example.com/data.csv"
let data = checkpoint_stage_retry("fetch", 3, fn() { fetch_with_timeout(url) })
log(data)

File location

Checkpoint files are stored at .harn/checkpoints/<pipeline>.json relative to the project root (where harn.toml lives), or relative to the source file directory if no project root is found. Files are plain JSON and can be copied between machines to migrate pipeline state.

std/agent_state module

import "std/agent_state"

Provides a durable, session-scoped text/blob store rooted at a caller-supplied directory.

FunctionNotes
agent_state_init(root, options?)Create or reopen a session root under root/<session_id>/
agent_state_resume(root, session_id, options?)Reopen an existing session; errors when absent
agent_state_write(handle, key, content)Atomic temp-write plus rename
agent_state_read(handle, key)Returns string or nil
agent_state_list(handle)Deterministic recursive key listing
agent_state_delete(handle, key)Deletes a key
agent_state_handoff(handle, summary)Writes a JSON handoff envelope to __handoff.json

Keys must be relative paths inside the session root. Absolute paths and parent-directory escapes are rejected.

std/memory module

import "std/memory"

Provides a first-class durable memory log for observations that should be available across later runs. The VM-native backend is append-only JSONL under .harn/memory/<namespace>/events.jsonl by default. Callers may pass {root: "path"} in options to place the memory root elsewhere. Namespace path segments must be relative and cannot escape the memory root.

FunctionNotes
memory_open(namespace, options?)Append a configuration event that selects the recall backend (bm25, vector, or hybrid) for this namespace
memory_store(namespace, key, value, tags?, options?)Appends a memory observation and returns a memory_record dict
memory_recall(namespace, query, k?, options?)Returns up to k active records ranked by the namespace backend (override per-call with options.mode)
memory_summarize(namespace, window?, options?)Returns {_type: "memory_summary", count, text, records} for a recent or query-filtered slice
memory_forget(namespace, predicate, options?)Appends a soft-delete event and returns {forgotten, forgotten_ids}

Records contain {id, namespace, key, value, text, tags, stored_at, provenance}. memory_store accepts options.id, options.now, and options.provenance; id defaults to UUIDv7 and stored_at defaults to the current UTC timestamp.

memory_recall defaults to deterministic local BM25: it tokenizes the key, tags, text, and JSON value, then ranks active records with BM25 plus small exact key/tag boosts. Pass options.mode (lexical, semantic, or hybrid) to override the namespace default for one call.

memory_open configures the namespace backend without rewriting prior records. The latest open event wins. Supported options:

  • backend"bm25" (default), "vector", or "hybrid".
  • embed_model_hint — opaque model identifier passed to the host. Defaults to "default".
  • embed_dim — expected embedding dimensionality. Recall fails fast on a dimension mismatch.
  • bm25_weight and cosine_weight — hybrid blend weights (defaults 0.5 each). Negative values are rejected.

When the namespace backend uses embeddings (or memory_store is called with options.embed: true), Harn delegates to the typed host capability memory.embed. Request shape: {text: string, model_hint: string}. Response shape: {vector: list<float>, model?: string, dim?: int}. Harn never bundles an embedding model; hosts choose it, and embeddings are cached on disk under .harn/memory/<namespace>/vectors/<sanitized_model_hint>/<sha256(text)>.json keyed by (model_hint, content_hash). Tests can satisfy the capability with host_mock("memory", "embed", {result}).

Determinism contract: a recall over the same (namespace, query, mode, embed_model_hint, top_k) against the same event log and embedding cache returns the same ordered hits, regardless of whether the host is still attached.

memory_summarize is deterministic by default. window may be nil, an integer limit, or a dict with limit, query, and tag / tags. The summary text is an extractive bullet list capped to a bounded size; callers that need LLM prose can pass summary.records to llm_call.

memory_forget never rewrites or removes prior observations. It appends a tombstone event. Predicates may be a string (substring match against searchable record text) or a dict using any combination of id, key, tag / tags, and query; all provided dict predicates must match.

std/agent/fact module

import { store_fact, recall_facts, invalidate_facts } from "std/agent/fact"

Provides typed harn.fact.v1 assertions on top of std/memory. A fact contains kind, claim, evidence, confidence, provenance, optional valid_until, and asserted_at. Kinds normalize to observation, claim, hypothesis, decision, or constraint; evidence kinds normalize to trace_ref, file_range, tool_output, or user_input.

FunctionNotes
fact(input, options?) / fact_validate(input)Normalize and validate a harn.fact.v1 envelope; validation failures include HARN-FACT-NNN codes
fact_namespace(scope?)Map project, workspace, or user to the default fact namespace, defaulting to project/facts
fact_id(kind, claim, evidence?, provenance?)Build a stable fact_... id from normalized assertion fields
fact_key(fact)Return the reserved fact:<kind>:<id> memory key
fact_tags(fact, tags?)Return fact, fact:<kind>, schema:harn.fact.v1, generic and kind-scoped evidence tags, and caller tags
store_fact(input, options?)Store the fact as MemoryRecord.value, using the fact id as the memory record id
recall_facts(query, kind?, min_confidence?, scope?)Recall normalized facts and filter by kind/confidence; scope may be a scope string or an options dict with normal memory options
invalidate_facts(predicate, scope?)Append memory tombstones; predicates accept exact fact_... ids or dicts with id, key, kind, claim, query, tag, tags, evidence_ref, or evidence

std/agent/probe module

import { probe, probe_eval, probe_typecheck } from "std/agent/probe"

Provides a probe-first verification primitive: instead of asserting a claim about runtime behavior, run a small snippet, capture the outcome deterministically, and auto-record it as a harn.fact.v1 Observation backed by std/agent/fact. Probes return a harn.probe.v1 envelope with kind, outcome (pass / fail / unknown), observed summary, evidence (trace id, snippet, command, stdout/stderr/exit_code, duration_ms), fact_id of the auto-stored observation, and the round-tripped expected and asserted_at. MVP supports eval and typecheck; test and inspect are reserved and currently return an unknown-outcome envelope so callers can wire them in deterministically.

FunctionNotes
probe(kind, body, options?)Run a snippet (eval or typecheck today; test/inspect reserved) and capture the outcome. options.expected enables a hard match (string for stdout, int for typecheck error count); without it, outcome derives from exit code or error count.
probe_eval(body, options?)Convenience for probe("eval", ...). Runs body as a shell command by default; options.lang = "harn" writes the body to a temp file and runs harn run <path>.
probe_typecheck(body, options?)Convenience for probe("typecheck", ...). Writes the fragment to a temp file and runs harn check <path> --json, parsing the summary for error and warning counts.

Unless options.store_fact = false, every probe writes an Observation with confidence = 0.9 for observed pass/fail and 0.4 for unknown, provenance.source = "probe", and provenance.probe_kind = "<kind>". Pass options.scope, options.namespace, or options.root to override the fact-store location, and options.harn_binary to point at a specific Harn CLI build for typecheck (and harn-lang eval) probes.

Validation failures throw with HARN-PROBE-NNN diagnostic codes: 001 (options), 002 (kind), 003 (lang), 004 (body).

std/postgres module

import "std/postgres"

Provides VM-native Postgres helpers for durable tenant state, event logs, receipts, claims, and audit records.

FunctionNotes
pg_pool(source, options?)Open a pooled Postgres connection from a URL, env:NAME, secret:namespace/name, or source dict
pg_connect(source, options?)Open a single-connection Postgres pool
pg_query(handle, sql, params?)Run a parameterized query and return rows as dictionaries
pg_query_one(handle, sql, params?)Return the first row, or nil when no rows match
pg_execute(handle, sql, params?)Execute a statement and return {rows_affected}
pg_transaction(pool, callback, options?)Pass a scoped transaction handle to a closure, commit on success, rollback on thrown error
pg_close(pool)Close a pool handle
pg_stmt_cache_clear(pool)Clear prepared-statement caches on idle primary and replica connections
pg_mock_pool(fixtures)Create an in-process fixture-backed Postgres handle for tests
pg_mock_calls(mock)Inspect recorded mock SQL calls

Dynamic values must be passed through the params list rather than string interpolation. Compound Harn values bind as JSON. Row decoding covers null, boolean, integer and float types, text, uuid, json/jsonb, bytea, date, time, timestamp, and timestamptz. Transaction options may include settings, applied with transaction-local set_config(name, value, true) for RLS policies. Schema migrations are host-owned; Harn does not maintain a migration ledger.

Agent lifecycle (suspend/resume)

Agent workers are cooperatively schedulable. A running worker may yield — mid-loop, at a turn boundary — to be resumed later in the same process, a different process, or on another machine. The primitive is shared by script-driven suspends, model-driven self-parks via the agent_await_resumption lifecycle tool, and the daemon idle path.

User-facing reference: docs/src/agent-lifecycle.md. Upstream protocol companions are documented at docs/src/protocol-contributions/acp-session-suspend.md and docs/src/protocol-contributions/a2a-paused-state.md.

Lifecycle states

A worker observes a strict state machine:

            spawn_agent
                |
                v
  +---------+ suspend_agent / agent_await_resumption +-----------+
  | running | -----------------------------------------> | suspended |
  +---------+                                            +-----------+
       ^                                                     |
       |             resume_agent / subagent_resume          |
       +-----------------------------------------------------+
       |                                                     |
       | natural completion                                  | close_agent
       v                                                     v
  +---------+                                            +-----------+
  |  done   |                                            |  closed   |
  +---------+                                            +-----------+

Transitions:

  • running -> suspended: cooperative. suspend_agent flips the worker's suspend flag; the worker honors it at the next turn boundary (after the current LLM call and tool dispatch settle). A snapshot is persisted before the state flips. Idempotent: a second suspend_agent on an already-suspended worker returns the current summary unchanged and does not re-emit WorkerSuspended.
  • suspended -> running: resume_agent(handle_or_snapshot, input?, continue_transcript?) rehydrates the worker. Concurrent resumes raise HARN-SUS-006; the second caller may retry against the now-running handle.
  • running -> done: natural completion through agent_loop end conditions; emits WorkerCompleted.
  • suspended -> closed: close_agent marks the snapshot rejected; later resume attempts raise HARN-SUS-010.

Invalid transitions raise specific diagnostics:

FromOpDiagnostic
closed, donesuspend_agentHARN-SUS-001
runningresume_agentHARN-SUS-003
closed (snapshot rejected)resume_agentHARN-SUS-010

Suspension

A Suspension is the canonical payload returned to the parent (or direct caller) when a worker parks:

Suspension = {
  status:                "suspended",            // discriminator
  handle:                WorkerHandle,           // resumable reference
  reason:                string,                 // operator / model-supplied
  initiator:             "self" | "parent" | "operator" | "triggered",
  conditions:            ResumeConditions | nil,
  iterations_completed:  int,                    // completed loop turns
  suspended_at:          string,                 // RFC3339 timestamp
  suspended_at_turn:     int | nil,              // loop turn index, when known
  snapshot_path:         string,                 // persisted snapshot
  resume_by_mechanism:   string | nil,           // e.g. "ResumeBy.parent_llm"
  auto_resume_trigger:   string | nil,           // registered trigger id
}

Top-level agent_loop(...) returns the same shape with handle set to the persisted snapshot. The CLI cold-restores it with harn run --resume <snapshot_path>. The runtime never persists transcript text, resume input, or condition payloads in the Suspension envelope itself — sensitive content stays in the worker snapshot and the transcript store.

ResumeConditions

ResumeConditions declares what may wake a suspended worker. All three fields are optional:

ResumeConditions = {
  trigger?:  TriggerSpec,
  timeout?:  {duration_minutes: int, on_timeout?: TimeoutAction},
  on_event?: string,
}

TimeoutAction = "resume_with_summary" | "fail" | "resume_with_input"
  • trigger is validated by the same trigger-spec parser used by trigger_register(...). Any provider that works as a trigger source (github, slack, cron, channel, webhook, etc.) works as a resume condition. Registration failures raise HARN-SUS-007.
  • timeout.duration_minutes must be a positive integer. timeout.on_timeout defaults to "resume_with_summary". Unsupported values raise HARN-SUS-008.
  • on_event must be a non-empty EventLog topic name.

parse_resume_conditions(nil) returns nil. Invalid input raises HARN-SUS-002 with the failing field path. A worker with no conditions is parked "open" — only the parent agent, an operator, or an explicit resume_agent(...) call can wake it.

agent_await_resumption

agent_await_resumption(reason, conditions?, resume_by?) returns a normalized AgentAwaitResumptionRequest:

AgentAwaitResumptionRequest = {
  reason:     string,                                   // trimmed, non-empty
  conditions: ResumeConditions | nil,
  resume_by:  (harness, suspension) -> dict | nil,
}

Empty or whitespace-only reason raises HARN-SUS-005. The function itself is pure validation; the structural integration happens inside agent_loop(...):

  • When called from inside an agent_loop running as a worker, the loop intercepts the request before normal tool dispatch, persists a snapshot, and returns the Suspension payload to the parent.
  • When called from outside that structural integration (e.g. as a direct tool handler), it raises HARN-SUS-005.

Resume responsibility (ResumeBy.*)

Every suspension has exactly one resume owner. The optional resume_by argument is a callback with signature (harness, Suspension) -> {handled: bool, mechanism: string, reason?: string}.

Four canonical presets live in std/agent/resume_by:

PresetBehavior
ResumeBy.parent_llmAlways returns {handled: true, mechanism: "ResumeBy.parent_llm"}. The parent agent's LLM owns the resume via subagent_resume.
ResumeBy.local_runtimeRegisters conditions.trigger with the in-process trigger dispatcher. Declines with {handled: false, reason: "no_conditions"} when no conditions are present.
ResumeBy.cloud_harnessRegisters the suspension with the harn-cloud webhook receiver for durability across process restart. Declines with {handled: false, reason: "no_cloud_session"} when no cloud session is bound.
ResumeBy.pipeline_drainAlways returns {handled: true, mechanism: "ResumeBy.pipeline_drain"}. The enclosing pipeline's drain step owns the resume.

Presets compose with std/lifecycle/combinators::first_available (re-exported as first_handled in std/agent/resume_by). When the script omits resume_by, the runtime calls default_resume_by(...):

  • conditions == nilResumeBy.parent_llm
  • conditions != nil, no cloud session → ResumeBy.local_runtime
  • conditions != nil, cloud session → first_handled([cloud_harness, local_runtime])

Every resolved dispatch emits a resume_by_dispatched audit through harness.emit_audit. Declined entries emit resume_by_declined. A callback that returns {handled: false} or throws falls back to ResumeBy.parent_llm so the parent agent is always the safety net. Audits surface through lifecycle_audit_log_take().

Transcript continuity

resume_agent(worker_or_snapshot, input? = nil, continue_transcript? = true) controls how the resumed worker sees the prior loop:

  • continue_transcript: true (default) — the full transcript is preserved. The runtime injects a single-shot system_reminder event with dedupe_key: "resume_continuity" summarizing the suspension reason, the gap, and the resume cause. The reminder is consumed on the first resumed turn and is not re-applied on subsequent suspends of the same worker.
  • continue_transcript: false — the worker resumes from the prior transcript summary plus the new input only. Useful when the previous deliberation reached a dead-end and the next turn should think from first principles.

Resume input validation failures raise HARN-SUS-009.

Lifecycle hooks

Suspend and resume fire structured hook events around the state transition:

  • PreSuspend (advisory; supports Allow/Deny decisions). A Deny cancels the suspend; the worker remains running and the operator receives pre_suspend_denied audit metadata.
  • PostSuspend (advisory only; runs after the snapshot is persisted and the WorkerSuspended event is emitted).
  • PreResume / PostResume (analogous; PreResume Deny cancels the resume and the worker remains suspended).

Hook payloads carry the structured suspend metadata (handle, reason, initiator, condition presence flags) without exposing transcript text or resume-input contents.

Top-level loops

A root agent_loop(...) (one called from a pipeline or harn run script, not as a child) uses the same suspend path. On self-park the runtime persists a snapshot under .harn/workers/worker_*.json and returns the Suspension shape to the caller. The CLI restores it in any subsequent process:

harn run --resume <snapshot_path>

--resume accepts absolute or script-relative paths. --json emits the final value as a structured event when the resumed loop terminates.

Daemon idle

agent_loop(..., {daemon: true}) and the daemon_* stdlib wrappers internally call agent_await_resumption(...) when no wake source (messages, agent/resume notification, wake_interval_ms, watched paths) is queued. The persisted snapshot extends the standard suspend metadata with daemon-specific fields (pending_event_count, queued_event_count, inflight_event, wake_interval_ms, watch_paths, event_queue_capacity). daemon_resume(path) cold- restores the loop identically.

Cooperative cancellation contract

Suspend is cooperative, not preemptive. The runtime flips the worker's suspend flag; the loop honors it only at a turn boundary (after the current LLM call and tool dispatch settle). Workers that do not return to a turn boundary — e.g. a tool that blocks forever inside a single call — never observe the request. Bounded execution of long-running tools is the caller's responsibility (tool_call_timeout, async_tool_run, etc.).

This is the same cooperative model as agent_loop's natural completion gate: state transitions happen at boundaries the runtime owns, not inside opaque calls the model issued.

Host shell discovery

The process host capability owns shell discovery and shell-mode invocation. This keeps IDEs, TUIs, headless CLI runs, and cloud workers on the same contract instead of hardcoding /bin/sh, cmd, or host-specific settings in each integration.

  • process.list_shells returns {shells, default_shell_id}. Each shell entry has id, label, path, platform, available, supports_login, supports_interactive, default_args, login_args, and source.
  • process.get_default_shell returns the selected shell object for the current host/session.
  • process.set_default_shell may be implemented by stateful hosts. Harn's standalone fallback stores the selection for the current thread/session.
  • process.shell_invocation resolves {shell_id?, shell?, command?, login?, interactive?} into {program, args, command_arg_index, shell}. When neither shell_id nor shell is supplied, it uses the selected default shell.

Shell-mode command runners may pass a shell object or shell ID resolved through this capability, and otherwise use the selected default shell. argv mode remains preferred for programmatic execution; shell mode is for user-authored commands and interactive shell semantics. The normative schema is spec/schemas/host-shell-discovery.schema.json.

Workspace manifest (harn.toml)

Harn projects declare a workspace manifest at the project root named harn.toml. Tooling walks upward from a target .harn file looking for the nearest ancestor manifest and stops at a .git boundary so a stray manifest in a parent project or $HOME is never silently picked up.

[check] — type-checker and preflight

[check]
host_capabilities_path = "./schemas/host-capabilities.json"
preflight_severity = "warning"          # "error" (default), "warning", "off"
preflight_allow = ["mystery.*", "runtime.task"]

[check.host_capabilities]
project = ["ensure_enriched", "enrich"]
workspace = ["read_text", "write_text"]
  • host_capabilities_path and [check.host_capabilities] declare the host-call surface that the preflight pass is allowed to assume exists at runtime. The CLI flag --host-capabilities <file> takes precedence for a single invocation. The external file is JSON or TOML with the namespaced shape { capability: [op, ...], ... }; nested { capabilities: { ... } } wrappers and per-op metadata dictionaries are accepted.
  • preflight_severity downgrades preflight diagnostics to warnings or suppresses them entirely. Type-checker and lint diagnostics are unaffected — preflight failures are reported under the preflight category so IDEs and CI filters can route them separately.
  • preflight_allow suppresses preflight diagnostics tagged with a specific host capability. Entries match an exact capability.operation pair, a capability.* wildcard, a bare capability name, or a blanket *.

Preflight capabilities in this section are a static check surface for the Harn type-checker only. They are not the same thing as ACP's agent/client capability handshake (agentCapabilities / clientCapabilities), which is runtime protocol-level negotiation and lives outside harn.toml.

[workspace] — multi-file targets

[workspace]
pipelines = ["pipelines", "scripts"]

harn check --workspace resolves each path in pipelines relative to the manifest directory and recursively checks every .harn file under each. Positional targets remain additive. The manifest is discovered by walking upward from the first positional target (or the current working directory when none is supplied).

[[personas]] — durable agent role manifests

[[personas]] entries define durable agent roles in the package/workspace manifest. The canonical typed schema and validator live in harn-modules so CLI, host, cloud, and editor consumers share the same contract. Tooling parses, validates, lists, and inspects the contract. The continuous runtime records lifecycle controls, schedule and trigger wake receipts, single-writer leases, budget receipts, and status snapshots in the persona.runtime.events EventLog topic; long-lived hosted wake loops and typed handoff dispatch remain host/orchestrator work.

Required fields are name, description, entry_workflow, either tools or capabilities, autonomy_tier, and receipt_policy. Optional fields include triggers, schedules, model_policy, budget, handoffs, context_packs, evals, owner, version, package_source, and rollout_policy.

[[personas]]
name = "merge_captain"
version = "0.1.0"
description = "Owns pull request readiness, CI triage, merge approvals, and receipts."
entry_workflow = "workflows/merge_captain.harn#run"
tools = ["github", "ci", "linear", "notion", "slack"]
capabilities = ["git.get_diff", "project.test_commands", "process.exec"]
autonomy_tier = "act_with_approval"
receipt_policy = "required"
triggers = ["github.pr_opened", "github.check_failed"]
schedules = ["*/30 * * * *"]
handoffs = ["review_captain"]
context_packs = ["repo_policy", "release_rules", "flaky_tests"]
evals = ["merge_safety", "regression_triage", "reviewer_quality"]
budget = { daily_usd = 20.0, frontier_escalations = 3 }

Validation rejects missing required fields, malformed or unknown capability.operation entries, invalid cron schedules, unknown handoff targets, unknown budget/model/source/rollout fields, negative budget amounts, and invalid rollout percentages. harn persona list and harn persona inspect <name> --json expose the resolved schema for hosts such as IDEs and cloud runners. harn persona check <path> validates a package or standalone persona manifest through the same canonical validator. Persona triggers install manifest trigger bindings whose handler kind is persona; explicit [[triggers]] entries may also use handler = "persona://<name>". harn persona status <name> --json exposes stable runtime state including lifecycle state, current assignment, active lease, last run, next scheduled run, queued work, typed handoff inbox summaries, budget status, value receipts, and last error. pause queues later events, resume drains queued events under leases, and disable records later events as dead-lettered.

[dependencies] and harn.lock — git-backed package installs

[dependencies]
sdk = { version = "^1.2", registry_name = "@burin/notion-sdk", package = "notion-sdk-harn" }
harn-openapi = { git = "https://github.com/burin-labs/harn-openapi", tag = "v1.2.3" }
notion-sdk-harn = { git = "https://github.com/burin-labs/notion-sdk-harn", tag = "v1.2.3" }
notion-connector-harn = { git = "https://github.com/burin-labs/notion-connector-harn", tag = "v1.2.3" }
notion = { git = "https://github.com/burin-labs/notion-sdk-harn", tag = "v1.2.3", package = "notion-sdk-harn" }
openapi = { git = "https://github.com/burin-labs/harn-openapi", branch = "main" }
local-fixture = { path = "../fixture-lib" }

[dependencies] installs package sources into .harn/packages/ so imports like import "notion-sdk-harn" or import "notion/providers" resolve without filesystem-relative hacks.

  • The table key is the local import alias.
  • git accepts HTTPS, SSH, file://, local-repo paths, and GitHub-style shorthand URLs.
  • Git dependencies must specify exactly one of tag, rev, or branch.
  • tag pins a git tag in the manifest and lockfile.
  • rev pins a symbolic ref or full commit SHA in the manifest.
  • branch records a moving ref in the manifest, but harn.lock still pins one resolved commit for reproducible installs.
  • version resolves through the configured registry index, selects the highest unyanked semver version matching the range, and then clones the git tag, rev, or branch recorded for that registry version.
  • registry_name records the registry-side package name when it differs from the local import alias. Omit it for unscoped registry names that match the alias.
  • package documents the upstream package name when the local alias differs from the repository name.
  • path installs a local directory or .harn file without using the shared git cache. Directory path dependencies are live-linked into .harn/packages/<alias> when the platform supports symlinks, with a copy fallback for restricted filesystems. This makes sibling-repo development ergonomic for layouts such as ~/projects/{app, shared-packages,harn-openapi}: editing ../harn-openapi/lib.harn is immediately visible to imports in the consuming project.

Transitive package dependencies are resolved from installed package manifests and flattened into the root workspace .harn/packages/ directory. For example, a connector package can depend on notion-sdk-harn, and that SDK can depend on harn-openapi helpers; harn install records all reachable packages in harn.lock and materializes them from a clean cache. Git-installed packages cannot declare transitive path dependencies, because publishable package installs must not depend on local sibling directories.

harn add ../harn-openapi treats an existing local path as a path dependency and derives the default alias from that package's [package].name in harn.toml, falling back to the directory or file stem. Use harn add <alias> --path ../repo for the legacy explicit alias form, or harn add <alias> --git ../repo when a local git checkout should be pinned by commit instead of live-linked.

harn tool new <name> scaffolds a Harn-native tool package with [[package.tools]] metadata, a stable tools export, package-local dispatch tests, API docs, and CI. harn skill new <name> is the singular alias for harn skills new <name>. Tool and skill packages still install through the same [dependencies], .harn/packages/, and harn.lock mechanism as ordinary module packages.

Eval packs

Packages can ship portable eval packs in harn.eval.toml or in paths listed under [package].evals:

[package]
name = "slack-connector"
version = "0.1.0"
evals = ["evals/webhooks.toml"]

After harn install, eval-pack discovery includes the root package plus materialized dependency packages under .harn/packages/<alias>/. Installed package eval packs are inert until a command or root trigger references them; installing a package does not install that package's own triggers.

An eval pack is a TOML document with version = 1, package metadata, fixture references, rubrics, optional judge calibration, thresholds, cases, and optional persona timeout ladders:

version = 1
id = "slack-connector"
name = "Slack connector evals"

[[fixtures]]
id = "url-verification-run"
kind = "run-record"
path = "fixtures/url-verification.run.json"

[[fixtures]]
id = "url-verification-replay"
kind = "replay-fixture"
path = "fixtures/url-verification.replay.json"

[[rubrics]]
id = "normalization"
kind = "deterministic"

[[rubrics.assertions]]
kind = "run-status"
expected = "completed"

[[cases]]
id = "url-verification"
run = "url-verification-run"
fixture = "url-verification-replay"
rubrics = ["normalization"]
severity = "blocking"

[cases.thresholds]
max-latency-ms = 500
max-cost-usd = 0.001

Persona timeout ladders run one fixture across a matrix of model routes and timeout/budget tiers. The local runner currently supports the Merge Captain persona and emits per-tier JSONL transcripts, receipts, summaries, counts, state-machine coverage, and first-correct-tier metadata:

[[ladders]]
id = "merge-captain-green-pr"
persona = "merge_captain"
artifact-root = ".harn-runs/merge-captain-timeout-ladder"

[ladders.backend]
kind = "replay"
path = "../../examples/personas/merge_captain/transcripts/green_pr.jsonl"

[[ladders.model-routes]]
id = "gemma-value"
route = "local/gemma-value"
provider = "llama.cpp"
model = "gemma"
profile = "value"

[[ladders.timeout-tiers]]
id = "balanced"
timeout-ms = 500
max-tool-calls = 4
max-model-calls = 1

Fixture kind values are portable labels: run-record, replay-fixture, jsonl-trace, provider-events, and connector-payload. Local CLI evaluation executes run-record/replay-fixture cases and deterministic assertions; other fixture kinds remain importable metadata for hosted runners.

Rubric kind values include deterministic, replay, budget, hitl, side-effect, and llm-judge. LLM judge rubrics declare model selection, prompt version, tie handling, confidence minimums, and golden examples, but a blocking llm-judge rubric fails local evaluation unless an explicit judge runner handles it.

Threshold severity controls deployment-gate behavior:

SeverityMeaning
blockingFailure exits non-zero locally and blocks deployment gates
warningFailure is reported but does not block
informationalFailure is reported for comparison and dashboards only

Run a pack directly with harn eval harn.eval.toml, run root and installed package-declared packs with harn test package --evals, or run a ladder directly with harn merge-captain ladder <manifest>.

Harn source may also declare an eval pack directly:

eval_pack regression "slack-connector" {
  baseline: "fixtures/baseline.run.json"
  fixtures: [{id: "candidate", kind: "run-record", path: "fixtures/candidate.run.json"}]
  rubrics: [{id: "status", kind: "deterministic", assertions: [{kind: "run-status", expected: "completed"}]}]
  cases: [{id: "url-verification", run: "candidate", rubrics: ["status"]}]
}

eval_pack NAME { ... } binds NAME to eval_pack_manifest({ ... }). If a string id is supplied after the name, that string becomes the manifest id; if the declaration starts with a string id, Harn derives a valid binding name from the id. Missing version defaults to 1, and missing id defaults to the header id.

Field entries use field: expression and are normalized through the same eval-pack runtime data model as TOML packs. A top-level baseline field acts as a default compare_to path for cases that do not specify their own baseline.

Eval-pack manifests may set trials = N to repeat each case evaluation N times. A case may override the manifest default with its own trials value. The runner records every trial under report.cases[*].trials, summarizes the distribution in report.cases[*].reliability, and emits generic stats rows in report.stats_rows with passes, fails, trials, case_fingerprint, and harness_config_fingerprint fields. report.stats.macro_pass_at_1 is the uniform-case-weighted pass-rate mean over decided cases.

Cases are replay/fixture cases by default. A case with kind: "live-verify" is executed against a live workspace instead of a pre-recorded run record. Live cases declare task, workspace (or project), verify_command, expected_output_paths, required_output_snippets, and optional tool_budgets. The manifest or case must declare executor, a command spec that is either a shell string, an argv list, or an object with command or argv, plus optional cwd, env, and timeout_seconds. Relative command working directories resolve under the live workspace.

For each live trial, Harn sends the executor a JSON request on stdin: {schema, manifest, case, trial, trials}. case.workspace is an absolute workspace path, and the case object includes the task, verify command, expected paths, required snippets, tool budgets, metadata, and case fingerprint. The executor writes a normalized JSON object to stdout. Generic outcome fields are verification (PASS, FAIL, or skip), verificationExitCode, timedOut, wallTimeSeconds, costUsd, producedPaths, and toolCallSummary; snake_case aliases are also accepted. Harn then runs the case verify_command in the workspace, checks expected paths and required snippets, enforces any declared tool budgets against toolCallSummary, and records the merged normalized outcome under report.cases[*].trials[*]. Executor command details are part of the harness fingerprint, while the live task, workspace/project reference, verify command, expected outputs, required snippets, and tool budgets are part of the case fingerprint.

Each normalized case carries case_fingerprint, a deterministic SHA-256 prefix over the case contract: task source, expected output references, resolved rubrics, comparison target, thresholds, severity, and case metadata. The report also carries harness_config_fingerprint, a deterministic hash over the manifest-level harness configuration such as model, prompt/tool-format, pipeline revision metadata, package data, and judge configuration. Paired eval statistics compare rows only when both the case and harness fingerprints are compatible.

eval_pack_run(manifest, options?) appends one durable eval-ledger row per trial cell keyed by (suite, model, split, commit, case, case_fingerprint, harness_config_fingerprint, trial) to the active event-log backend, defaulting to the sqlite event log under the manifest base_dir / HARN_STATE_DIR. Before running a cell, the runner reuses an exact matching row and records the skip in report.run_state; rows for the same case/trial with mismatched fingerprints are refused as resume candidates and reported under run_state.fingerprint_refusals. A rerun where every requested cell is already present reports run_state.all_skipped = true and appends a run-state heartbeat without duplicating trial rows. options may set namespace, suite, model, commit, and branch for eval-pack resume identity and provenance. Lower-level ledger builtins also accept split, case, case_fingerprint, harness_config_fingerprint, and limit filters: eval_ledger_read, eval_ledger_append_rows, eval_ledger_prior_commit_rows, and eval_ledger_resolve_resume_plan.

Declarative trigger manifests may bind a trigger directly to an eval pack with handler = "eval_pack://<target>". A path-like target (eval_pack://evals/a.toml or an absolute path) loads that TOML/JSON pack relative to harn.toml; a bare target resolves by pack id, name, or file stem through the root package and installed package eval declarations ([package].evals or default harn.eval.toml). Dispatch runs the normalized manifest through the same eval_pack_run path as scripts, so cron ticks inherit trigger dedupe, retry, DLQ, replay/cancel, budget, and flow-control handling without a separate scheduler. Optional trigger-local ledger = { ... } or eval_options = { ... } fields are passed as the eval_pack_run options.

Manifests may declare named partitions with a split block:

[split]
tune = ["case-a", "case-b"]
holdout = ["case-c"]

eval_pack_validate_split(manifest) rejects duplicate case ids, duplicate partition entries, overlapping partitions, unknown case ids, and under-covered splits. eval_pack_run(manifest) performs the same validation before running cases.

An eval_pack block may include ordinary Harn statements and one summarize { ... } block. These statements run when the declaration is executed in script or block position, with the binding name, id, version, and all declared field names in scope. When a file has pipelines, top-level eval_pack declarations are preloaded as manifest values without running their executable body, so importing or running an unrelated pipeline does not trigger eval side effects.

Eval statistics stdlib

std/eval/stats provides pure, deterministic eval-meter statistics over generic row dictionaries. A row should carry passes, trials, skips, timeouts, wallTimeSeconds, costUsd, and group; name or case_name identifies the case, and case_fingerprint/caseFingerprint plus harness_config_fingerprint/harnessConfigFingerprint may be supplied to reject non-comparable paired rows. Ledger aliases such as pass_rate, total_cost_usd, and agent_lane_escalated are accepted when present.

The module exposes:

FunctionContract
aggregate_trials(name, outcomes, metadata?)Collapses trial outcomes into counts, PASS/FAIL/FLAKY/skip status, majority, mean/stdev wall time, and cost fields
bootstrap_mean_ci(values, resamples, alpha, seed)Returns {mean, lo, hi, std, n} for a seeded bootstrap; resample indices are drawn from the high-order LCG state bits so power-of-two case counts do not collapse the CI
macro_pass_at_1(rows)Computes the uniform-case-weighted mean pass rate over decided cases
reliability_breakdown(rows)Returns all-pass, flaky, all-fail, and no-decision fractions plus raw case counts
pass_caret_k(rows) / pass_at_k(rows)Computes strict pass^k over decided cases
paired_case_deltas(baseline, current)Pairs decided rows by case and compatible fingerprint, returning current-minus-baseline pass-rate deltas
paired_delta_report(baseline, current, resamples?, seed?)Reports paired bootstrap delta and status, where improved requires CI lower bound > 0, regression requires current macro below baseline - sigma, and all other results are inconclusive
regression_gate(baseline, current, k?)Fails when current macro pass@1 is below baseline - k * sigma
skip_rate(rows) / timeout_rate(rows)Mean per-row skip and timeout fractions
worst_group(rows)Lowest macro pass@1 group
cost_per_solved(rows)Total realized cost divided by solved cases, or nil when nothing solved
routing_calibration_report(cheap, ladder, frontier)Pairs cheap-only, ladder, and frontier rows to report over-escalation, under-escalation, costs, and convergence-at-frontier

Package registry index

harn package search, harn package info, and registry-name dependencies use a lightweight TOML index. The registry source is chosen in this order: a command --registry flag, HARN_PACKAGE_REGISTRY, [registry].url from the nearest manifest, then Harn's default hosted index URL. Registry URLs may be https://, http://, file://, or a filesystem path; relative manifest registry paths resolve from the manifest directory.

Registry package names are either unscoped names such as acme-lib or scoped names such as @burin/notion-sdk. Segments must start with an ASCII alphanumeric character and may then contain ASCII alphanumerics, -, _, or .. First-party packages should use the @burin/ namespace.

Registry entries map discovery names to the existing git-backed package manager path; they do not introduce a second package install mechanism. For example, harn add @burin/notion-sdk@1.2.3 reads the index entry, writes the equivalent [dependencies] git table, updates harn.lock, and materializes the same .harn/packages/<package>/ tree that a direct GitHub install would use.

Manifests may also keep a registry dependency semantic:

[dependencies]
notion-sdk-harn = { version = "^1.2" }
notion = { version = ">=1.2,<2.0", registry_name = "@burin/notion-sdk", package = "notion-sdk-harn" }

harn install records the selected exact registry version, resolved git tag when present, commit SHA, and content hash in harn.lock. Frozen/offline installs reuse that lock entry and local git cache without querying the registry index again.

[registry]
url = "https://burin-labs.github.io/harn-cloud/package-index/harn-package-index.toml"

The default URL points at the free-tier GitHub-Pages-hosted index in burin-labs/harn-cloud; the deployed harn-cloud-gateway mirrors the same content at /index.toml. Override per-project via [registry] url = ... in harn.toml, or globally via HARN_PACKAGE_REGISTRY.

Registry index format:

version = 1

[[package]]
name = "@burin/notion-sdk"
description = "Notion SDK package for Harn connectors"
repository = "https://github.com/burin-labs/notion-sdk-harn"
license = "MIT OR Apache-2.0"
harn = ">=0.7,<0.8"
exports = ["client", "schema"]
connector_contract = "v1"
docs_url = "https://docs.harnlang.com/connectors/notion"
checksum = "sha256:..."
provenance = "https://github.com/burin-labs/notion-sdk-harn/releases/tag/v1.2.3"

[[package.version]]
version = "1.2.3"
git = "https://github.com/burin-labs/notion-sdk-harn"
tag = "v1.2.3"
package = "notion-sdk-harn"
checksum = "sha256:..."
provenance = "https://github.com/burin-labs/notion-sdk-harn/releases/tag/v1.2.3"

Package-level metadata includes the registry name, version list, description, repository, license, Harn compatibility range, exported modules, connector contract compatibility, docs URL, and optional checksum/provenance fields. Version entries must specify git plus exactly one of tag, rev, or branch; tag or rev is preferred for reproducible installs.

Use registry names when developers should discover first-party or community packages by capability and stable name. Use direct GitHub refs for local dogfood, private repositories, unreleased commits, or temporary pins that are not ready for the shared index.

harn.lock is a typed TOML file with version = 4 and one [[package]] entry per dependency. Each git entry records:

  • source
  • tag, when the dependency resolved through a git tag
  • rev_request
  • commit
  • content_hash
  • package_version
  • harn_compat
  • provenance
  • manifest_digest
  • exports
  • permissions
  • host_requirements

content_hash is a SHA-256 over the cached package tree. Harn verifies that hash whenever it reuses a cached package or re-materializes .harn/packages/<alias>/. manifest_digest separately hashes the resolved package manifest so audit and host policy can detect package-surface drift without re-hashing the full tree. provenance, exports, permissions, and host_requirements mirror the resolved package's declared source, module/tool/skill surface, and policy requirements for host UI, CI, and locked-install review.

For CI and production hosts, harn install --locked --offline uses only the committed harn.lock plus the local shared cache; it fails when the manifest and lockfile disagree or when a locked git package is not already cached. harn package cache list, clean, and verify inspect, garbage-collect, and recompute content hashes for cached git packages. harn package cache verify --materialized also verifies installed .harn/packages/ contents against the lockfile hashes. harn package list summarizes locked packages, their exported surfaces, permissions, host requirements, materialization status, and integrity state. harn package doctor diagnoses missing/stale lockfiles, missing materialized packages, content-hash mismatches, host capability gaps, and invalid installed tool or skill metadata. Publish-readiness checks stay under harn package check; applications can run doctor without becoming publishable packages.

[exports] — stable package module entry points

[exports]
capabilities = "runtime/capabilities.harn"
providers = "runtime/providers.harn"

[exports] maps logical import suffixes to package-root-relative module paths. After harn install, consumers import them as "<package>/<export>" instead of coupling to the package's internal directory layout.

Exports are resolved after the direct .harn/packages/<path> lookup, so packages can still expose raw file trees when they want that behavior.

[[package.tools]] and [[package.skills]]

Custom tool and skill packages declare their host-facing surface in the [package] table so installs can be reviewed from the manifest and lockfile:

[package]
name = "acme-tools"
version = "0.1.0"
provenance = "https://github.com/acme/acme-tools/releases/tag/v0.1.0"
permissions = ["tool:read_only"]
host_requirements = ["workspace.read_text"]

[exports]
tools = "lib/tools.harn"

[[package.tools]]
name = "read-note"
module = "lib/tools.harn"
symbol = "tools"
description = "Read a note through the package tool registry."
permissions = ["tool:read_only"]

[package.tools.input_schema]
type = "object"
required = ["path"]

[package.tools.annotations]
kind = "read"
side_effect_level = "read_only"

[[package.skills]]
name = "review"
path = "skills/review"

Each tool module is a package-root-relative Harn module and symbol names the exported registry builder. input_schema, output_schema, and annotations are TOML tables that must round-trip to the runtime JSON schema and tool annotation shapes. Each skill path is a package-root-relative directory containing SKILL.md with valid front matter. Package-level permissions and host requirements are merged with per-tool or per-skill values when Harn writes the lockfile.

[asset_roots] — package-root prompt asset aliases

[asset_roots]
partials = "src/prompts/partials"
prompts  = "src/prompts"

[asset_roots] defines named directories under the project root that prompt assets can address through @<alias>/<rel> paths. The render / render_prompt builtins, the template.render host capability, and {{ include "..." }} directives all honor:

  • @/<rel> — anchored at the project root (the harn.toml directory).
  • @<alias>/<rel> — anchored at the directory [asset_roots] maps <alias> to.

The project root is derived from the calling file, so a render call inside an imported module resolves the same way regardless of who called it. Both forms reject .. segments and absolute targets so a package-rooted asset can never escape the project root.

harn check validates that @-prefixed asset paths resolve to a real file at preflight time. harn contracts bundle records every resolved asset under prompt_assets. Plain (non-@) paths keep their legacy source-relative resolution unchanged.

Stdlib prompt assets use embedded std/...harn.prompt paths, for example std/agent/prompts/tool_contract_text.harn.prompt. They are the default home for reusable model-facing stdlib prompt prose, render without filesystem I/O, and report stable std://... provenance URIs.

[llm] — packaged provider extensions

[llm.providers.my_proxy]
base_url = "https://llm.example.com/v1"
chat_endpoint = "/chat/completions"
completion_endpoint = "/completions"
auth_style = "bearer"
auth_env = "MY_PROXY_API_KEY"
cost_per_1k_in = 0.0002
cost_per_1k_out = 0.0006
latency_p50_ms = 900

[llm.aliases]
my-fast = { id = "vendor/model-fast", provider = "my_proxy" }

The [llm] table accepts the same schema as providers.toml (providers, aliases, inference_rules, tier_rules, tier_defaults, model_defaults) but scopes it to the current run.

When Harn starts from a file inside a workspace, it merges:

  1. built-in defaults,
  2. the global provider file (HARN_PROVIDERS_CONFIG or ~/.config/harn/providers.toml),
  3. the root project's [llm] table.

Installed package manifests do not auto-merge runtime extensions such as [llm], [capabilities], [[hooks]], or [[triggers]] into the host project. Package code is importable; host runtime configuration remains root-manifest-owned by default.

Persona and step lifecycle hooks

Persona scripts can install lifecycle hooks without forking the bundled persona source:

@persona(name: "merge_captain")
fn merge_captain(ctx) {
  return admin_merge(ctx)
}

@step(name: "admin_merge")
fn admin_merge(ctx) {
  return ctx
}

pipeline default() {
  register_persona_hook("merge_*", "PreStep", { ctx -> nil })
  register_step_hook("merge_captain", "admin_merge", "PostStep", { ctx ->
    {output: ctx.output}
  })
}

register_persona_hook(persona_pattern, event, handler) matches a glob-style persona name and fires for matching lifecycle events. register_step_hook(persona_pattern, step_name, event, handler) further narrows the hook to one statically declared @step(name: ...). harn check rejects literal step-hook targets whose persona pattern matches a statically declared @persona but whose step name is not declared by that persona.

Accepted event strings are PreStep, PostStep, OnBudgetThreshold(<pct>), OnApprovalRequested, OnHandoffEmitted, OnPersonaPaused, and OnPersonaResumed.

The core VM emits normalized hook payloads with event, target, persona, and step fields. target is persona.step for step events. PreStep handlers return nil, {deny: "reason"}, or {args: [...]}. PostStep handlers return nil or {output: value}. Tool hooks still run inside the step body, so low-level tool policy and high-level persona policy compose in execution order.

Preset run_command tool hooks

The std/tool_hooks module ships a catalogue-driven wrapper for run_command-shaped tool handlers. Rules describe shell-command faux-pas (rewriteable mistakes such as find . -name '*.py', cargo build without --target-dir, or denyable irreversible commands like git push --force main). The shipped catalogues (harn-canon/*) live in crates/harn-stdlib/src/stdlib/stdlib_tool_hooks_catalogues.harn and cover the universal deny set plus per-stack rules for rust, python, typescript/ts, swift, sql, and harn.

tool_rule(config) validates a rule at construction:

FieldTypeRequiredNotes
idstringyesConvention <stack>.<tool>.<short>; unique within the catalogue.
patternstring regex or (command, context) -> bool callableyesPredicate. Regex strings have no lookaround; callables run on every dispatch and must be pure.
applies_tolist<string>noPer-rule stack scoping. [] matches every opt-in.
severity"error" | "warning" | "info"no, default "warning"Drives audit-side rendering and shipped-mode dispatch.
explanationstringnoSingle sentence; agent paraphrases it back.
referenceslist<string>noDoc / RFC / post-mortem URLs surfaced in the audit envelope.
priorityintno, default 0Sort key during the linear sweep; higher fires first.
rewrite(command, context) -> string | dict | nilnoOptional rewriter; nil/identical returns skip the rewrite but the audit envelope still flows.

catalogue(config) accepts {id, rules, stack?, version?, source?, priority?}. Stackless catalogues survive tool_hooks_filter; stack-tagged catalogues fire only when the caller opts into the matching stacks list. The shipped catalogues advertise source: "harn-canon".

preset_run_command(config) returns an (args) -> result closure shaped for agent_loop's tool registry. Recognized keys:

  • stacks (list<string>, default []) — drives both tool_hooks_filter and registry auto-seed.
  • registry (tool_hooks_registry() value, default tool_hooks_seed_registry(stacks)) — explicit override.
  • custom_rules (list<tool_rule>, default []) — matched before the registry regardless of stack scoping.
  • mode ((rule, args, inner) -> result, default tool_hooks_mode_rewrite_with_audit) — match-dispatch callable.
  • inner ((args) -> result, default nil) — underlying executor. When omitted the wrapper returns decision envelopes without executing.
  • llm_classifier (dict, default nil) — optional small-model classifier consulted when no deterministic rule matched.

Three shipped modes return a uniform decision envelope:

{action: "rewrite" | "deny" | "passthrough",
 command: <string>,
 original_command: <string>,
 rule_id: <string>,
 catalogue_id: <string>,
 severity: <string>,
 explanation: <string>,
 references: [<string>, ...],
 result?: <inner's return>}
  • tool_hooks_mode_rewrite_with_audit invokes the rule's rewrite, forwards to inner, records a tool_rewrite lifecycle audit entry, and queues a one-turn tool_rewritten system reminder via tool_hooks_inject_reminder. When the rewrite is identical to the original command the envelope still flows.
  • tool_hooks_mode_deny_with_explanation refuses to dispatch, records a tool_denied lifecycle audit entry, and never invokes inner.
  • tool_hooks_mode_passthrough_only_audit invokes inner with the original command unchanged and records a tool_rule_warning lifecycle audit entry.

Custom modes call the same tool_hooks_emit_audit(kind, payload) and tool_hooks_inject_reminder({tags, body, ttl_turns, ...}) primitives and return any envelope shape the caller wants — unknown action strings are treated as advisory extensions by replay tooling.

The optional llm_classifier runs a small model against any command that didn't hit a deterministic rule. Verdicts shape: {kind: "rewrite" | "deny" | "allow", confidence, rewritten?, explanation?, references?}. Verdicts at or above the configured threshold (default 0.8) dispatch via the verdict's own mode (rewritetool_hooks_mode_rewrite_with_audit, denytool_hooks_mode_deny_with_explanation); allow and sub-threshold verdicts fall through to inner. Every classifier invocation emits a tool_hook_classifier_verdict audit regardless of outcome. Transport errors and non-JSON responses degrade gracefully to passthrough.

The user-facing reference is docs/src/tool-hooks.md; runnable recipes live in docs/src/cookbooks/tool-hooks.md; the contribution guide for harn-canon rules is in docs/src/contributing/preset-hooks.md.

[lint] — lint configuration

[lint]
disabled = ["unused-import"]
require_file_header = false
complexity_threshold = 25
persona_step_allowlist = ["legacy_helper"]
  • disabled silences the listed rules for the whole project.
  • require_file_header opts into the require-file-header rule, which checks that each source file begins with a /** */ HarnDoc block whose title matches the filename.
  • complexity_threshold overrides the default cyclomatic-complexity warning threshold (default 25, chosen to match Clippy's cognitive_complexity default). Set lower to tighten, higher to loosen. Per-function escapes still go through @complexity(allow).
  • persona_step_allowlist lists non-stdlib helper functions that @persona bodies may call directly without a matching @step declaration.

Sandbox mode

The harn run command installs a default worktree sandbox before the VM starts. The default policy uses sandbox_profile: "worktree", roots filesystem/process access at the nearest harn.toml project root (or the invocation working directory when no project manifest is present), allows local process execution, and denies network side effects. This makes direct runs fail closed for filesystem escapes, subprocess working directories, and outbound HTTP/connectors unless the operator explicitly opts out.

harn run also supports builtin allow/deny flags that restrict which builtins a program may call.

--no-sandbox

harn run --no-sandbox script.harn

Disables the default worktree filesystem/process sandbox and the network side-effect ceiling for this invocation. The CLI emits a warning when this escape hatch is used. --deny / --allow still apply when present.

--read-only-root

harn run --read-only-root /path/to/other-repo main.harn

Permit reads from additional filesystem roots while keeping the default harn run sandbox policy intact. Paths under each --read-only-root are allowed for read-style operations but rejected for writes. Use this when a script needs to consume files from a sibling checkout or shared assets without disabling sandboxing. --read-only-root cannot be combined with --no-sandbox.

--deny

harn run --deny read_file,write_file,exec script.harn

Denies the listed builtins. Any call to a denied builtin produces a runtime error:

Permission denied: builtin 'read_file' is not allowed in sandbox mode
  (use --allow read_file to permit)

--allow

harn run --allow llm,llm_stream,llm_stream_call script.harn

Allows only the listed builtins plus the core builtins (see below). All other builtins are denied.

--deny and --allow cannot be used together; specifying both is an error.

Core builtins

The following builtins are always allowed, even when using --allow:

println, print, log, type_of, to_string, to_int, to_float, len, assert, assert_eq, assert_ne, json_parse, json_stringify, runtime_context, task_current, runtime_context_values, runtime_context_get, runtime_context_set, runtime_context_clear

Propagation

Sandbox restrictions propagate to child VMs created by spawn, parallel, and parallel each. A child VM inherits the same set of denied builtins as its parent.

Handler capability sandbox

When a workflow or handler runs under an active CapabilityPolicy, Harn also enforces workspace_roots at runtime for filesystem builtins. Attempts to read, write, create, copy, stat, list, or delete paths outside the declared roots fail as typed tool_rejected sandbox violations. File-backed prompt-template rendering (render, render_prompt, render_with_provenance, template.render, and include) follows the same read boundary. Embedded std/... prompt assets are not filesystem reads. Path-backed vision_ocr(...) image inputs follow the same read boundary. Process cwd escapes through exec_at / shell_at are rejected the same way.

Pure-compute handlers can run through the WASM sandbox entrypoint exposed by harn-wasm as executePureComponent and described by crates/harn-wasm/wit/harn-pure.wit. That component surface has no host imports for filesystem, process, network, clock, random, LLM, or async effects, so attempted side effects fail inside the component boundary.

Process execution is wrapped in an OS sandbox selected by the active CapabilityPolicy.sandbox_profile. The default profile is worktree: workspace-root path enforcement plus best-effort OS confinement, with HARN_HANDLER_SANDBOX={off,warn,enforce} controlling what happens when the platform mechanism is unavailable. Pipelines opt into sandbox_profile: "os_hardened" to make the OS confinement required — spawns return tool_rejected if the platform mechanism is missing, regardless of HARN_HANDLER_SANDBOX. The Tesseract subprocess used by vision_ocr(...) is launched through the same sandbox entrypoint. The per-platform mechanisms are:

  • Linux: a Landlock LSM ruleset derived from workspace_roots and the workspace capability set, plus a seccomp-bpf blocklist of Tier-1 dangerous syscalls (and the network family when the side-effect level is below network); PR_SET_NO_NEW_PRIVS is always enabled.
  • macOS: a sandbox-exec profile rendered from the policy. Writes are limited to scratch dirs plus declared workspace_roots only when the policy allows workspace writes; network is allowed only when the side-effect ceiling permits network.
  • Windows: a per-spawn AppContainer with no capability SIDs plus a Job Object capping memory, process count, and UI surface; icacls grants the AppContainer SID Modify (or ReadAndExecute) on each workspace_roots entry for the lifetime of the spawn.
  • OpenBSD: pledge promises and unveil path permissions derived from the same policy.

SandboxProfile::Unrestricted skips both path enforcement and OS confinement; harn run --no-sandbox is the CLI escape hatch that leaves direct runs in that state. SandboxProfile::Wasi is testbench-only — subprocesses are intercepted by the process tape and resolved against recorded WASI modules. See the sandboxing guide for the full per-platform capability → kernel-knob mapping table.

Test framework

Harn includes a built-in test runner invoked via harn test.

Running tests

harn test path/to/tests/         # run all test files in a directory
harn test path/to/test_file.harn # run tests in a single file

Test discovery

The test runner scans .harn files for pipelines whose names start with test_. Each such pipeline is executed independently. A test passes if it completes without error; it fails if it throws or an assertion fails.

pipeline test_addition() {
  assert_eq(1 + 1, 2)
}

pipeline test_string_concat() {
  let result = "hello" + " " + "world"
  assert_eq(result, "hello world")
}

Assertions

Three assertion builtins are available. They can be called anywhere, but they are intended for test pipelines and the linter warns on non-test use:

FunctionDescription
assert(condition)Throws if condition is falsy
assert_eq(a, b)Throws if a != b, showing both values
assert_ne(a, b)Throws if a == b, showing both values

Mock LLM provider

During harn test, the HARN_LLM_PROVIDER environment variable is automatically set to "mock" unless explicitly overridden. The mock provider returns deterministic placeholder responses, allowing tests that call llm, llm_stream, or llm_stream_call to run without API keys.

CLI options

FlagDescription
--filter <pattern>Only run tests whose names contain <pattern>
--verbose / -vShow per-test timing and detailed failures
--timingShow per-test timing and summary statistics
--timeout <ms>Per-test timeout in milliseconds (default 30000)
--parallelRun test files concurrently
--junit <path>Write JUnit XML report to <path>
--recordRecord LLM responses to .harn-fixtures/
--replayReplay LLM responses from .harn-fixtures/

Environment variables

The following environment variables configure runtime behavior:

VariableDescription
HARN_LLM_PROVIDEROverride the default LLM provider. Any configured provider is accepted. Built-in names include anthropic (default), openai, openrouter, huggingface, ollama, local, and mock.
HARN_LLM_TIMEOUTLLM request timeout in seconds. Default 120.
HARN_STATE_DIROverride the runtime state root used for store, checkpoint, metadata, and default worktree state. Relative values resolve from the active project/runtime root.
HARN_RUN_DIROverride the default persisted run directory. Relative values resolve from the active project/runtime root.
HARN_WORKTREE_DIROverride the default worker worktree root. Relative values resolve from the active project/runtime root.
ANTHROPIC_API_KEYAPI key for the Anthropic provider.
OPENAI_API_KEYAPI key for the OpenAI provider.
OPENROUTER_API_KEYAPI key for the OpenRouter provider.
HF_TOKENAPI key for the HuggingFace provider.
HUGGINGFACE_API_KEYAlternate API key name for the HuggingFace provider.
OLLAMA_HOSTOverride the Ollama host. Default http://localhost:11434.
HARN_OLLAMA_NUM_CTXPreferred Ollama context window for Harn-owned Ollama chat, completion, context-window fallback, and warmup requests. Must be a positive integer. Takes precedence over OLLAMA_CONTEXT_LENGTH and OLLAMA_NUM_CTX; default 32768. Hosts that persist IDE preferences should pass the raw stored value here and let Harn validate/default it.
HARN_OLLAMA_KEEP_ALIVEPreferred Ollama keep-alive for Harn-owned Ollama chat, completion, and warmup requests. Takes precedence over OLLAMA_KEEP_ALIVE; default 30m. forever, infinite, and -1 normalize to Ollama's numeric -1; default normalizes to 30m.
HARN_OLLAMA_UNLOAD_GRACE_MSPreferred Ollama unload/warmup grace in milliseconds before Harn emits a one-time progress notification for an Ollama stream that has produced no chunks yet. Takes precedence over OLLAMA_UNLOAD_GRACE_MS; default 10000. Set 0 to disable the notification.
LOCAL_LLM_BASE_URLBase URL for a local OpenAI-compatible server. Default http://localhost:8000.
LOCAL_LLM_MODELDefault model ID for the local OpenAI-compatible provider.
MLX_BASE_URLBase URL for the MLX OpenAI-compatible provider. Default http://127.0.0.1:8002.
MLX_MODEL_IDDefault model ID for the MLX OpenAI-compatible provider readiness probe.

Known limitations and future work

The following are known limitations in the current implementation that may be addressed in future versions.

Type system

  • Definition-site generic checking: Inside a generic function body, type parameters are treated as compatible with any type. The checker does not yet restrict method calls on T to only those declared in the where clause interface.
  • No runtime interface enforcement: Interface satisfaction is checked at compile-time only. Passing an untyped value to an interface-typed parameter is not caught at runtime.

Runtime

Syntax limitations

  • No impl Interface for Type syntax: Interface satisfaction is always implicit. There is no way to explicitly declare that a type implements an interface.