If you read a leaked Claude system prompt, an OpenAI tool spec, or a high-performing agent transcript, you’ll notice the same pattern: the model is fluent in Unix. Read file.md. Grep TODO src/. cmd --flag value | jq ... > out.

That’s not an accident. It’s the densest token-efficient idiom in the training data. Frontier LLMs have ingested a giant corpus of bash-shaped commands and they reach for them naturally. An agent SDK that hands the model JSON-RPC tool calls is asking it to translate out of its first language every turn.

agentlib’s Sh mini-shell hands the model that idiom directly — sandbox-safely.

What Sh is

Sh is a strict, sandboxed Unix shell that lives inside the Dart process. It parses:

  • Pipes (|) — connect one command’s stdout to the next’s stdin.
  • Redirects (>, >>) — write stdout to a VFS path.
  • Variables ($VAR, $1, $@) — set via RunConfig.env or the set builtin.
  • Command substitution ($(cmd)) — splice a command’s stdout into another command.
  • Quoting (single and double, escapes) — same as POSIX shell.

It does not parse:

  • exec — no real binaries are spawned.
  • & — no backgrounded subprocesses.
  • eval — no dynamic shell construction.
  • source — no script inclusion.
  • function definitions — no shell-defined macros.

Every “command” Sh understands is a registered Dart function in the /bin VFS mount. Pipes connect streams in memory. Redirects write to VFS paths. There’s no escape to the host filesystem, no arbitrary code execution, no syscall surface beyond what your registered CLIs explicitly expose.

What it unlocks

Compare an agent SDK that hands the model JSON-RPC tools:

// Turn 1: fetch contacts
{ "tool": "contacts.grep", "args": { "pattern": "Mom" } }
// Turn 2: extract number
{ "tool": "json.extract", "args": { "field": ".number" } }
// Turn 3: send message
{ "tool": "whatsapp.send", "args": { "contact": "...", "message": "Late" } }

That’s three round trips, three tool calls, three opportunities for the model to lose state.

With Sh, the same intent is one model turn:

Sh("contacts grep 'Mom' | jq -r .number | xargs whatsapp send --message 'Late'")

The shell pipeline executes atomically. The hooks (PreToolUse, PostToolUse) still fire on each underlying CLI, so audit logs and consent sheets work the same way. But the model emits one tool call, not three.

How it composes with hooks

Sh isn’t a bypass of the hook system. The execution path is:

  1. Model emits Sh("...") tool call.
  2. OnShellParse hook fires with the parsed AST. App code can inspect, mutate, or deny.
  3. For each command in the pipeline, the normal tool dispatch path runs: PreToolUsePermissionRequestHook (if the command declares capabilities) → tool exec → PostToolUse.
  4. Streams connect: command N’s stdout feeds command N+1’s stdin.
  5. Final stdout is returned to the model as ToolResult.ok.

So if whatsapp.send is highImpact: true, agentlib still snapshots before it runs. If contacts.grep requires the contacts permission, the PermissionRequestHook still surfaces. The pipeline is sugar; the safety is not.

Why frontier LLMs are good at this

Two reasons.

First, training distribution. The shape cmd --flag value | jq -r .field | xargs cmd2 --arg shows up everywhere in GitHub, in Stack Overflow, in shell tutorials, in CI scripts. Models have ingested a lot of it. They reach for grep, jq, xargs, head, tail without prompting.

Second, density. A three-step pipeline is one short string. The same intent expressed as JSON-RPC calls is three nested objects, with field names that have to be remembered and called precisely. Less room for the model to drift.

The builtins

agentlib ships twelve POSIX-ish builtins out of the box:

BuiltinPurpose
catconcatenate files
echoemit text
headfirst N lines
taillast N lines
cutextract columns by delimiter
sortsort lines
uniqde-duplicate adjacent lines
xargsturn stdin into args for the next command
jqJSON query (the inline subset most agents use)
teststring / numeric tests for conditionals
lslist directory contents
wccounts

That’s enough to express most of what an agent needs to do with text. For the rest, the model emits a direct tool call.

First-party CLIs

On top of the builtins, agentlib ships CLIs that drive the device:

CLISub-commands
fsread, write, edit, grep, glob, ls
whatsappsend, list, read
gmailsend, list, read, search
calendarlist, create, update, delete
browseropen, tabs, screenshot, dom
phonecall, sms
notificationslist, dismiss
visiondescribe, ocr, detect
taskdispatch, await, list
skillinvoke, list

Each one is a registered CLI in the /bin mount. Each one is sandbox-safe. Each one composes in Sh pipelines.

When not to use Sh

Sh is excellent for composition. It’s overkill for one-shot tool calls. If the model needs to call one tool, it can still emit a direct JSON-RPC tool call — there’s no penalty. agentlib’s prompt template encourages Sh for two-or-more step pipelines and direct tool calls for one-shot work.

Snapshots are the natural companion to Sh — when a pipeline includes a destructive CLI like whatsapp.send, you want a snapshot before the dispatch so the user can undo. Read snapshots, revert, fork next.

Or jump to /install and try a pipeline yourself. The five-line example below works as soon as you wire a Sh and a whatsappCli() into your agent.

final bin = BinMount()
  ..register(fsCli())
  ..register(whatsappCli());
final vfs = Vfs()..mount('/workspace', WorkspaceMount(docsDir))..mount('/bin', bin);
final sh = Sh(binMount: bin, vfs: vfs, builtins: defaultShBuiltins());

final agent = AgentSpec(
  name: 'assistant',
  instructions: 'Use Sh for any multi-step tool work.',
  model: AnthropicProvider(apiKey: '<KEY>'),
  tools: [...virtualFilesystemTools(), sh.asTool()],
);