Agent shell

Run shell commands over your app's files, with pipes and heredocs.

Docs menu

substrio_shell supports commands such as grep -rn and sed -n 10,40p. It is the source.shell operation (POST /v1/_source/shell, MCP tool substrio_shell).

There is no process or access to the host filesystem. The server interprets a POSIX shell subset, and each command is a builtin operating on the app's stores. Paths cannot use .. to leave a mount. Symlinks, devices, and network access are unavailable.

Mounts

Path What it is Writable
/src The branch's tree (branch, default main): the same files source.tree lists yes: every change of one call lands as one commit
/site The site/ folder of the version live runs no: SHELL_READ_ONLY
/files The app's stored files (file.list) yes: each write is a file.upload; rm is refused (SHELL_FILES_DELETE), file.delete does it

A command starts in /src (or cwd). Any other path, / itself included, is refused with SHELL_PATH_OUTSIDE.

One call, one commit

  • Reads see the stores as they are, and every write of the call is staged: a later command in the same call reads what an earlier one wrote (sed -i ... && grep ... sees the edit).
  • When the whole command exits with status 0, every /src change is committed together on the branch. The commit message comes from message, commit -m, or the command itself, in that order. Each /files write is then stored.
  • Any other exit status writes nothing. A refusal ends the call immediately and writes nothing.
  • A command that changes nothing (a sed -i that matched nothing) makes no commit.
  • Nothing is kept between calls: no variables, no working directory, no history. Each call stands alone.

The call

{"command": "grep -rn 'TODO' /src", "branch": "main", "cwd": "/src", "offset": 0, "message": "..."}
Field Meaning
command The command line (required).
branch The branch /src is (default main; main before its first commit is an empty /src, made by the first commit). Any other branch is made by branch.create.
cwd Where the command starts (default /src).
offset Continue a cut output from this byte (the last answer's next_offset). Refused for a command that writes (SHELL_OFFSET_WRITES): running it again would write twice.
message The commit's message.
expected The version the branch must still be at (64 hex); 409 when it moved.

The answer

{"stdout": "...", "stderr": "", "exit_code": 0, "offset": 0, "truncated": false, "next_offset": 812,
 "commit": {"branch": "main", "version": "<64 hex>", "number": 14, "changed": ["src/a.js", "src/lib/b.js"]},
 "files": ["exports/report.csv"]}

commit is present only when the call committed; files appears only when it stored files. exit_code is the last pipeline's status, as in a shell. For example, grep's status 1 means no match and is not a refusal.

When output exceeds the budget, the response contains the first part, truncated: true, and next_offset. Call again with offset: next_offset for the next part. stderr_truncated: true indicates stderr was cut at the same budget.

Budgets

Each is a server setting:

Flag Default Past it
--shell-cpu-ms 2000 SHELL_CPU_BUDGET: the call ends and lands nothing (a js script shares the same budget)
--shell-output-bytes 1048576 the answer is cut: truncated and next_offset
--shell-pipe-bytes 67108864 SHELL_PIPE_BUDGET: one stage wrote more than this into the next

Who may run it

source.shell is a read operation. Anyone allowed to read the app's source can run it, including a caller with a read-only grant. Each write is checked against its corresponding operation: source.edit for /src, or file.upload for /files. A caller without the required grant receives SHELL_WRITE_NOT_GRANTED at the first write, and nothing is written.

Grammar

  • Pipes |, lists ;, &&, ||, and ! before a pipeline.
  • Redirections >, >>, <, 2>, 2>&1, >&2, &>, here-strings <<< and heredocs <<EOF (expanded) and <<'EOF' (literal), <<- strips leading tabs.
  • Single quotes, double quotes, backslash escapes.
  • Globs *, ? and [...] on unquoted words (a name starting with . is matched only by a pattern that starts with .).
  • Variables, a fixed set: $PWD, $HOME (/src), $SRC, $SITE, $FILES, $BRANCH, $?. Any other is SHELL_UNKNOWN_VARIABLE.
  • Not in the grammar (SHELL_SYNTAX): $(...) and backticks, subshells ( ), if, for, while, case, functions, NAME=value assignments, background &.

Builtins

Command Options
grep -r -R -n -l -L -c -i -v -w -x -o -q -s -H -h -a -E -F -G -e PAT -m N -A N -B N -C N --include=GLOB --exclude=GLOB --exclude-dir=GLOB --color=never. A binary file answers Binary file X matches.
sed -n -E -r -s -i[SUFFIX] -e SCRIPT -f FILE; addresses N, $, /re/, \cREc, first~step, a,b, a,+N, !; commands s/// (flags g p N i, & and \1..\9), p P d D q Q = a i c y n N h H g G x { }. -i edits in place in the call (no backup file is written).
find -name -iname -path -ipath -wholename -type f/d -maxdepth -mindepth -empty -true -false -print -delete -exec CMD {} ; -exec CMD {} +, !, -a, -o, ( ).
xargs -n N -I REPL -0 -d DELIM -r.
cat -n.
head, tail -n N, -c N, -N, +N (tail), -q.
wc -l -w -c -m.
sort -r -n -u -f -b -s -k KEYDEF -t SEP -o FILE.
uniq -c -d -u -i.
cut -d DELIM -f LIST -c LIST -b LIST -s.
tr ranges, classes ([:upper:] and the like), escapes; -d -s -c.
diff unified output, -u, -U N, -q.
ls -a -l -R -d -1 -F.
cd, pwd within the mounts.
mv, cp -r; an unchanged file is moved or copied as its stored object (0 bytes copied).
rm -r -f; never a mount itself; never under /files.
mkdir, touch, tee mkdir -p, tee -a.
echo, printf echo -n -e; printf with %s %b %c %d %i %u %x %X %o, widths and precisions, the format reused for extra arguments.
test, [ -e -f -d -s -z -n -r -w -x, = == !=, -eq -ne -lt -le -gt -ge.
basename, dirname, true, false
commit -m MESSAGE names the call's commit.
js a script in the app's own runtime (below).

An unknown command is refused with SHELL_UNKNOWN_COMMAND, whose detail lists the builtins.

js

js 'SCRIPT' [ARGS], js -e 'SCRIPT', js FILE [ARGS], or js <<'EOF' ... EOF (the heredoc is the script). The script runs in the app's own JavaScript runtime, as a function body with these in scope:

Name What it is
fs readFile(path), writeFile(path, data), appendFile, readdir(path), stat(path) (size, isFile(), isDirectory()), exists(path), rm(path), rename(from, to), mkdir(path), each also as ...Sync. Paths are the shell's: relative to the cwd, under the three mounts, with the same refusals. Writes are the call's staged writes.
require require('fs') (or 'node:fs') is fs.
console log and info write stdout, error and warn stderr.
input the command's standard input as a string.
args the arguments after the script.

A value the script returns is printed. An uncaught error is status 1 with the error on stderr. A refusal inside fs (a path outside the mounts, a write the grant does not reach) refuses the whole call even when the script catches it.

Refusals

Code HTTP When
SHELL_SYNTAX 400 the command is not in the grammar above
SHELL_UNKNOWN_COMMAND 400 not a builtin (the detail lists them)
SHELL_UNKNOWN_VARIABLE 400 a variable outside the fixed set
SHELL_PATH_OUTSIDE 400 a path under no mount, .. past a mount, a mount itself removed
SHELL_READ_ONLY 400 a write to /site
SHELL_WRITE_NOT_GRANTED 403 a write the caller's grant does not reach
SHELL_FILES_DELETE 403 rm (or mv out of) /files: use file.delete
SHELL_OFFSET_WRITES 400 offset on a command that writes
SHELL_CPU_BUDGET 408 past --shell-cpu-ms
SHELL_PIPE_BUDGET 413 one stage past --shell-pipe-bytes

Every refusal carries field: "command" and a detail naming the word, path or budget. Nothing lands on any refusal.

What it costs

Per call: one parse of the command, one head read and one read of what live runs. Each directory is read once per call and each file once (later reads in the call are a hash probe), and every builtin works on a view of those bytes: nothing is copied until the answer is written. A pipe hands one buffer to the next stage (two buffers, reused). A write stages one copy of the new bytes (none for mv or cp of an unchanged file). The commit is one History::commit of every changed unit. A js script takes the app's runtime once per script.

Examples

Find every TODO with line numbers:

{"name": "substrio_shell", "arguments": {"command": "grep -rn TODO /src"}}

Read lines 10 to 40 of a file:

{"name": "substrio_shell", "arguments": {"command": "sed -n 10,40p functions/orders.js"}}

Rename a function across every file, one commit:

{"name": "substrio_shell", "arguments": {"command": "grep -rl getOrders functions | xargs sed -i 's/getOrders/listOrders/g'"}}

Write a new page from a heredoc:

{"name": "substrio_shell", "arguments": {"command": "cat > site/about.html <<'EOF'\n<h1>About</h1>\n<p>We make todo lists.</p>\nEOF"}}

Transform a file with a script:

{"name": "substrio_shell", "arguments": {"command": "js 'const rows = JSON.parse(fs.readFile(\"data/todos.json\")); fs.writeFile(\"data/todos.json\", JSON.stringify(rows.filter(r => !r.done), null, 2)); rows.length'"}}

Compare the source with what is live:

{"name": "substrio_shell", "arguments": {"command": "diff -u /site/index.html site/index.html"}}

Page through a long output:

{"name": "substrio_shell", "arguments": {"command": "cat data/big.csv", "offset": 1048576}}