# 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. **Sensory is live-only.** `api emit` payloads reach a *live* owner
   session or are dropped with a diagnostic — sensors report the present,
   never the past.
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): `[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 [`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).

## 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.
