Restrictions#
restrict limits what tools and agents may touch inside its block. Outside any restriction,
tools and agents are allowed by default. A blocked action raises restriction_violation.
| Clause | Allows |
|---|---|
restrict './out' or restrict fs './out' |
Files inside the folder only |
restrict net 'api.example.com' |
Only these hosts (*.example.com matches subdomains) |
restrict exec 'npm,git' |
Only these executables |
restrict mcp 'docs/search' |
Only these MCP servers (docs, docs/*) or tools (docs/search) |
Values are a comma-separated string or a list of strings. Combine clauses with commas; nested or repeated clauses of the same kind intersect, so a scope can only narrow.
restrict './src', exec 'git', net 'docs.example.com':
let review = agent 'Review the changes in git diff and check them against the docs'
What is checked:
- Files: paths are resolved against
--cwd, including..and symbolic links, before checking. - Network: every redirect hop is checked.
web_searchuseshtml.duckduckgo.com, so list that host to search insiderestrict net. - Commands: every executable in pipelines and chains, plus path-like arguments, URLs and redirections. Programs with their own I/O (build tools, package managers) are limited by name only; this is not an OS sandbox.
- MCP: tools outside the scope are hidden from the agent and rejected if requested. MCP servers themselves are trusted and not sandboxed.
Models are told the active restrictions, and the runtime enforces them on every call.
ask and think have no tools at all.
Assertions, fail and success#
let subtotal = 10
let total = 12
assert total >= subtotal 'total is smaller than subtotal'
if total > 1000:
fail 'needs manual approval'
success total
assert condition 'message'raisesassertion_failedwhen the condition is false.fail reasonraisesexplicit_failure.success valueends the document immediately, even from inside a function.returnexits a function, or the document at top level.
Failure payload#
Every failure has the same shape. Uncaught failures are printed to stdout and exit with code 1.
{
"status": "failure",
"reason": "lines[2].quantity: must be number",
"command": "let invoice = ask(schema=Invoice) prompt",
"line": 12,
"status_code": "validation_failed"
}
status_code |
Cause |
|---|---|
invalid_format |
Answer is not valid JSON of the requested fmt |
validation_failed |
Answer does not match the schema |
type_error |
Known typed violation before execution, or a runtime contract violation |
assertion_failed |
An assert was false |
explicit_failure |
fail was executed |
restriction_violation |
An action was blocked by restrict |
tool_error |
Missing file, HTTP failure or nonzero shell exit |
llm_error |
Authentication, timeout, transport or replay mismatch |
runtime_error |
Undefined name, wrong type, invalid modifier and similar bugs |
try / except#
let data = ''
try:
data = read_file('optional.txt')
except err:
log("{err.status_code} on line {err.line}: {err.reason}")
success data
The handler gets the failure payload as a dict. Failures do not roll back earlier writes. Syntax errors and statically known typed violations are reported before anything runs, so they cannot be caught. Runtime contract violations can be caught; failed typed bulk mutations leave the affected value unchanged.
Human in the loop#
human 'question' asks a person and returns their answer as a string. In normal CLI runs, the shell
prints the question on stderr and reads one line from stdin, preserving whitespace except the line
ending. Execution continues immediately without a suspension or repeating completed tool/model calls.
Use repeatable --human <answer> flags to supply exact answers in question order and skip prompting.
Remaining questions prompt normally, including with --silent; EOF without an answer fails the script.
Blockquote agents use the ask_human tool for the same workflow (@HUMAN). The answer is returned to the
same agent session, which may then make new model calls and save it to a shared variable. Runtime
actions completed before the question are not repeated, including actions inside invoked functions.
let feedback = human 'What should change in the release notes?'
assert len(feedback.strip()) > 0 'feedback must not be blank'
spl review.md # asks directly and continues
spl review.md --human "Make the wording clearer" # supplies the answer without prompting
Explicit checkpoint mode#
For noninteractive automation, --checkpoint still saves progress to a journal, prints the question
as JSON and exits with code 3. A later --resume invocation supplies the answer and continues without
repeating completed tool or model calls. These modes never read stdin and cannot use --human.
spl review.md --checkpoint run.jsonl --silent # exits 3 and prints the question
spl review.md --resume run.jsonl # shows the pending question again
spl review.md --resume run.jsonl --answer-for REQUEST_ID --answer-file feedback.txt
The pending question looks like this:
{
"status": "waiting_for_input",
"run_id": "run-identifier",
"request_id": "request-identifier",
"question": "What should change in the release notes?",
"checkpoint": "/abs/path/run.jsonl",
"line": 9
}
- Answer with the latest
request_id, not therun_id. Each new question gets a new request ID. --answer textand--answer-file fileare alternatives; files keep all whitespace.- Finished runs print
{"status": "success", "run_id": ..., "value": ...}or a failure envelope. - Exit codes:
0success,1failure,2usage or invalid answer,3waiting,70journal error.
Resume needs the same workspace, script, runtime, --model/--provider/--mcp options and environment. The
journal is not a snapshot: files changed in between stay changed. If a run was interrupted mid-action,
SPL refuses to guess and asks for manual recovery. Checkpoints cannot be combined with stores or
trace/replay. Journals contain prompts and answers, so keep them private.
In CI, treat exit code 3 as "waiting", publish the question to an authorized channel, and resume in the same persistent workspace once an answer arrives.