write-a0

star 1

This skill should be used when the user asks to "write an A0 program", "create an A0 script", "generate A0 code", "write a .a0 file", "A0 syntax", "A0 example", "how to write A0", "A0 language", or needs to produce any new A0 source code. Provides the complete syntax, type system, tool signatures, stdlib, and idiomatic patterns needed to author correct A0 programs.

ThomasRohde By ThomasRohde schedule Updated 2/21/2026

name: write-a0 description: This skill should be used when the user asks to "write an A0 program", "create an A0 script", "generate A0 code", "write a .a0 file", "A0 syntax", "A0 example", "how to write A0", "A0 language", or needs to produce any new A0 source code. Provides the complete syntax, type system, tool signatures, stdlib, and idiomatic patterns needed to author correct A0 programs.

Writing A0 Programs

A0 is a line-oriented scripting language for automation agents. Programs are sequences of statements producing a JSON result. Every program must end with return <expr> (any expression: record, literal, variable, arithmetic, list, etc.).

Core Syntax

# Comments start with #
cap { fs.read: true }        # capability declaration (top of file)
let name = expr              # bind a value
expr -> name                 # bind result of expression statement
return expr                  # required, must be last statement (any expression)

Types

Primitives: int float bool str null — literals: 42 3.14 true false null "hello"

Records: { key: value, nested: { a: 1 } } — keys may be dotted: { fs.read: true }. Spread syntax: { ...base, extra: 42 } merges base into the new record; later keys override earlier ones. Spreading a non-record produces E_TYPE.

Lists: [1, 2, "three"]

Strings are double-quoted with JSON escapes (\", \\, \n, \t).

Punctuation: ( ) for grouping expressions (controls operator precedence).

Arithmetic and Comparison Operators

Binary operators on numeric values, usable anywhere an expression is expected. AST node type: BinaryExpr.

Arithmetic Operators

let total = a + b
let diff = a - b
let product = a * b
let ratio = a / b
let remainder = a % b

Precedence follows standard math: *, /, % bind tighter than +, -. Use parentheses to override: let x = (a + b) * c.

Operands must be numbers (int or float). Using arithmetic on non-numbers produces E_TYPE (exit 4). Division or modulo by zero also produces E_TYPE.

Exception: string concatenation with +. The + operator also works on strings: "hello" + " world" produces "hello world". Both operands must be the same type (both strings or both numbers). Mixing types (e.g., "hello" + 1) produces E_TYPE.

Comparison Operators

let bigger = a > b
let ok = a <= threshold
let same = a == b
let diff = a != b

Full set: >, <, >=, <=, ==, !=. Return bool. Work on numbers and strings (lexicographic comparison for strings). Comparing incompatible types produces E_TYPE.

Comparison operators have lower precedence than arithmetic, so a + 1 > b means (a + 1) > b.

Unary Minus

Negate a numeric value. AST node type: UnaryExpr.

let neg = -x
let result = -(a + b)

Using unary minus on a non-number produces E_TYPE.

Capabilities

Declare required capabilities at the top of the file. Execution fails before any side-effect if the host policy denies them.

cap { fs.read: true, http.get: true }

Valid capabilities: fs.read, fs.write, http.get, sh.exec

Only declare capabilities the program actually uses.

Tools — Read vs Effect

Read-only tools use call?. Effectful tools use do. Using call? on an effect tool is a validation error (E_CALL_EFFECT, exit 2) caught by a0 check.

Tool Mode Keyword Capability
fs.read read call? fs.read
fs.write effect do fs.write
fs.list read call? fs.read
fs.exists read call? fs.read
http.get read call? http.get
sh.exec effect do sh.exec

Tool arguments are always records { ... }, never positional. For full argument/return schemas, consult references/tool-signatures.md.

call? fs.read { path: "data.json" } -> content
do fs.write { path: "out.json", data: result, format: "json" } -> artifact

Stdlib Functions

Pure functions called like name { args }. No capability needed.

Data Functions

Function Purpose Key Args
parse.json Parse JSON string { in: str }
get Read nested path { in: record, path: "a.b[0]" }
put Set nested path { in: record, path: "a.b", value: x }
patch JSON Patch (RFC 6902) { in: record, ops: [...] }
coalesce Return in if not null, else default { in: any, default: any } -> any
typeof Return type name of a value { in: any } -> str

Predicate Functions

Function Purpose Key Args
eq Deep equality { a: val, b: val }bool
contains Substring / element / key check { in: str|list|record, value: val }bool
not Boolean negation { in: val }bool
and Logical AND { a: val, b: val }bool
or Logical OR { a: val, b: val }bool

Predicates use A0 truthiness: false, null, 0, and "" are falsy; everything else is truthy.

List Operations

Function Purpose Key Args
len Length of list, string, or record (key count) { in: list|str|rec } -> int
append Append element to list { in: list, value: any } -> list
concat Concatenate two lists { a: list, b: list } -> list
sort Sort list (natural order, by key, or multi-key) { in: list, by?: str|list } -> list
filter Keep elements by key truthiness or predicate fn (return { ok: expr }) { in: list, by?: str, fn?: str } -> list
find First element where key matches value { in: list, key: str, value: any } -> any|null
range Generate integer list { from: int, to: int } -> list
join Join list of strings { in: list, sep?: str } -> str
map Transform list via named function { in: list, fn: str } -> list
reduce Reduce list to single value via named function { in: list, fn: str, init: any } -> any
unique Remove duplicate values (deep equality) { in: list } -> list
pluck Extract a field from each record in a list { in: list, key: str } -> list
flat Flatten one level of nested lists { in: list } -> list

Math Operations

Function Purpose Key Args
math.max Maximum of numeric list { in: list } -> number
math.min Minimum of numeric list { in: list } -> number

String Operations

Function Purpose Key Args
str.concat Concatenate strings from a list { parts: list } -> str
str.split Split string by separator { in: str, sep: str } -> list
str.starts Starts-with check { in: str, value: str } -> bool
str.ends Ends-with check { in: str, value: str } -> bool
str.replace Replace substring { in: str, from: str, to: str } -> str
str.template Interpolate {var} placeholders in a string { in: str, vars: rec } -> str

Record Operations

Function Purpose Key Args
keys List of record keys { in: rec } -> list
values List of record values { in: rec } -> list
merge Shallow merge two records { a: rec, b: rec } -> rec
entries Convert record to list of { key, value } pairs { in: rec } -> list
let parsed = parse.json { in: raw_string }
let val = get { in: record, path: "nested.key[0]" }
let same = eq { a: 1, b: 1 }
let has_key = contains { in: record, value: "name" }
let count = len { in: items }
let nums = range { from: 1, to: 5 }
let sorted = sort { in: items, by: "name" }
let k = keys { in: record }
let full = str.concat { parts: ["hello", " ", "world"] }
let safe = coalesce { in: maybe_null, default: "fallback" }
let t = typeof { in: 42 }                        # "number"
let names = pluck { in: users, key: "name" }
let flat_list = flat { in: [[1, 2], [3]] }        # [1, 2, 3]
let pairs = entries { in: { a: 1 } }             # [{ key: "a", value: 1 }]
let path = str.template { in: "pkg/{name}", vars: { name: "core" } }

Evidence — assert & check

Both take { that: bool, msg: str } and produce evidence records in the trace.

  • assert — Fatal. Halts execution immediately on failure (exit 5, E_ASSERT). Use for invariants that MUST hold — the program cannot continue meaningfully if these fail.
  • check — Non-fatal. Records evidence (ok/fail) and continues execution. If ANY check fails, the program still completes but the runner returns exit 5 after execution finishes. Use for validations the agent should know about but that should not prevent the program from finishing.

Use predicate functions to produce meaningful boolean values:

let same = eq { a: actual, b: expected }
assert { that: same, msg: "values match" }

let has_name = contains { in: record, value: "name" }
check { that: has_name, msg: "record has name field" }

You can also use assert { that: true, msg: "..." } as an evidence marker to document that a step completed, or check { that: true, msg: "..." } to record non-fatal evidence.

Control Flow (v0.3)

if — Conditional expression

Record-style (simple value expressions) with lazy evaluation (only the taken branch evaluates):

let msg = if { cond: ok, then: "yes", else: "no" }

Block-style (statement bodies with return) — parenthesized condition, both branches required:

let result = if (ok) {
  let msg = "success"
  return { status: msg }
} else {
  let msg = "failure"
  return { status: msg }
}

Block if/else bodies work like fn or for bodies: they can contain let bindings, tool calls (call?/do), stdlib calls, and must end with return. Both the if and else branches are required. The condition uses A0 truthiness.

Uses A0 truthiness: false, null, 0, "" are falsy; everything else truthy.

for — List iteration

Iterates a list, producing a list of results. Each iteration runs in its own scope.

let results = for { in: items, as: "item" } {
  let processed = parse.json { in: item }
  return { data: processed }
}

Body must end with return. Budget-aware via maxIterations. The loop variable (item) is scoped to the body. Tool calls (call?/do) work inside for bodies.

map — Higher-order list transformation

Apply a user-defined function to every element of a list, collecting results. The function must be defined with fn before use and referenced by name as a string.

fn double { x } {
  return { val: x * 2 }
}
let doubled = map { in: [1, 2, 3], fn: "double" }
# doubled == [{ val: 2 }, { val: 4 }, { val: 6 }]

Multi-param functions work with record items — params are destructured from record keys:

fn fullName { first, last } {
  let name = str.concat { parts: [first, " ", last] }
  return { name: name }
}
let names = map { in: users, fn: "fullName" }

Budget-aware via maxIterations (shared counter with for, filter (fn:), and reduce). Errors propagate immediately — no partial results.

reduce — Accumulate a list to a single value

Apply a 2-parameter user-defined function to accumulate a list into a single value. The function receives (accumulator, item) for each element.

fn addScore { acc, item } {
  let newTotal = acc.val + item.score
  return { val: newTotal }
}
let result = reduce { in: items, fn: "addScore", init: { val: 0 } }
# result.val contains the sum

The callback must accept exactly 2 parameters. Budget-aware via maxIterations (shared counter with for, map, filter, loop).

filter block — Inline predicate filtering (v0.5)

Filter a list with an inline predicate body. More concise than filter { fn: ... } when the predicate is simple.

let positives = filter { in: nums, as: "x" } {
  return x > 0
}

The body runs once per element. If the return value is truthy, the original item is kept. Uses the same truthiness unwrapping as filter { fn: ... } — if a record is returned, checks the first value; if a bare value is returned, checks it directly.

Backward compatible: filter { in: list, by: "key" } and filter { in: list, fn: "pred" } still work (no block = old behavior).

Budget-aware via maxIterations (shared counter with for, map, reduce, loop).

loop — Iterative convergence (v0.5)

Run a body a fixed number of times, threading state through each iteration. The body receives the current value and returns the next value.

let result = loop { in: 0, times: 5, as: "x" } {
  return x + 1
}
# result == 5

Complex state with records:

let result = loop { in: { sum: 0, count: 0 }, times: 3, as: "acc" } {
  return { sum: acc.sum + acc.count + 1, count: acc.count + 1 }
}
  • in: — initial value (any type)
  • times: — number of iterations (must be a non-negative integer, else E_TYPE)
  • as: — binding name for current value in each iteration
  • 0 iterations returns the in: value unchanged

Budget-aware via maxIterations (shared counter with for, map, filter, reduce).

fn — User-defined functions

Define before use. Called with record-style arguments. Direct recursion allowed. Functions can access variables from their defining scope (closure).

fn greet { name } {
  return { greeting: "hello", who: name }
}
let result = greet { name: "world" }

Parameters are destructured from the caller's record. Missing params default to null. Body must end with return.

Closure example — a function capturing an outer variable:

let threshold = 18
fn isAdult { item } {
  return { ok: item.age >= threshold }
}
let adults = filter { in: users, fn: "isAdult" }

The function isAdult captures threshold from the scope where it was defined.

match — ok/err discrimination

Discriminates records with ok or err keys. Two forms:

# Match on a variable (identPath)
let output = match result {
  ok { val } {
    return { data: val }
  }
  err { e } {
    return { error: e }
  }
}

# Match on an expression (parenthesized)
let output = match ({ ok: 42 }) {
  ok { v } {
    return { value: v }
  }
  err { e } {
    return { error: e }
  }
}

Subject must be a record with ok or err key. When matching on a variable, use match ident { ... }. When matching on an expression, wrap it in parentheses: match ( expr ) { ... }. Both arms must end with return.

try/catch — Error recovery

Catch runtime errors (tool failures, type errors, stdlib errors, etc.) without halting the program. The catch binding receives a { code, message } record.

let result = try {
  call? fs.read { path: "maybe-missing.txt" } -> content
  let parsed = parse.json { in: content }
  return { data: parsed }
} catch { e } {
  return { error: e.code, detail: e.message }
}

The try body executes normally. If any statement throws, control transfers to the catch body. The binding { e } uses the same syntax as match arm bindings. Both bodies must end with return.

Caught errors include: E_TOOL, E_TOOL_ARGS, E_FN, E_TYPE, E_PATH, E_FOR_NOT_LIST, E_MATCH_NOT_RECORD, E_MATCH_NO_ARM, and other runtime errors. Note: E_ASSERT (fatal assertion) is NOT catchable -- it always halts the program.

Budget

Declare resource limits with budget { ... } at the top of the file (before or after cap). Exceeding a limit stops execution with E_BUDGET (exit 4).

budget { timeMs: 30000, maxToolCalls: 10, maxBytesWritten: 1048576, maxIterations: 100 }
Field Type Meaning
timeMs int Maximum wall-clock time in milliseconds
maxToolCalls int Maximum number of tool invocations
maxBytesWritten int Maximum bytes written via fs.write
maxIterations int Maximum for, map, filter, reduce, and loop iterations (cumulative)

Only declare budget fields the program needs. Unknown fields produce E_UNKNOWN_BUDGET at validation time.

Property Access

Dot notation on bound variables: response.body, result.exitCode, data.items.

Program Rules

  1. return <expr> is required and must be the last statement. Can return any expression (record, literal, variable, arithmetic, list, etc.).
  2. Variables must be bound with let or -> before use.
  3. No duplicate let bindings in the same scope.
  4. call? for read-mode tools only; do for effect-mode tools only.
  5. Tool and function args are always records { ... }.
  6. Reserved words cannot be variable names: cap, let, return, do, assert, check, true, false, null, import, as, budget, if, else, for, fn, match, try, catch, filter, loop.
  7. fn bodies, for bodies, match arms, filter block bodies, and loop bodies must each end with return.
  8. fn must be defined before use (no hoisting).

Common Mistakes

Avoid these frequent errors:

  • Forgetting returnE_NO_RETURN. Every program must end with return <expr>.
  • return not lastE_RETURN_NOT_LAST. No statements after return.
  • Using call? for an effect toolE_CALL_EFFECT. Use do for fs.write and sh.exec.
  • Using do for a read tool — allowed but unconventional. Prefer call? for fs.read and http.get to signal read-only intent.
  • Undeclared capabilityE_UNDECLARED_CAP. Declare the capability in cap { ... } for each tool used.
  • Capability denied by policyE_CAP_DENIED. Update the policy file or use --unsafe-allow-all.
  • Budget exceededE_BUDGET. Increase the budget limit or reduce resource usage.
  • Unknown budget fieldE_UNKNOWN_BUDGET. Valid fields: timeMs, maxToolCalls, maxBytesWritten, maxIterations.
  • Duplicate function nameE_FN_DUP. Each fn name must be unique.
  • map with non-list inE_TYPE. The in: value must be a list.
  • map with non-string fnE_TYPE. The fn: value must be a function name string.
  • map with unknown functionE_UNKNOWN_FN. The named function must be defined with fn before the map call.
  • reduce with non-2-param functionE_TYPE. The callback must accept exactly 2 parameters (accumulator, item).
  • reduce with unknown functionE_UNKNOWN_FN. The named function must be defined with fn before the reduce call.
  • filter with unknown functionE_UNKNOWN_FN. When using fn:, the named function must be defined with fn before the filter call.
  • filter with neither by nor fnE_FN. Must provide either by: (key name) or fn: (predicate function name).
  • filter with both by and fnE_FN. Provide exactly one of by: or fn:, not both.
  • filter block on non-listE_TYPE. The in: value must evaluate to a list.
  • loop with non-integer timesE_TYPE. The times: value must be a non-negative integer (floats like 2.5 also rejected).
  • loop with negative timesE_TYPE. Use 0 or positive integers.
  • for on non-listE_FOR_NOT_LIST. The in: value must evaluate to a list.
  • match on non-recordE_MATCH_NOT_RECORD. The subject must be a record with ok or err key.
  • match missing armE_MATCH_NO_ARM. Subject record must have ok or err key.
  • Type error in expressionE_TYPE. Arithmetic on non-numbers, division by zero, comparing incompatible types, mixed types with + (e.g., "hello" + 1), or spreading a non-record.
  • Missing else in block if → Parse error. Block if/else requires both branches.
  • Reusing a variable nameE_DUP_BINDING. Each let name must be unique.
  • Using a variable before bindingE_UNBOUND. Bind with let or -> first.
  • Positional arguments → Parse error. Always use record syntax { key: value }.

Best Practices

Prefer these patterns to produce shorter, more robust programs:

  1. Discover dynamically — Use fs.list to discover directories and files at runtime instead of hardcoding paths. Hardcoded paths break when the project structure changes and bloat the program with repetitive path literals. See Pattern 17.
  2. Inherit with coalesce — Use coalesce { in: val, default: fallback } for null-defaulting instead of manual if/else fallback chains. It's one line vs. five, and preserves falsy values (0, false, "").
  3. Compose with spread — Use { ...base, key: val } to extend records instead of manually repeating every field. This is especially valuable inside for loops when augmenting each item.
  4. Compute, don't enumerate — Derive values algorithmically with keys, filter, pluck, math.max, sort { by: [...] } (multi-key) instead of writing out each case. If you find yourself listing items one-by-one, there's usually a stdlib function that computes the answer.
  5. Define functions before usefn declarations are not hoisted. Define helper functions before the first filter { fn: "..." }, map { fn: "..." }, or reduce { fn: "..." } call that references them.

Idiomatic Patterns

For complete patterns (HTTP+transform, shell exec, validation, data pipelines), see references/patterns.md.

Minimal program (pure data)

let data = { name: "example", version: 1 }
return { result: data }

HTTP fetch + transform

cap { http.get: true, fs.write: true }
call? http.get { url: "https://api.example.com/data" } -> resp
let body = parse.json { in: resp.body }
let title = get { in: body, path: "title" }
do fs.write { path: "out.json", data: { title: title }, format: "json" } -> artifact
return { artifact: artifact }

Shell command

cap { sh.exec: true }
do sh.exec { cmd: "echo hello", timeoutMs: 5000 } -> result
assert { that: true, msg: "command succeeded" }
return { stdout: result.stdout, exitCode: result.exitCode }

CLI Quick Reference

a0 check file.a0                     # validate syntax + semantics
a0 run file.a0                       # execute (deny-by-default)
a0 run file.a0 --unsafe-allow-all    # allow all caps (dev only)
a0 run file.a0 --trace t.jsonl       # emit JSONL trace
a0 run file.a0 --pretty              # human-readable error output
a0 fmt file.a0                       # canonical format to stdout
a0 fmt file.a0 --write               # format in place

Exit codes: 0 ok, 2 parse error, 3 capability denied, 4 runtime error, 5 assert/check failed. assert = fatal (halts immediately), check = non-fatal (records evidence, continues; exit 5 after run if any check failed).

Additional Resources

Reference Files

  • references/tool-signatures.md — Full argument and return schemas for all built-in tools
  • references/patterns.md — Idiomatic program patterns with complete working examples

Example Files

Working .a0 programs in examples/:

  • examples/hello.a0 — Minimal pure-data program
  • examples/fetch-transform.a0 — HTTP fetch, parse, write pattern
  • examples/if-demo.a0 — Conditional branching with if
  • examples/for-demo.a0 — List iteration with for
  • examples/fn-demo.a0 — User-defined functions with fn
  • examples/match-demo.a0 — ok/err discrimination with match
  • examples/map-demo.a0 — Higher-order list transformation with map
  • examples/bare-return-demo.a0 — Returning bare values (non-record expressions)
  • examples/filter-block-demo.a0 — Inline filter block with predicate body
  • examples/loop-demo.a0 — Iterative convergence with loop
  • examples/list-packages.a0 — Dynamic package discovery with fs.list, for, sort, math.max, reduce
Install via CLI
npx skills add https://github.com/ThomasRohde/Agent0 --skill write-a0
Repository Details
star Stars 1
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator