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:
| Keyword | Token |
|---|---|
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')
| Suffix | Unit | Equivalent |
|---|---|---|
ms | milliseconds | – |
s | seconds | 1000 ms |
m | minutes | 60 s |
h | hours | 60 m |
d | days | 24 h |
w | weeks | 7 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)
| Operator | Token | Description |
|---|---|---|
== | .eq | Equality |
!= | .neq | Inequality |
&& | .and | Logical AND |
|| | .or | Logical OR |
|> | .pipe | Pipe |
?? | .nilCoal | Nil coalescing |
** | .pow | Exponentiation |
?. | .questionDot | Optional property/method chaining |
-> | .arrow | Arrow |
<= | .lte | Less than or equal |
>= | .gte | Greater than or equal |
+= | .plusAssign | Compound assignment |
-= | .minusAssign | Compound assignment |
*= | .starAssign | Compound assignment |
/= | .slashAssign | Compound assignment |
%= | .percentAssign | Compound assignment |
Single-character operators
| Operator | Token | Description |
|---|---|---|
= | .assign | Assignment |
! | .not | Logical NOT |
. | .dot | Member access |
+ | .plus | Addition / concatenation |
- | .minus | Subtraction / negation |
* | .star | Multiplication / string repetition |
/ | .slash | Division |
< | .lt | Less than |
> | .gt | Greater than |
% | .percent | Modulo |
? | .question | Ternary / Result propagation |
| | .bar | Union types |
Keyword operators
| Operator | Description |
|---|---|
in | Membership test (lists, dicts, strings, sets) |
not in | Negated membership test |
Delimiters
| Delimiter | Token |
|---|---|
{ | .lBrace |
} | .rBrace |
( | .lParen |
) | .rParen |
[ | .lBracket |
] | .rBracket |
, | .comma |
: | .colon |
; | .semicolon |
@ | .at (attribute prefix) |
Special tokens
| Token | Description |
|---|---|
.newline | Line break character |
.eof | End 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:
| Precedence | Operators | Associativity | Description |
|---|---|---|---|
| 1 | |> | Left | Pipe |
| 2 | ? : | Right | Ternary conditional |
| 3 | || | Left | Logical OR |
| 4 | && | Left | Logical AND |
| 5 | == != | Left | Equality |
| 6 | < > <= >= in not in | Left | Comparison / membership |
| 7 | + - | Left | Additive |
| 8 | ?? | Left | Nil coalescing |
| 9 | * / % | Left | Multiplicative |
| 10 | ** | Right | Exponentiation |
| 11 | ! - (unary) | Right (prefix) | Unary |
| 12 | . ?. [] [:] () ? | Left | Postfix |
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
valuesdictionary mapping names toHarnValue - A
mutableset tracking which names were declared withvar - An optional
parentreference
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– definesnameas immutable in the current scope.var name = value– definesnameas 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
forloop bodies (loop variable is mutable)whileloop iterationsparallel,parallel each, andparallel settletask bodies (isolated interpreter per task)try/catchblocks (catch body gets its own child scope with optional error variable)- Closure invocations (child of the captured environment, not the call site)
blocknodes
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
- All top-level nodes are scanned. Pipeline declarations are registered by name. Import declarations are processed (loaded and evaluated).
- The entry pipeline is selected: the pipeline named
"default"if it exists, otherwise the first pipeline in the file. - 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
overridedeclarations, 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
overridedeclarations, 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:
- If path starts with
std/, loads embedded stdlib module (e.g.std/text) - Relative to current file’s directory; auto-adds
.harnextension .harn/packages/<path>directories rooted at the nearest ancestor package root (the search walks upward and stops at a.gitboundary)- Package manifest
[exports]mappings under.harn/packages/<package>/harn.toml - Package directories with
lib.harnentry 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 importederror (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-functionrule 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_oflookup, 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
| Type | Syntax | Description |
|---|---|---|
string | "text" | UTF-8 string |
int | 42 | Platform-width integer |
float | 3.14 | Double-precision float |
bool | true / false | Boolean |
nil | nil | Null value |
list | [1, 2, 3] | Ordered collection |
dict | {key: value} | String-keyed map |
set | set(1, 2, 3) | Unordered collection of unique values |
closure | { x -> x + 1 } | First-class function with captured environment |
enum | Color.Red | Enum variant, optionally with associated data |
struct | Point({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
| Value | Truthy? |
|---|---|
bool(false) | No |
nil | No |
int(0) | No |
float(0) | No |
string("") | No |
list([]) | No |
dict([:]) | No |
set() (empty) | No |
| Everything else | Yes |
Equality
Values are equal if they have the same type and same contents, with these exceptions:
intandfloatare compared by convertinginttofloat- 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 (+, -, *, /)
| Left | Right | + | - | * | / |
|---|---|---|---|---|---|
| int | int | int | int | int | int (truncating) |
| float | float | float | float | float | float |
| int | float | float | float | float | float |
| float | int | float | float | float | float |
| string | string | string (concatenation) | TypeError | TypeError | TypeError |
| string | int | TypeError | TypeError | string (repetition) | TypeError |
| int | string | TypeError | TypeError | string (repetition) | TypeError |
| list | list | list (concatenation) | TypeError | TypeError | TypeError |
| dict | dict | dict (merge, right wins) | TypeError | TypeError | TypeError |
| other | other | TypeError | TypeError | TypeError | TypeError |
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 ** intreturnsintfor non-negative exponents that fit inu32, using wrapping integer exponentiation.- Negative or very large integer exponents promote to
float. - Any case involving a
floatreturnsfloat. - Non-numeric operands raise
TypeError.
Logical (&&, ||)
Short-circuit evaluation:
&&: if left is falsy, returnsfalsewithout evaluating right.||: if left is truthy, returnstruewithout 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:
- If
fevaluates to a closure, invokes it withaas the single argument. - If
fis an identifier resolving to a builtin, calls the builtin with[a]. - If
fis an identifier resolving to a closure variable, invokes it witha. - 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].
| Expression | Value | Shape |
|---|---|---|
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:
| Field | Type | Description |
|---|---|---|
results | list | List of Result values (one per item), in order |
succeeded | int | Number of Ok results |
failed | int | Number 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):eis bound to the thrown value directly. - Any other runtime error:
eis bound to the error’slocalizedDescriptionstring.
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: trueon each deferred tool and prepends thetool_search_tool_{bm25,regex}_20251119meta-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: trueon 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 matchesgpt-5.4+. - Everyone else (and any of the above on older models) — Harn
injects a synthetic
__harn_tool_searchtool 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 resultResult.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
| Function | Description |
|---|---|
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 { ... }
| Marker | Meaning | Where T may appear |
|---|---|---|
out T | covariant | output positions only |
in T | contravariant | input 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
| Constructor | Variance |
|---|---|
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, ...) -> R | parameters 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.
| Argument | Type | Meaning |
|---|---|---|
since | string | Version that introduced the deprecation |
use | string | Replacement 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.
| Argument | Type | Meaning |
|---|---|---|
name | string | Tool name (defaults to fn name) |
kind | string | One of read, edit, delete, move, search, execute, think, fetch, other |
side_effect_level | string | none, 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 exprreturn exprbreakandcontinue- A block where every control path exits
- An
if/elsewhere both branches infer tonever - 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"narrowsxtoTon the truthy branch (whereTis one of the type-of protocol names:string,int,float,bool,nil,list,dict,closure).schema_is(x, Shape)narrowsxtoShapeon the truthy branch.guard type_of(x) == "T" else { ... }narrowsxtoTin 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 concretetype_ofvariants have been ruled out on the current flow path, so an exhaustive chain ending inunreachable()/throwcan be validated; see the “Exhaustive narrowing onunknown” subsection of “Flow-sensitive type refinement”.
Interop between any and unknown:
unknownis assignable toany(upward to the full escape hatch).anyis assignable tounknown(downward — theanyescape hatch lets it flow into anything, includingunknown).
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. Preferunknownunless 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 anllm_calloptions 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
Noneand skip checks. intis assignable tofloat.- 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 withV. - 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
| Method | Signature | Returns |
|---|---|---|
count | .count (property) | int – character count |
empty | .empty (property) | bool – true if empty |
contains(sub) | string | bool |
replace(old, new) | string, string | string |
split(sep) | string | list of strings |
trim() | (none) | string – whitespace stripped |
starts_with(prefix) | string | bool |
ends_with(suffix) | string | bool |
lowercase() | (none) | string |
uppercase() | (none) | string |
substring(start, end?) | int, int? | string – character range |
List methods
| Method | Signature | Returns |
|---|---|---|
count | (property) | int |
empty | (property) | bool |
first | (property) | value or nil |
last | (property) | value or nil |
map(closure) | closure(item) -> value | list |
filter(closure) | closure(item) -> bool | list |
reduce(init, closure) | value, closure(acc, item) -> value | value |
find(closure) | closure(item) -> bool | value or nil |
any(closure) | closure(item) -> bool | bool |
all(closure) | closure(item) -> bool | bool |
flat_map(closure) | closure(item) -> value/list | list (flattened) |
Dict methods
| Method | Signature | Returns |
|---|---|---|
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) | string | bool |
merge(other) | dict | dict (other wins on conflict) |
map_values(closure) | closure(value) -> value | dict |
filter(closure) | closure(value) -> bool | dict |
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.
| Function | Signature | Returns |
|---|---|---|
set(...) | values or a list | set – deduplicated |
set_add(s, value) | set, value | set – with value added |
set_remove(s, value) | set, value | set – with value removed |
set_contains(s, value) | set, value | bool |
set_union(a, b) | set, set | set – all items from both |
set_intersect(a, b) | set, set | set – items in both |
set_difference(a, b) | set, set | set – items in a but not b |
to_list(s) | set | list – convert set to list |
Sets are iterable with for ... in and support len().
Encoding and hashing builtins
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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 stringgroups: 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.zipand.enumerateand dict iteration produce pairs automatically. - Access:
.firstand.secondas properties. - For-loop destructuring:
for (k, v) in iter_expr { ... }binds the.firstand.secondof eachPairtokandv. - 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
Iterand 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.
| Method | Signature |
|---|---|
.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.
| Method | Signature |
|---|---|
.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 yieldPairvalues; a runtime error is raised otherwise..min()/.max()returnnilon an empty iter..any/.all/.findshort-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 aRangereturns a lazyIterwithout 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
| Error | Description |
|---|---|
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) |
retryExhausted | All 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):
| Function | Description |
|---|---|
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
| Function | Description |
|---|---|
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.
| Function | Notes |
|---|---|
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_pathand[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_severitydowngrades preflight diagnostics to warnings or suppresses them entirely. Type-checker and lint diagnostics are unaffected — preflight failures are reported under thepreflightcategory so IDEs and CI filters can route them separately.preflight_allowsuppresses preflight diagnostics tagged with a specific host capability. Entries match an exactcapability.operationpair, acapability.*wildcard, a barecapabilityname, 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:
- built-in defaults,
- the global provider file (
HARN_PROVIDERS_CONFIGor~/.config/harn/providers.toml), - installed package
[llm]tables from.harn/packages/*/harn.toml, - 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
disabledsilences the listed rules for the whole project.require_file_headeropts into therequire-file-headerrule, which checks that each source file begins with a/** */HarnDoc block whose title matches the filename.complexity_thresholdoverrides the default cyclomatic-complexity warning threshold (default 25, chosen to match Clippy’scognitive_complexitydefault). 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:
| Function | Description |
|---|---|
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
| Flag | Description |
|---|---|
--filter <pattern> | Only run tests whose names contain <pattern> |
--verbose / -v | Show per-test timing and detailed failures |
--timing | Show per-test timing and summary statistics |
--timeout <ms> | Per-test timeout in milliseconds (default 30000) |
--parallel | Run test files concurrently |
--junit <path> | Write JUnit XML report to <path> |
--record | Record LLM responses to .harn-fixtures/ |
--replay | Replay LLM responses from .harn-fixtures/ |
Environment variables
The following environment variables configure runtime behavior:
| Variable | Description |
|---|---|
HARN_LLM_PROVIDER | Override the default LLM provider. Any configured provider is accepted. Built-in names include anthropic (default), openai, openrouter, huggingface, ollama, local, and mock. |
HARN_LLM_TIMEOUT | LLM request timeout in seconds. Default 120. |
HARN_STATE_DIR | Override the runtime state root used for store, checkpoint, metadata, and default worktree state. Relative values resolve from the active project/runtime root. |
HARN_RUN_DIR | Override the default persisted run directory. Relative values resolve from the active project/runtime root. |
HARN_WORKTREE_DIR | Override the default worker worktree root. Relative values resolve from the active project/runtime root. |
ANTHROPIC_API_KEY | API key for the Anthropic provider. |
OPENAI_API_KEY | API key for the OpenAI provider. |
OPENROUTER_API_KEY | API key for the OpenRouter provider. |
HF_TOKEN | API key for the HuggingFace provider. |
HUGGINGFACE_API_KEY | Alternate API key name for the HuggingFace provider. |
OLLAMA_HOST | Override the Ollama host. Default http://localhost:11434. |
LOCAL_LLM_BASE_URL | Base URL for a local OpenAI-compatible server. Default http://localhost:8000. |
LOCAL_LLM_MODEL | Default 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
Tto only those declared in thewhereclause 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 Typesyntax: Interface satisfaction is always implicit. There is no way to explicitly declare that a type implements an interface.