Shoal separates two related ideas:
- an outcome is the result of a command invocation, including status, output, and metadata;
- an error is a control-flow failure that stops evaluation unless caught.
A non-zero external status always exists in an outcome. Whether it becomes an error immediately depends on where the command appears.
Outcome anatomy🔗
let result = (^printf '{"answer":42}\n')
result.status # int or null
result.ok # bool
result.signal # signal name or null
result.dur # duration
result.pid # int
result.cmd # display form
result.stdout # bytes or lazy CAS-backed bytes
result.stderr # bytes
result.out # semantic payload on success
result.err # stderr bytes on success; failed payload access raisesRaw stdout is always retained up to the host’s capture/spill policy. out is the semantic payload: an adapter may supply a typed value, or raw output may become parsed JSON when it has a JSON object/array shape. Otherwise it is decoded text with one trailing newline removed.
On a failed outcome, .stdout and .stderr remain inspectable, while .out, .err, or forwarded semantic field access raises cmd_failed. Check .ok first when failure is expected.
let probe = (^sh -c 'echo detail >&2; exit 7')
if !probe.ok {
{ status: probe.status, stderr: probe.stderr.str() }
}Statement position versus value position🔗
^false # statement position: raises cmd_failed
let probe = (^false) # value position: binds a failed outcome
(^false).ok # value expression: falseParentheses are the clearest way to place a command inside an expression. This rule lets ordinary interactive failures stop the line while allowing probes, retries, fallbacks, and exact status propagation.
Most structured builtins also return successful outcomes so external and builtin transformations can chain similarly. Evaluator-native session operations such as pwd can return a bare value.
Forwarding through out🔗
Successful outcomes forward unknown fields and methods to their semantic payload:
(ls .).where(.type == "file").sort_by(.size)
(stat ./Cargo.toml).sizeThis is why builtins can feel like direct tables or records in a chain. Explicit .out remains useful at boundaries and is required before indexing:
let result = (some_json_command)
result.out[0]Forwarding never hides status fields such as .ok, .status, or .stderr.
Boolean chaining with outcomes🔗
&& and || accept booleans or outcomes. They short-circuit and return the operand that determined the result, unchanged.
(^test -f ./Cargo.toml) && "manifest exists"
(^git diff --quiet) || "working tree changed"
let final = (^step_one) && (^step_two)
final.statusCommand operands in a statement chain render each executed command once. In value position, the chain remains an inspectable result rather than narrowing everything to true/false.
Do not confuse ?? with error recovery. It only coalesces null:
config.get("port") ?? 8080Catch a block🔗
try evaluates a block and runs its handler only when the block raises:
let config_value = try {
json.parse(path("config.json").read)
} catch err {
{ failed: true, code: err.code, message: err.msg }
}The catch binder is optional:
try {
risky()
} catch {
null
}The handler has a child scope. An error handled there does not erase unrelated outer bindings.
Postfix recovery🔗
For a local fallback, use postfix catch:
let port = env.PORT.parse_int() catch 8080
let body = path("optional.json").read catch "{}"
let report = parse_report()
catch err { { code: err.code, message: err.msg } }The handler can be an expression or block, with an optional binder between catch and the handler. A following-line catch attaches as a continuation.
Error fields🔗
A caught error exposes:
| Field | Type | Meaning |
|---|---|---|
code | str | machine-oriented category |
msg | str | human-readable detail |
hint | str or null | suggested corrective action |
stderr | str or null | captured diagnostic text when attached |
status | int or null | originating external status when attached |
Source spans are used by host diagnostics and wire encodings where available, but the current in-language caught-error accessor does not expose a .span field.
Common currently emitted codes include:
| Family | Codes you will commonly handle |
|---|---|
| Syntax/binding | parse_error, undefined_var, arg_error, type_error |
| Data/access | field_missing, index_range, utf8_error, overflow, div_zero |
| Commands/host | not_found, cmd_failed, io_error, net_error, permission, timeout |
| Streams/channels | stream_consumed, stream_unbounded, channel_closed, feed_error |
| Reef | reef_unlocked, reef_drift, reef_conflict, reef_not_found, reef_provider |
| Control | assert_failed, recursion_limit, stale_undo |
This is an operational list, not a promise that no new code will be added during the preview. Branch on a code only when recovery genuinely differs; otherwise preserve the error or report its message and hint.
try {
path("settings.toml").read
} catch err {
match err.code {
"not_found" => ""
"permission" => { return { error: "cannot read settings" } }
_ => { return { error: err.msg } }
}
}Assertions🔗
assert(condition, message?) returns null when the condition succeeds and raises assert_failed otherwise.
assert(args.len() >= 1, "usage: deploy.shl ENV")
assert((^git diff --quiet), "working tree must be clean")Assertions are appropriate for preconditions and invariants. For expected user choices, return a structured result or catch a narrower operation instead.
Preserve an external status🔗
An uncaught statement-position external failure carries its status into the non-interactive host, which propagates statuses 1..=255; absent, signal-only, or out-of-range statuses fall back to 1. Capture and exit explicitly only when you need to inspect output or transform/control the code:
let result = (^tool --check)
if !result.ok {
echo (result.stderr.str())
exit (result.status)
}If a process died by signal, status may be null and signal contains its name. Choose an application-specific fallback exit code in that case.
Rendering and duplicate output🔗
An interactive external command can run through a real PTY and tee bytes to the terminal. The final renderer marks that outcome as already streamed so it does not print the same payload twice. Captured command values and builtins have not streamed their value, so rendering their outcome remains necessary.
Scripts and -c use captured/non-interactive behavior. Never parse the human outcome rendering; read typed fields.
Capture limits and content references🔗
Shoal bounds resident process capture. In journal-enabled hosts, oversized stdout can spill to content-addressed storage, and .stdout becomes a lazy ref-backed bytes value with the correct total length. Data sinks such as redirects and save operations load the full content rather than writing only the resident preview.
Defaults and environment overrides are documented in Current status and limits. Agent wire responses have a separate 64 KiB rendering cap and return session/content references for larger values; see Agents, kernel, and MCP.
Recovery design checklist🔗
When a command may legitimately fail:
- Put it in value position.
- Inspect
.okbefore touching.out. - Preserve
.status,.signal, and.stderrin diagnostics. - Use
&&/||only when their returned-operand semantics are useful. - Catch evaluator errors narrowly; do not wrap an entire script when only parsing may fail.
- Propagate exact external status explicitly when another program depends on it.