Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Harn language specification

Version: 1.0 (derived from implementation, 2026-04-01)

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.sh.

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.

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
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
spawn.spawnKw
while.whileKw
type.typeKw
enum.enum
struct.struct
interface.interface
pub.pub
from.from
to.to
tool.tool
exclusive.exclusive
guard.guard
require.require
each.each
settle.settle
deadline.deadline
yield.yield
mutex.mutex
break.break
continue.continue
select.select
impl.impl

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' | 't' | '\\' | '"' | '$')
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), \t (tab), \\ (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* '"'

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"

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.

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

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()). The consume() helper also skips newlines before checking the expected token.

Top-level

program            ::= (top_level | NEWLINE)*
top_level          ::= import_decl
                     | attributed_decl
                     | pipeline_decl
                     | statement

attributed_decl    ::= attribute+ (pipeline_decl | fn_decl | tool_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        ::= 'import' STRING_LITERAL
                     | 'import' '{' IDENTIFIER (',' IDENTIFIER)* '}'
                       'from' STRING_LITERAL

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

param_list         ::= (IDENTIFIER (',' IDENTIFIER)*)?
block              ::= statement*

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)
  • import "std/collections" — collection utilities (filter_nil, store_stale, store_refresh)
  • 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)

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

Statements

statement          ::= let_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
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 ')' '{' [IDENTIFIER '->'] block '}'
parallel_each      ::= 'parallel' 'each' expression '{' IDENTIFIER '->' block '}'
parallel_settle    ::= 'parallel' 'settle' expression '{' IDENTIFIER '->' block '}'
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     ::= '<' IDENTIFIER (',' 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

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.

```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) {
  println("[${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 ['?' logical_or ':' logical_or]
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     ::= power_expr (('*' | '/' | '%') power_expr)*
power_expr         ::= unary ['**' power_expr]
unary              ::= ('!' | '-') unary | postfix
postfix            ::= primary (member_access
                               | optional_member_access
                               | subscript_access
                               | slice_access
                               | call
                               | try_unwrap)*
member_access      ::= '.' IDENTIFIER ['(' arg_list ')']
optional_member_access
                    ::= '?.' IDENTIFIER ['(' arg_list ')']
subscript_access   ::= '[' expression ']'
slice_access       ::= '[' [expression] ':' [expression] ']'
call               ::= '(' arg_list ')'    (* only when postfix base is an identifier *)
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"}). 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**RightExponentiation
11! - (unary)Right (prefix)Unary
12. ?. [] [:] () ?LeftPostfix

Multiline expressions

Binary operators ||, &&, +, *, /, %, **, |> and the . member access operator 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.

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 first argument to a closure or function.

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.
  • var name = value – defines name as mutable in the current 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.

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).

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 {
  println("${name}=${val}")
}

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

Var destructuring

var destructuring creates mutable bindings that can be reassigned:

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

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)
  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.

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
int42Platform-width integer
float3.14Double-precision 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
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
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 returns nil. 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 zero follows the same runtime error path as division by zero.

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.

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 returns nil.

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 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 matching arm in match expression). This makes non-exhaustive matches a hard failure rather than a silent nil.

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

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

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

defer

defer {
  // cleanup body
}

Registers a block to run when the enclosing scope exits, whether by normal return or by a thrown error. Multiple defer blocks in the same scope execute in LIFO (last-registered, first-executed) order, similar to Go’s defer. The deferred block runs in the scope where it was declared.

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

spawn/await/cancel

let handle = spawn {
  // async body
}
let result = await(handle)
cancel(handle)

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 cancels the task.

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
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 {
  println(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 returns false and no new values are accepted. Buffered items can still be received.

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(1)
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(1)
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.

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_file(path: string, encoding: string) -> string {
  description "Read a file from the filesystem"
  read_file(path)
}

tool search(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.

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 strategy (BM25, regex, semantic, or host-delegated) in-VM, promoting matching deferred tools into the next turn’s schema list.

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 extended thinking 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, defer_loading,
//   tool_search: [string], max_tools: int | nil,
//   prompt_caching, thinking,
// }

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-and-agents.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. 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.

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 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: ...")

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:

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) {
  println(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 {
  println(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 literal values (strings, numbers, booleans, nil, bare identifiers) — no runtime evaluation, no expressions.

Syntax

attribute    ::= '@' IDENTIFIER ['(' attr_arg (',' attr_arg)* [','] ')']
attr_arg     ::= [IDENTIFIER ':'] attr_value
attr_value   ::= literal | IDENTIFIER
@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.

@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.

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).
  • 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”.

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.

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:

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.

Parameterized types

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

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

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,
})

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.
  • 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<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))
println(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>.

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.
  • 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`
  }
}

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 expressions

The condition’s refinements apply to the true and false branches respectively.

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`
    _ -> {}
  }
}

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
}

Covering all eight type_of variants (int, string, float, bool, nil, list, dict, closure) 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, 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}) {
  println("${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

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
sha256(str)Returns the hex-encoded SHA-256 hash of str
md5(str)Returns the hex-encoded MD5 hash of str
let encoded = base64_encode("hello world")  // "aGVsbG8gd29ybGQ="
let decoded = base64_decode(encoded)        // "hello world"
let hash = sha256("hello")                  // hex string
let md5hash = md5("hello")                  // hex string

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.

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.

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.

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 = ["Sources/BurinCore/Resources/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).

[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.

[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"

[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. installed package [llm] tables from .harn/packages/*/harn.toml,
  4. the root project’s [llm] table.

Later layers win on key collisions; rule lists are prepended so package and project inference/tier overrides run before the built-in defaults.

[lint] — lint configuration

[lint]
disabled = ["unused-import"]
require_file_header = false
complexity_threshold = 25
  • 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).

Sandbox mode

The harn run command supports sandbox flags that restrict which builtins a program may call.

–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 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

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.

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 or llm_stream 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.
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.

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.