Reference
Errors & exit codes
Press Run to see a caught error; edit it (remove the try/recover) to see an uncaught Runtime error: …. The tests below it pin the messages of the errors you are most likely to meet.
-- Doc example: how errors surface — raise, try/recover, and a clean message.
-- Edit this and press Run: remove the try/recover to see an uncaught
-- "Runtime error: x must be >= 0 …" instead.
intent: "doc example: errors"
task risky(x)
when x < 0
raise("x must be >= 0, got " + text(x))
give x * 2
task fails()
give risky(-1)
-- The message of the error `f` raises, or nothing when it does not raise.
task error_of(f)
try
f()
recover err
give err
give nothing
task set_out_of_range()
let xs be [1]
set xs[10] to 1
-- run shows the recovery in action:
try
print("risky(5) = " + text(risky(5)))
print("risky(-3) = " + text(risky(-3))) -- this one raises
recover err
print("caught → " + err)
test "raise fails; try/recover catches the message"
assert_eq(risky(5), 10)
let caught be ""
try
let bad be risky(-1)
recover err
set caught to err
assert(contains(caught, "must be >= 0"))
assert_error(fails)
assert_eq(error_of(() => risky(1)), nothing)
test "Python reflexes get the Synsema form in the message"
let xs be [1]
assert(contains(error_of(() => None), "in Synsema: nothing"))
assert(contains(error_of(() => len(xs)), "in Synsema: length(x)"))
assert(contains(error_of(() => xs.append(2)), "Synsema has no methods: set xs to append(xs, item)"))
assert(contains(error_of(() => round(1.234, 2)), "round_to(x, n)"))
assert(contains(error_of(() => "%d" % 5), "there is no %-formatting"))
test "calls: arity and named arguments"
assert(contains(error_of(() => risky()), "task 'risky' is missing argument 'x'"))
assert(contains(error_of(() => risky(1, 2)), "task 'risky' takes 1 argument, got 2"))
assert(contains(error_of(() => upper("a", "b")), "upper() takes at most 1 argument, got 2"))
assert(contains(error_of(() => upper(s = "a")), "does not accept named arguments"))
test "type: values do not convert behind your back"
assert(contains(error_of(() => "id=" + nothing), "Cannot add text and nothing — convert it on purpose"))
assert(contains(error_of(() => fmt("{a}", {})), "fmt: no value for {a}"))
assert(contains(error_of(() => int("ff", 16)), "int(x, base = 16)"))
assert(contains(error_of(() => int("ff", 2.0)), "int(x, base = 2)"))
assert(contains(error_of(() => int(1.5)), "not a whole number"))
assert(contains(error_of(() => number("123456789012345678901")), "Use int(x)"))
assert(contains(error_of(() => bytes(33)), "int_to_bytes(33, size)"))
assert(contains(error_of(() => 1d == 1.0), "cannot mix decimal and float"))
assert(contains(error_of(() => length(array(5))), "use size(a)"))
assert(contains(error_of(() => json_encode({"f": (x) => x})), "the value[\"f\"] is a task, not data"))
test "data: missing keys, indexes out of range or not integers, invalid JSON"
assert(contains(error_of(() => {"a": 1}["b"]), "Map has no key 'b'"))
assert(contains(error_of(() => [1][5]), "Index 5 out of bounds (list length 1)"))
assert(contains(error_of(set_out_of_range), "Index 10 out of bounds (list length 1)"))
assert(contains(error_of(() => [1, 2][1.5]), "index must be an integer, got 1.5"))
assert(contains(error_of(() => json_decode("{")), "invalid JSON"))
test "total forms return a fallback instead of raising"
assert_eq(json_decode("{", nothing), nothing)
assert_eq(int("abc", fallback = nothing), nothing)
assert_eq(number("abc", nothing), nothing)
assert_eq(number("2.5", nothing), 2.5)Raising & catching§
raise("message") -- fail deliberately (or re-propagate inside recover)
try
risky()
recover err
log "failed: " + err -- err is the message (text)
raise(err) -- RE-PROPAGATE; without it, recover swallows the error
give and stop are not errors — they pass through try/recover. (fail(...) builds an HTTP response, it does not raise.) Test that something raises with assert_error(task). The statement form raise "msg" / raise err (no parens) works too, and a bare raise alone is a parse error with the fix in the message. ⚠️ On the published v0.5.1 and older, raise "msg" without parens was two inert expressions — a silent no-op; on those binaries always raise("msg").
A builtin that can fail on untrusted input often has a total form that returns a fallback instead of raising: json_decode(text, nothing), int(text, fallback = nothing), number(text, nothing).
Errors that name the fix§
A message says what to write, not only what went wrong. The reflexes of other languages are recognized at load time (so the program never starts half-wrong) and answered with the Synsema form:
| You wrote | The error says |
|---|---|
x = 5 | `declare with let x be …, change it with set x to … ` |
x += 1 | `Synsema has no compound assignment (+=): write set x to x + 1` |
return v / def f(x): | `return is not a Synsema statement: give <value> returns from a task` / def is not a Synsema statement: a function is task name(params) …`` |
if / elif / else | `if is not a Synsema statement: a condition is when <condition> …`, chain with otherwise when <condition> , the fallback branch is otherwise ` |
when x > 0: | Synsema blocks have no colon |
break / continue / pass | `leave a loop with stop / skip to the next item by wrapping the rest of the body in when / an empty block is not needed` |
except / finally | `after the try block write recover err / there is no finally: put the cleanup after the try/recover block` |
import json / class P: | `import a local module with use "./file.syn" as name / a record type is type Name with typed fields` |
a if c else b / c ? a : b | `the inline form is when c then a otherwise b ` |
&& / ; | `the logical operators are and / or / one statement per line (no ;)` |
f"…" / lambda y: y / [x for x in xs] / xs[1:3] | the backtick template / a lambda is (y) => y / no comprehensions: apply(…) … where(…) / no slice syntax: slice(xs, start, end?) |
// note (a C-style comment) | `comments in Synsema start with -- (//` is integer division) |
5--1 | ` -- right after a value: … write 5 - -1, or 5 -- comment ` |
And at run time: None, len, str, null, dropna… → `Undefined variable: 'None' — in Synsema: nothing; xs.append(2) → Synsema has no methods: set xs to append(xs, item); d.get("a") → … get(m, key, default); round(x, 2) → … round_to(x, n); "%d" % x → there is no %-formatting: use a template`.
Since engine v0.6.46: log(x) → `Undefined variable: 'log' — in Synsema: ln(x) for the natural log, log10(x), log2(x) or log_base(x, b); log "message" (no parentheses) writes a log line (synsema check warns it too); in_units(90, "minutes") → in_units() takes a duration, got number — make one first: in_units(duration(seconds = 90), "minutes"); a named argument that does not exist → unknown parameter 'limt' — did you mean 'limit'?; the parameters are: limit, offset (tasks, lambdas and builtins; found when the call runs, not by check — before, a misspelled name on a task said that the right one was missing); enumerate(array(5)) → enumerate() of a 0-d array: it is a scalar and has no rows — use to_list(a) or the value itself; parquet_scan(bytes) → parquet_scan: expected a path, got bytes — for bytes already in memory, parquet_read(bytes)`.
synsema check (engine v0.6.45+) catches the call before it runs: a call to a name that exists nowhere in the file — not a builtin, not defined, not used as a value — is the warning `p.syn:3: warning: len(...) — there is no task, variable or builtin named 'len' in this file; it fails when that line runs — did you mean length(x)? (without(xs, k) with no task without` gets the same warning without the hint). A warning does not change the exit code. The other warnings are listed in CLI.
The LLM words reason/decide/analyze/generate and the other keywords (to, type, match…) cannot name what you bind: task f(reason) → 'reason' is a reserved word in Synsema; choose another name for the parameter name. Rename (resolve, why). After a . any word is a member name, so m.decide and tx.to are fine.
Exit codes§
| Exit | When |
|---|---|
0 | success |
1 | parse error, runtime error, or any spawned agent ended in ERROR |
2 | usage error — a missing argument, synsema openapi without serve, or an unknown --flag (rejected, not ignored, on run/test/conform) |
Plain run prints a stable one-liner: Runtime error: <file>:<line>:<col>: <msg>. Measuring it in a shell? Redirect (… >/dev/null; echo $?) — don't pipe before echo $?, or you read the pipe's code.
internal error in the engine (a bug, not your program — please report it): … means the engine itself failed; there is nothing to change in your code — report it with the program that triggers it.
Rich diagnostics (--explain)§
synsema run --explain program.syn # human-readable, on stderr
synsema run --explain --format json program.syn # structured, for tools/agents
The report adds: source context, call stack, visible variables, the program intent, a classification (data / io / logic / capability / type), whether it's recoverable, and fix suggestions. The exit code is unchanged either way.
Common error shapes§
- Capability:
Capability not granted: net("…")— add the matchingrequire(or widen the scope). If it says declared but above the host ceiling, therequireis already there: only the host (--sandbox/--cap-set/the embedder's ceiling) can allow it.Capability not granted: file_read("…")from arendermeans a disk template read wasn't declared — addrequire file.read("…")(or bundle the template withsynsema build). - Pure profile:
<name>: not available in the pure profile — this run has no filesystem(and likewise no child processes / sockets / database drivers / scheduler threads) — the builtin is walled off by--profile pure; drop the flag to run it natively. See Sandboxing. run_program:above parent ceiling/above parent profilein the child's audit (a child asked for more than the parent lends — trimmed, not fatal);run_program: timed out after Ns(timed_out: true);run_program: max depth N exceeded;run_program: env "X" is a secret — reveal() it explicitly.synsema build:bundle corrupt (sha256 mismatch)(a tampered built binary refuses to run);"<path>" is part of the bundle (read-only)(a write to a bundled asset);this is a built program (synsema build); rebuild it…(--engine updateon a built binary); at build time,ausewith a dynamic path cannot be bundledand'…' escapes the bundle root.- Labels (v0.6.24+, under
--labels/serve --attested— see Labels):label_violation: <path> is private to <principal>, the sink accepts (public)— a private value reached a public sink;declassifyit with a reason, or keep it inside.label_violation: print called under private control flow— the branch is the secret, so move the output out of it.private: labels are off; run with --labels or serve --attested— the program needs the second wall and it is not on. Two things to know: an error caused by private data is not catchable (try/recoverre-propagates it — use the total variants), and a label violation ends the wholesynsema test --labelsrun rather than failing one block. - Attestation (v0.6.24+ — see Attestation):
attestation_verify: opts.now is required (unix seconds);attestation_document: this server is not attested (start it withsynsema serve --attested);reveal: secret(attestation_key) is sealed; and, with no platform,run --attestfails closed — no artefact is ever published on a doubt. - Calls:
task 'f' is missing argument 'b' — pass it, or give the parameter a default;task 'f' takes 1 argument, got 2;upper() takes at most 1 argument, got 2;upper() does not accept named arguments (got s = …); pass it by position. - Type: an operation got the wrong type, and nothing converts behind your back —
Cannot add text and nothing — convert it on purpose: text(x), or interpolate it;fmt: no value for {a};int(x, 16): the second argument is the fallback value, not the base — for base 16 write int(x, base = 16)…(also for2.0);int(1.5): not a whole number;number("1234…") would lose digits … Use int(x);bytes(33)→ useint_to_bytes(33, size)orbytes([33]);cannot mix decimal and float;length: a 0-d array has no length (it is a scalar) — use size(a);json_encode: the value["f"] is a task, not data;as_secret(123)→ "expects text or bytes". - Data: a missing map key (
Map has no key 'b'— orget(m, "b", default)), an out-of-range index, read or written (Index 10 out of bounds (list length 1)), a non-integer index (index must be an integer, got 1.5), invalid JSON injson_decode. - Files (engine v0.6.45+):
read_file,read_file_bytesandedit_filesay why they cannot read.File not found: <path>is only for what does not exist; a file that exists givesCannot read file <path>: <why>—it is a directory (use list_dir),it is not valid UTF-8 text (use read_file_bytes),it is a broken link (it points to something that does not exist),permission denied, or on Windowsin use by another process (it is open without sharing; retry later)(the one worth retrying). Before, every case wasFile not found.
- Load errors (engine v0.6.46+, the program does not start): `
unknown capabilitynettinrequire— did you meanrequire net?; a name that does not exist grants nothing. Known: net, file, …— fix the name (also a dotted one,require file.raed(...), and inside a task or an agent).'declassify' is read as a value here (an alias, a list or map entry, an argument): it can only be called by its name,declassify(…), so that every call is in the review (synsema code check)— call it asdeclassify(x, "why"); the same forprivate, and a bare map key with one of these names ({private: 1}) has to be quoted,{"private": 1}.unknown type 'int' for parameter 'a': the parameter types are text, number, bool, list or mapandthe default of 'a: number' is a text` — use one of the five types, and a default of that type. - Labels, v0.6.46 (see Labels): `
label_violation: declassify is not granted for what this value is private to (clientes). With a ceiling the host names what a program may publish (--cap-set declassify=<principal>), and inside asandboxonly asandbox underthat delegatesdeclassifyallows it— the host addsdeclassify=<principal>to its ceiling (or delegates it withsandbox under {"declassify": …}); like every label violation it cannot be caught, and the message names the declared set, not the value's real label.label_violation: reason would send data private to (customers) to the model; the host allows that per principal and provider in its ceiling (--cap-set llm=<principal>@<provider host>,@localfor a model in the process,@embedderin wasm) and the program declaresrequire llm("<principal>"). Otherwise declassify(…) the part the model may see— the same foranalyze/decide/generate/llm_step/llm_stream, and withjudge=/require judge(...)for ajudgeblock.label_violation: read_line prompt under private control flow (pc = [a]); …` — a private prompt, or a prompt inside a branch decided by a private value: move it out of the branch, or declassify it (the same message today covers a private prompt outside a branch). - Tool calls with typed parameters (engine v0.6.46+, see Tool calling): `
call_tool(add): 'a' must be number, got text — a tool call fromllm_stepbrings typed arguments only for the parameters the catalog types (tool_spec(task, describe)takes them from the signature)— build the catalog withtool_spec, or convert inside the task;call_tool(add): missing 'a' (number)— pass it, or give the parameter a default.call/call_tool` check the types strictly and convert nothing; a normal call does not check them. - Trusted modules (engine v0.6.46+, see Modules § trusted): `
no trusted module named 'nope': the host loads it (synsema run --trusted nope=./file.syn, ortrusted: { nope: source }in wasm); a program cannot load one itself— the host passes--trusted; a task, a lambda or a builtin passed to a trusted tool, a named argument, a top-levelrequirein the trusted module,--trustedunderserve, and thea trusted tool cannot … yet` family each name what to do instead. - Secrets, v0.6.46 (see Secrets): `
json_encode: the value["auth"]["key"] is secret(K), and a secret is never written as text — pass the value to the request itself (in a header; in the body or the URL once it is bound:require secret("K") for net("host")) and the engine writes it at the socket, or leave it out of what you encode(alsojsonl_encode,json_for_script, andcsv_encodewithrow N, column "c") — before, the secret came out as"[redacted]";a secret inside a map cannot go into a text hole (secret(K) would travel as its placeholder): take it out on its own (creds.k) or use bearer(...)/basic(...). When the host binds a secret (--cap-set secret=K@host):secret("K") not permitted: the host binds K to api.example.com; writerequire secret("K") for net("api.example.com")`. - Sealed receipts (engine v0.6.46+, see Observability § Sealed receipts):
--auditor: the auditor key must be an X25519 public key of 32 bytes, got Nand… cannot seal (a low-order X25519 point…)— pass thepublicofsynsema receipt keygen;--receipt needs an auditor…— add--auditororSYNSEMA_AUDITOR_KEY;SYNSEMA_RECEIPT_SEALED_BYTES must be between 4096 and 16777216, got N; `serve --auditor: routeGET /xis a stream route: its body leaves while it runs…— serve it from a server without an auditor;--replay: f is not an opened receipt…— give it the output ofreceipt open;replay: input #N differs from the recorded run — …— the input changed since the recorded run;the receipt's signature does not verify (…)(the receipt was altered),the chain does not verify: the entries are not the ones the run signed(entries missing or reordered),the envelope does not open with this key (…)(wrong key, or the header was touched),<file> has 2 sealed receipts (lines 1, 2): pick one with --line N`. - wasm host hooks (engine v0.6.46+): a host
kvthat throws or answers something else is an error of the builtin (state_get: host kv.get failed: …,kv.get must return a string or null, got a number,kv.list must return a list of strings, got …), not an absent key — the full table and how to migrate: WebAssembly § What a hook must answer. - Network, v0.6.46 (see HTTP client and Capabilities): `
http_get:127.0.0.1.nip.ioresolved to 127.0.0.1, a private address; anetgrant with a wildcard reaches only public addresses. Name it:require net("127.0.0.1.nip.io:47290"), its address or its CIDR (the scope can come from the configuration:require net(env("…_URL")))— the HTTP builtins return it inerror(withstatus: 0) like any transport failure;tcp_connect/ws_connectraise it;proxy toanswers the client502 proxy error: upstream not allowed` and logs the reason. Write the line it names. - Bound secrets (engine v0.6.46+, see Secrets): `
http_get: the URL is secret(K), and an unbound secret only goes in a header. If the API takes it in the URL, bind it to the host it goes to:require secret("K") for net("api.example.com")— write that line;secret(K) is bound to [api.example.com]; this request goes to other.example.com — it never goes to another host, not even in a header— send it only to a host of its binding;secret(K) only travels over https; this request to api.example.com is http://— use https (http and ws only to127.0.0.1,localhost,::1);the URL is secret(K) and its host is not written out before the secret; write the scheme and host first (https://host/…{key}): the host of a request never comes from a secret;require secret("K") for net(...): binding a secret to every host is not binding it: name the host it goes to(a load error, also for*.com`).