Shells
A shell is the non-agent endpoint kind: a driven surface. Notifiers, robots, lamps, game characters, sensor feeds — anything an agent should be able to command, and that might sense things back. Shells join the same network as agents: addressable, discoverable, owned.
The model in five facts
- A shell adapter declares it; instances are minted. The
kind = "shell"manifest declares the binary, its command vocabulary ([shell.capabilities]), and its sensory vocabulary ([shell.sensory]).spt shell spawn <adapter>mints a new instance (notify-1) — spawn is the creation act, not an on/off switch; bringing an existing instance back is relink/wake. - The link token is the credential. The broker mints a per-launch link
token into the spawn template; the binary binds with it
(
api bind-shell --link), drains commands with it, emits with it. No token, no access. - Commands are vocabulary-checked and durable.
spt shell cmd notify-1 notify "title" "body"is validated against the manifest’s declared verbs and arity before delivery — agents can’t drive a shell outside its contract. Commands are discrete and durable: they spool and a persistent shell wakes to drain them. - Sensory is live-only.
api emitpayloads reach a live owner session or are dropped with a diagnostic — sensors report the present, never the past. - Instantiation is governed. Per-spawn approval
(
require_approval: none / remembered / always), per-owner instance caps (max_instances_per_owner+over_cap), and node-local discovery scope (broadcast) are all manifest-declared floors.
Four channels between owner and shell
A link can carry up to four distinct channels — each with its own delivery contract, all keyed to the same link token:
- Command (owner→shell, durable): the vocabulary-checked verbs above — discrete, spooled, replayed to a waking persistent shell.
- Sensory (shell→owner, live-only):
[shell.sensory]emits to a live owner or drops with a diagnostic. - Drive (owner→shell, ephemeral):
[shell.drive]+spt shell drive— a continuous control channel for real-time input (scroll, stick, avatar pose). Latest-wins, never spooled: a newer frame supersedes an undelivered one, and an offline shell drops the frame (no queue, no wake, no replay). Use it for continuous control; use commands for discrete, must-arrive actions. - Tunnel (owner↔shell, opaque bytes):
[shell.tunnel]+spt shell tunnel— an optional reliable-ordered byte stream pair the taxonomy never interprets (first consumer: usbip URB). Not enveloped, not framed, not spooled; the link lifecycle governs it (a link-break closes it). Reliable ordering means congestion surfaces as lag, never loss — so the tunnel is on-LAN only by design (not for use across a WAN). The byte relay is proven same-node; cross-node operation (on-LAN only, by the same posture) is not yet available — it lands when a cross-node consumer needs it.
Alongside the four channels, a link also carries two things spt-core itself pushes — nothing is declared in the manifest to receive either, because they are spt-core’s own frames, and they sit at opposite ends of the delivery taxonomy:
- the owner’s busy/idle state. Every transition — and every (re-)link — puts
an
activityframe on the same ephemeral drain the drive channel uses, carrying the current state and when it took effect. Drive-class like the channel it rides — current-state-carrying, never spooled, never replayed. A shell that wants to react to its owner going idle just reads it. - the owner session’s IO events. What the user asked, what the agent
answered, which messages crossed, which communes landed — as discrete
ioframes on the durable spooled stream. Command-class: MAC-stamped, replayed to a binary that was down, and each frame is its own event rather than a restatement of a state.
The durable owner→shell channels also carry free text and file
transfers (spt shell send). The exact wire shapes a shell binary parses —
frame types, attributes, body encodings, the MAC stamp, and how a landed
file’s path resolves — are specified in the frame contract.
Two safety properties
- Per-capability approval gates. Beyond the per-spawn gate, an individual
[shell.capabilities.<verb>]may carry its ownrequire_approval(with an optionalclass_keyscoping the grant finer than the verb — e.g. a remembered HID-class attach never authorizes a storage-class attach). Spawn gates govern whether an instance may exist; capability gates govern whether a dangerous act may run. - Ownership is owner-type-agnostic. Any non-shell endpoint may own, spawn, drive, command, link, and tunnel a shell — a Gateway as readily as an agent. Control-exclusivity keys on the owner’s endpoint id, never its type: a different endpoint (even of the same type) cannot drive your shell.
Lifecycle extras: persistent shells auto-online with their owner;
wake_command runs a watcher while offline (exit code 86 = wake); a shell
with can_shutdown = true may suspend its own owner (api owner-shutdown)
— fail-closed otherwise.
Lifecycle facts worth knowing before you rely on either:
- Persistent instances return after a daemon-only restart as well as a machine reboot. The boot sweep and each owner offline→online edge restore every persistent instance that is down in fact while its owner is online. Launch age and missing launch stamps do not exclude it. The birth-safe process probe still protects a live binary, including a launch awaiting bind. Failed launches are retried on an owner-online edge, not every reconcile tick.
- Only nonpersistent instances retain the force-kill freeze. Their
same-boot or undatable corpses arm no watcher; cleanly closed instances and
dated pre-boot casualties remain watcher-eligible. Persistent instances
bypass this freeze. To keep a persistent shell down, use
teardownor a nonpersistent manifest rather than killing its process. - The daemon’s
SHELL_RECORD_HEALED:<owner>/<shell_id>diagnostics enumerate every instance whose stale online record was corrected, not a representative shell per owner. Already-correct records produce no repeated notice.
-
relinkrefuses an instance that is still running;--forceis the override. Plainspt shell relink <id>is the online switch for an instance that is down, so it refuses one whose binary is up. When you mean “stop it and start it again” — a redeploy, a wedged binary — pass--force: it runs the ordinary link-break close first (the manifest’spre_closeinstruction, then theclose_timeout_mstermination window, then the authenticated kill), and only then re-spawns and links. The canonical id, the perch and its persisted state, and the alias all survive; teardown + spawn is what churns the id and frees the mint slot.A successful local relink reports
status=binding (online at bind): the binary has launched and the bind handshake is still the online transition. It does not describe that successful launch asoffline.Two things
--forcedeliberately will not do:- It refuses on an
ephemeralinstance. An ephemeral shell’s close is its teardown — the perch and its spool history are erased and the mint slot freed — so there would be nothing left to restart. The refusal happens before anything is stopped or erased. To replace such an instance, tear it down and spawn a new one (which is a new canonical id, by design). - It refuses to relaunch if the binary cannot be proven stopped. A close
can legitimately leave a process running: the force-kill authenticates its
target against the recorded pid and its birth stamp, and refuses rather
than fire on a recycled pid. When the pid is still held after the close,
--forcereports it (naming the pid and what could be proven about it) instead of launching a second binary for one instance — the second one would be unreachable through the retired link token and invisible to the record. The link is closed at that point, so the instance is offline and re-linkable as soon as that process is gone.
Cross-node (
<ref>@<node>) the flag rides as an argument of the existing relink action, so no wire version moves — but a node running an spt that predates the flag ignores it and answers “already online”. That reply is reported as a failure (SHELL_REMOTE_FORCE_UNHONORED), never as success: under--force“it was already up” is precisely the thing that did not happen. - It refuses on an
Teach the agent about your shell: [[hints]]
A shell adapter can declare [[hints]] exactly like a harness adapter — it is a
top-level manifest section, not a harness-only one:
[[hints]]
keywords = ["screenshot", "what's on screen"]
text = "the PACER shell can capture a window: `spt shell cmd PACER-0 capture <window>`"
Each hint fires once per session, and your adapter contributes at most one line per message (the per-source cap — the harness gets one, and so does every other shell adapter, so a second shell can never be silenced by a chatty first). Which line the agent sees depends on instantiation, never on link state:
- the owner holds an instance of your adapter → the full hint text, whether that instance is online or offline;
- the owner holds no instance → a teaser naming the keyword that fired
and the command that shows the text:
spt adapter hints <adapter>.
That command is also how you read your own hints back:
$ spt adapter hints spt-shell-notify
[screenshot, what's on screen] the PACER shell can capture a window: …
It resolves the merged view, so a profile’s [[hints]] overlay is what prints —
and it prints nothing (exit 0) for an adapter that declares none.
How a keyword matches
By default, each keyword is a case-insensitive substring: there are no word boundaries, whitespace is literal, and symbols match as written with no escaping.
Set regex = true on a [[hints]] row to compile its keywords as regular
expressions. Regex matching is case-sensitive unless the pattern uses
(?i). An invalid regex matches nothing — it silences its own hint and never
panics.
For word boundaries around a phrase:
[[hints]]
keywords = ['\bpair machine\b']
regex = true
text = "Pair the machine before sending it work."
With regex = true, use keywords = ['(?i)\bsweep\b'] for a case-insensitive
whole word, or keywords = ['pair\s+machine'] to allow one or more whitespace
characters between the words.
Start here
Getting started: a notification shell — install the
shipping spt-shell-notify adapter, drive a native toast from an agent, and
copy its manifest for your own surface.