# 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

1. **A shell adapter declares it; instances are minted.** The
   `kind = "shell"` [manifest](../harness-contract/manifest.md#shell-adapters-kind--shell)
   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.
2. **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.
3. **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.
4. **Shells follow active.** Everything a shell sends its owner (`api
   shell-say` text, `api emit` sensory) goes to the owner's **active**
   instance, on this node or another, and never makes an instance active. At
   the receiver, sensory is live-only. While the owner has no active instance,
   payloads wait in order on the shell's node.
5. **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 at the receiver): `[shell.sensory]`
  emits to the owner's active instance, or drops with a diagnostic when that
  session isn't live. It waits in the outbound queue only while the owner has
  no active instance.
- **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 [`activity` frame](frames.md#activity--the-owners-busyidle-state-pushed) 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
  [`io` frames](frames.md#io--the-sessions-io-events-durable) 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](frames.md).

## Shells follow active

<!-- [doc->REQ-SHELLS-FOLLOW-ACTIVE] [doc->REQ-SHELL-OUTBOUND-SPOOL] -->
An owner endpoint can run on several nodes at once, and exactly one of those
instances holds its attention: the **active** instance (see
[Instances](../instances/overview.md)). A shell always talks to that instance,
whichever node it is on.

- **Where payloads go.** Text (`api shell-say`) and sensory (`api emit`) go
  to the owner's active instance. If that instance is on this node, delivery
  is local. If it is on another node, the payload crosses the subnet.
- **Shell traffic never moves attention.** A shell's payload never makes an
  instance active, and a linked shell is not a controller. Nothing a shell
  sends drags the owner's attention back to the shell's node.
- **With no active instance, payloads wait.** If every instance of the owner
  is dormant or suspended, payloads wait in an outbound queue on the shell's
  node. Every kind waits, sensory included, in the order sent, with no expiry.
  They drain in that order to the next instance that becomes active, local or
  remote.
- **Ordering and retries.** A payload waits behind anything already queued,
  so it never overtakes an older one. It is retried under the same identity,
  so a lost acknowledgement never delivers it twice.
- **A queued sensory frame can be stale.** It may arrive long after it was
  emitted. Every sensory frame carries `sent-at`, its emit time on the shell
  node's clock, so a consumer can tell ([frames](frames.md)).
- **Teardown keeps the queue.** Tearing a shell down does not drop its
  queued payloads. They belong to the owner once they are queued.
- **Across nodes, what the gate sees.** A payload the link token proves
  (`api shell-say`, `api emit`) passes the receiving node's access gate as the
  owner's own traffic, but only when this node holds the same endpoint. The
  receiving agent still sees the shell as the sender. A plain
  `spt send <owner> --from <shell-id>` is routed the same way, but nothing
  proves it, so it faces the receiving node's ordinary access rules.
- **Not yet across nodes: owner → shell text and files.** An owner active on
  another node reaches its shell with `spt shell cmd` and `spt shell drive`
  only.

## Two safety properties

- **Per-capability approval gates.** Beyond the per-*spawn* gate, an individual
  `[shell.capabilities.<verb>]` may carry its own `require_approval` (with an
  optional `class_key` scoping 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 `teardown` or 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.

<!-- [doc->REQ-SHELL-RELINK-FORCE] -->
- **`relink` refuses an instance that is still running; `--force` is the
  override.** Plain `spt 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's
  `pre_close` instruction, then the `close_timeout_ms` termination 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.

  <!-- [doc->REQ-SHELL-RELINK-BINDING-RESULT] -->
  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 as `offline`.

  Two things `--force` deliberately will **not** do:

  - **It refuses on an `ephemeral` instance.** 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,
    `--force` reports 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.

## Teach the agent about your shell: `[[hints]]`

<!-- [doc->REQ-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:

```toml
[[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:

```console
$ 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

<!-- [doc->REQ-MANIFEST-4] -->

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:

```toml
[[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](getting-started.md) — install the
shipping `spt-shell-notify` adapter, drive a native toast from an agent, and
copy its manifest for your own surface.
