# Outpost operations

> This is the exhaustive operator and implementation reference retained from the original Outpost guide. For everyday browser tasks, start with the Outpost overview.

This is the exhaustive operator and implementation reference retained from
the original Outpost guide. For everyday browser tasks, start with the
[Outpost overview](/docs/kin/guide/outpost/).

The scheduler/governor details are in [Automation
operations](https://github.com/kinra-ai/kin/blob/dev/docs/internals/automation-operations.md); persistent-computer deployment and VM
detail are in [Rig operations](https://github.com/kinra-ai/kin/blob/dev/docs/internals/rig-operations.md).

For moving active work between a local session and Outpost, see
[Beam work to Outpost](/docs/kin/guide/beam/). `/beam` is a sequential, same-feature-branch
handoff; it does not attach the TUI to a remote session.

For AI-peer development, `task live-beam-setup` maintains a dedicated
`svc_ai_peer_live` credential and disposable `beam-scratch` checkout. Its
operator grant permits profile and inbox discovery, but writes remain scoped
to that workspace and quota-clamped. `kin doctor` reports readiness without
revealing the token, and `task live-beam` fails rather than silently skipping
when the credential is absent. The gate works from an isolated temporary clone
of `origin/beam-smoke`, submits through the immutable controller, deliberately
discards one already-accepted response, and retries the exact returned plan.
It does this because the accepted-but-lost ambiguity—not an ordinary clean
retry—is where an idempotency bug would create a duplicate job. A genuine HTTP
failure is never disguised as loss. The gate then answers only the exact
disposable structured push approval, validates the native report and remote
stamp, and returns through production `outpost-send` into a real Session. It
requires the local fast-forward count, one `beam_returned` event, and the
durable journal marker before passing. Temporary checkout, locator, and journal
state are removed. If the gate fails while its job is still nonterminal, it
cancels that exact disposable job before cleanup, retries the stale answer
through the v1 door, and requires a conflict, unchanged run count, and
invalidated Inbox item. That proves delayed capacity recovery or human input
cannot revive an ownerless push; only the intended `beam-smoke` Git advance
lasts. The freshly cloned and re-read remote head counts as the native sync:
the brief forbids a redundant pull/fetch because that would add a second
approval question beside the sole exact push this fail-closed gate can answer.

<!-- SOURCE: container/Dockerfile, container/start.sh, container/compose.yaml, container/dashboard/, container/README.md, src/kin/harness/approvals.py, src/kin/harness/headless.py, src/kin/harness/presets.py, src/kin/harness/session/roundtrip.py, src/kin/tui/cli.py, Taskfile.yml, scripts/beam_live.py, scripts/outpost-install.sh -->

The machine door exposes execution profiles derived from the same built-in
presets and custom provider rows configured on Outpost. Operator clients can
discover profile/model/effort catalogues, quota, and timeout bounds, then
submit a validated reference. Raw endpoint URLs and credentials never cross
that door; a removed profile fails explicitly instead of changing models.

A single Ubuntu container on the `kloud` host that runs your **always-on
automation layer** at **`https://outpost.kinra.ai`** — gated by
**oauth2-proxy in front of `auth.kinra.ai` (Pocket ID)**, so the page is a
login prompt before it's anything else. It rides the standard kloud service
pattern: Cloudflare → kloud-vps Traefik (+ CrowdSec WAF) → WireGuard → the
container.

> This page documents Blake's own live deployment as the worked example —
> `kloud` / `kloud-vps` / `outpost.kinra.ai` / `auth.kinra.ai` and the
> `10.0.0.x` WireGuard addresses throughout are its concrete host names and
> mesh IPs, not requirements. Standing up your own Outpost, substitute your
> own docker host, edge proxy, domain, and mesh addressing — the shape
> (public edge + WAF → private mesh → the container) is what's meant to
> carry over.

**What it's for:** Kin, unattended. Create or connect a Project, describe an
automation, and choose when it should run. Activity keeps every result and
anything that needs you in one place; sovereign Chat and notifications bring
you back when attention is required. Underneath, Projects are workspaces and
automations are scheduled `kin -p` jobs on the existing cron/every/at
contract. See "What this is NOT" below for the shape this deliberately isn't
— a second place to sit down and drive Kin interactively
(docs/decisions/0033-outpost-automation-layer.md, the middle-way plan).

## What's running

One Ubuntu container runs an aiohttp **dashboard** in the foreground and
**oauth2-proxy** in the background, both via `container/start.sh`. The
dashboard serves the landing page + REST API — workspaces, the scheduler,
  Chat, the Activity feed + inbox, notifications, and the v1 machine door.
Everything public-facing is the existing kloud-vps Traefik — Outpost is
just another service behind it.

```
browser → https://outpost.kinra.ai
        → Cloudflare              DNS-only / grey cloud, A record → 172.233.215.158
        → kloud-vps:443           Traefik v3 + CrowdSec WAF, Let's Encrypt TLS
        → WireGuard               10.0.0.1 (vps) → 10.0.0.2 (kloud)
        → 10.0.0.2:4180           oauth2-proxy (in the container) — Pocket ID OIDC, PKCE / S256
        → 127.0.0.1:7681          dashboard (aiohttp) — landing page, /runs/<job>/<run> pages, /api/*
```

One container. Two binaries (oauth2-proxy + dashboard). A handful of env
vars. The VPS terminates TLS, runs the WAF, and forwards over the tailnet
to oauth2-proxy on kloud's WireGuard IP `10.0.0.2:4180`; oauth2-proxy proves
you to Pocket ID and then proxies to the dashboard on container loopback.

The image also carries the Outpost concierge's canonical guidance and a
curated help corpus compiled from this guide. On every boot `start.sh`
idempotently refreshes `/workspace/ranger/AGENTS.md` and
`/workspace/ranger/help/`, so a persistent workspace volume receives support
updates with the image while `docs/` remains the source of truth.

> **Historical:** before the middle-way plan's Step 3 (2026-07-06,
> `docs/decisions/0033-outpost-automation-layer.md`), the dashboard also
> reverse-proxied WebSocket terminal traffic to a per-session `ttyd` (`-W -O
> -m 1`, port 9000+) wrapping a `tmux` session — a real browser terminal onto
> a live `kin` process. That surface (`sessions.py`, `proxy.py`, `status.py`,
> `status_watch.py`, `history.py`, the vendored xterm assets, `tmux.conf`) is
> deleted wholesale. The escape hatch for a real interactive shell is `ssh
> kloud` + `docker exec` — an operator path, not a product surface.

A handful of env vars steer the container for a given deployment:

| Env var             | Local default                   | Deploy value             | Drives                                                          |
| ------------------- | ------------------------------- | ------------------------ | -------------------------------------------------------------- |
| `OUTPOST_BIND_IP`    | `127.0.0.1`                     | `10.0.0.2`               | which host IP `:4180` publishes on (the VPS must reach it)      |
| `OUTPOST_PUBLIC_URL` | `https://outpost.kinra.ai`          | `https://outpost.kinra.ai`   | oauth2-proxy's cookie domain + redirect URL (`…/oauth2/callback`) |
| `OUTPOST_ALLOWED_EMAILS` | `blake@kinra.ai`            | same (compose default)   | the oauth2-proxy email allowlist — the only addresses let into the shell (comma/space separated) |
| `OUTPOST_TRUSTED_PROXY_IP` | `10.0.0.1/32`             | same (compose default)   | the only CIDR trusted to assert `X-Forwarded-*` (the VPS WireGuard addr) |
| `OUTPOST_GH_CLIENT_ID` | `Ov23liaiAOroQzuvNs4O` | override for a throwaway OAuth App (local smoke) | GitHub Connect (Device Flow — no secret, no callback URL) |

The defaults boot a self-contained container on your laptop for image
smoke-testing; `task outpost:deploy-*` passes the deploy values for you.
Note the deploy tasks forward **only** `OUTPOST_BIND_IP` and
`OUTPOST_PUBLIC_URL` — to override the email allowlist or trusted-proxy CIDR
in prod, set those env vars on the kloud side (in the environment of the
shell that runs `docker compose`), not on the laptop.

## First-time setup / go-live

**On a fresh (non-kloud) docker host**, the scripted path does the staging
for you:

```bash
bash <(curl -fsSL https://get.kinra.ai/outpost-install.sh)
```

It checks docker + compose v2 + git + the GitHub SSH key, clones the repo
to `~/kin`, and stages the six primary secret files as empty mode-600
stubs. With the three required oauth secrets filled it runs
`docker compose up -d --build` (plain — no `--profile publish`; the
get/docs static services below are kloud-only) and waits for the
healthcheck; with any of them empty it stops *before* compose up and
prints the manual checklist — "staged, awaiting secrets" is its designed
success state, and it never generates a secret value or touches
Traefik/DNS. Re-run it after filling the secrets. `--check` walks the
whole decision tree mutating nothing.

An Outpost installed before the repository rename may still live at
`~/kin-textual`. When `~/kin` is absent, the installer reuses that checkout
and its existing `container/secrets/` in place rather than cloning a second,
apparently unconfigured Outpost.

**On kloud itself**, the infra (Traefik, CrowdSec, WireGuard, Pocket ID)
already exists on kloud-vps, so standing Outpost up is a short checklist:

1. **DNS** — nothing to add: `outpost.kinra.ai` is already covered by the
   wildcard `*.kinra.ai` → `172.233.215.158` (Cloudflare, DNS-only) that points
   at the VPS Traefik. Let's Encrypt issues the per-host cert on first request.
2. **Pocket ID** (`auth.kinra.ai`) — a confidential OIDC client with PKCE
   (S256), redirect URI **`https://outpost.kinra.ai/oauth2/callback`**, scopes
   `openid email profile`. (Already done.)
3. **Drop the secret files onto kloud** at `~/kin/container/secrets/`
   (no extension, mode 600, gitignored fail-closed — everything in the dir is
   ignored except `.gitkeep`, so a new secret can't leak by being unlisted).
   Inside the container they surface at `/run/secrets/<name>`. Three are
   **required** (the Pocket ID OIDC values):

   ```bash
   $ install -m 0600 /dev/null container/secrets/oauth-client-id
   $ install -m 0600 /dev/null container/secrets/oauth-client-secret
   $ install -m 0600 /dev/null container/secrets/oauth-cookie-secret
   $ printf '%s' '<client-id-uuid>'  > container/secrets/oauth-client-id
   $ printf '%s' '<client-secret>'   > container/secrets/oauth-client-secret
   $ openssl rand -hex 16            > container/secrets/oauth-cookie-secret
   ```

   Three are **optional**:

   - `container/secrets/kin-settings` — seeds `~/.kin/settings.toml` on first
     boot only. The current Kinra profile selects authenticated
     `https://api.kinra.ai/v1`, Responses, `deepseek-v4-flash`, and the
     `kinra-vision` Qwen route generated by `kin connect`.
   - `container/secrets/kin-credentials` — independently seeds the 0600
     `~/.kin/credentials.toml`. A named hosted provider needs both files;
     settings without this credential store cannot authenticate. Mint a
     distinct Outpost key and never paste it into documentation or shell
     argv. Existing volume files win over both seeds, so later changes survive
     restarts. Compose requires source files to exist; deploy self-heals empty
     placeholders when either seed is omitted.
   - `container/secrets/vapid-private-key` — the Web Push signing key (D5,
     the Push notifications card). Absent/empty → the push lane stays
     dormant; nothing else on the box is affected. `task outpost:deploy-*`
     self-heals an empty placeholder the same way it does for
     the Kin config files. One-time keygen + the exact drop-in steps are in the
     **Push notifications** card section below.

   **GitHub Connect needs no secret file** — it's OAuth Device Flow. The only
   setup is a one-time toggle on the OAuth App (`Ov23liaiAOroQzuvNs4O` by
   default): in the GitHub OAuth App settings, enable **Device Flow** (off on a
   new app). Without it every connect errors `device_flow_disabled` (and the
   card names the fix). The public `client_id` lives in `OUTPOST_GH_CLIENT_ID`;
   there is no client secret and no callback URL to register. Connect from the
   dashboard's GitHub card (see below).

   There is **no deploy key and no git clone**: the repo is baked into the image
   from the build context, so the box carries no git credentials at all.

   **Use `openssl rand -hex 16`, not `-base64 32`.** oauth2-proxy derives an
   AES key from the cookie secret, so it must be **exactly 16, 24, or 32
   bytes**. `openssl rand -hex 16` yields 32 printable hex chars (= 32 bytes);
   `openssl rand -base64 32` yields a 44-char string oauth2-proxy rejects as
   the wrong length (and can embed a NUL when decoded). `start.sh` fails loud on
   a bad length, and strips any trailing newline before handing the secret to
   oauth2-proxy as a file — otherwise the extra byte alone would break it
   mid-boot, leaving the dashboard up and the box looking healthy.
4. **Deploy** — `task outpost:deploy-dev` (or `task outpost:deploy-main`). The
   build bakes the repo into `/opt/kin` inside the image
   (`uv sync --extra outpost --locked`); `/workspace` starts empty, and the
   user creates or clones a workspace from the dashboard's landing page.
   A **main** deploy also rebuilds the public docs and atomically publishes
   Kin's allowlist into the durable get.kinra.ai root (normally
   `~/.local/share/kinra-publish/get`; initialize it once from the legacy live
   root with `task outpost:publish-init`). The wheel + `version.json` release
   channel additionally requires HEAD to carry the matching `v<version>` tag
   (DR 0121). Paddock owns its disjoint nested release paths and publishes them
   independently; a Kin deploy cannot sweep them. The compose `publish`
   profile brings up two `nginx:alpine` services, `get` on `:7018`
   (**get.kinra.ai** — product pages and release channels) and `docs` on
   `:7019` (**docs.kinra.ai**, the public user guide). Dev deploys leave both
   public roots untouched. See
   [Multi-machine setup](/docs/kin/internals/multi-machine-setup/) for the
   artifact-build detail.
5. **Traefik route** — already added on the VPS at
   `~/kloud/traefik/dynamic/routes.yml` (hot-reloaded). The Let's Encrypt
   cert issues automatically once DNS resolves; a few ACME attempts before
   DNS exists just back off and self-heal. The `kin-get` / `kin-docs`
   routers (→ `http://10.0.0.2:7018` / `:7019`) sit in the same file,
   mirroring the `kin-outpost` block below.

The Traefik router/service is named `kin-outpost`, rule
`` Host(`outpost.kinra.ai`) ``, middlewares `crowdsec` + **`kinra-secure-headers`**,
`tls.certResolver: letsencrypt`, service URL `http://10.0.0.2:4180`. CrowdSec
wraps it automatically — no extra wiring.

> **Why `kinra-secure-headers`, not the default `secure-headers`:** this
> dates from when the session page (`/session/<id>/view`) embedded the
> terminal in a same-origin `<iframe>` — the default `secure-headers`
> middleware sets `frameDeny: true` (→ `X-Frame-Options: DENY`), which would
> have blanked it. That reason stopped applying even before the middle-way
> plan: the chrome later owned an xterm.js `Terminal` directly instead of
> framing ttyd's own page, so nothing on the page framed anything. As of
> Step 3 (2026-07-06) the whole terminal surface — chrome, xterm.js, the
> session page — is deleted, so the reason is doubly moot. `frameDeny: false`
> + `customFrameOptionsValue: SAMEORIGIN` makes no functional difference
> today. The Traefik router still uses `kinra-secure-headers` in prod
> (`~/kloud/traefik/dynamic/routes.yml` on the VPS) — harmless rather than
> load-bearing now, and there's no urgency to revert it to the default
> `secure-headers` middleware.

## Day-to-day

The `deploy-*` and `remote-*` tasks drive kloud over SSH; the bare tasks act
on a LOCAL container for image smoke-testing.

```bash
# On kloud (PROD):
$ task outpost:deploy-dev      # align kloud to origin/dev, rebuild + up (the usual deploy)
$ task outpost:deploy-main     # same, from origin/main (the verified branch)
$ task outpost:deploy-fresh    # deploy dev with --pull --no-cache (refresh the base + apt layers)
$ task outpost:remote-status   # container state + oauth2-proxy /ping health on kloud
$ task outpost:remote-logs     # tail prod logs (dashboard + oauth2-proxy)
$ task outpost:remote-shell    # shell into the prod container on kloud
$ task outpost:remote-reset    # down -v on kloud: DESTROYS prod workspaces + kin state (prompts first)

# Locally (image smoke-testing only, publishes 127.0.0.1:4180):
$ task outpost:build           # build the image (amd64, matches kloud's arch)
$ task outpost:up              # bring a local container up
$ task outpost:logs            # tail local logs
$ task outpost:shell           # shell into the local container
$ task outpost:down            # stop; named volumes survive
$ task outpost:reset           # down -v: nuke the LOCAL named volumes (clean box)
$ task outpost:secrets-stub    # write throwaway local secrets (gitignored) for smoke-testing
```

Override the SSH target or checkout path with `OUTPOST_SSH` (default `kloud`)
and `OUTPOST_DIR` (default `~/kin`); `OUTPOST_PUBLIC_URL` and
`OUTPOST_BIND_IP` override the deploy values above.

## Lifecycle

Outpost itself is **one container and one subdomain**. Rig is a separate,
profile-gated warm service so an Outpost rebuild never churns its browser.
Deploying Outpost is a branch swap:
`task outpost:deploy-dev` SSHes to kloud, `git fetch` + `checkout` +
`reset --hard origin/dev` (which keeps the gitignored `secrets/` files), then
`docker compose up -d --build` with the prod env. `deploy-main` does the same
from `origin/main`. There's no blue/green or per-branch URL — the two deploy
tasks just choose which branch the single box mirrors. That kloud checkout is
only the build source + secrets dir — it is **not** mounted into the container.

The box is **disposable and self-contained** — no host bind-mounts, no git
clone. The code is **installed into the image**: the build runs
`uv sync --extra outpost --locked` from the build context into
`/opt/kin/.venv` (the dashboard, the `kin` command, and a tiny aiohttp
back-end share the venv). Chromium has moved to [Rig](/docs/kin/guide/rig/), whose separate
image carries the headful display and persistent profile. Outpost receives only
Rig's internal URL and secret-file path so scheduled `kin -p` runs can use the
same tools. **`/workspace` starts empty** — the user creates or
clones a workspace from the dashboard's landing page. (The old
first-boot-from-`/opt/kin-seed` repo seed is gone; the general-user
"onboard into a clean box" flow replaces Blake's personal-use "clone
kin in for me" path.) The dashboard imports `kin.harness.settings`
directly, so the **Endpoint** form on the landing page is a thin UI over
the same writer (`save_global_settings`) the in-TUI `/edit-settings`
artifact editor uses — single source of truth. State persists in **three
named volumes**, not host paths: `outpost-workspace` → `/workspace` (user
workspaces — created/cloned by the dashboard, no seed), `outpost-kin` →
`/home/blake/.kin` (kin session history + split settings/credentials, seeded
independently on first boot), and `outpost-gh` →
`/home/blake/.config/gh` (the `gh` token, so GitHub Connect survives a
restart). They survive `down`/`up`
and reboots; **`task outpost:reset`** (`docker compose down -v`, LOCAL
container) or **`task outpost:remote-reset`** (the same over SSH, against
the prod box on kloud) nukes them for a clean box. `restart: unless-stopped`
brings the box back after a reboot.

## Workspaces & snapshots

Every workspace in `/workspace` is reversible from the dashboard.
The **History** button on a workspace row opens a modal listing that
workspace's snapshots (newest first) — every entry has a **Restore**
and **Fork** action; the **Snapshot** button on the same row creates
a fresh snapshot on demand (you'll be prompted for a label).

### How snapshots work

Two substrate paths, picked automatically at `create()` time:

- **Git** — when the workspace has a `.git/` directory, the engine
  builds a temp git index, captures the tree (`git add -A` honors your
  `.gitignore` — files you explicitly `.gitignore` will not round-trip
  through snapshot/restore; this is documented behavior, see
  `docs/decisions/0030-workspace-snapshots.md`), commits it to a
  `refs/kin-snapshots/<ts>-<suffix>` ref, and stores the commit SHA
  in the metadata table. The working tree + index are UNTOUCHED.
- **Tar** — when there's no `.git/`, the engine creates a gzipped
  tarball under `<kin_home>/outpost/snapshots/<ws>/<ts>-<suffix>.tar.gz`.

Restore materializes either back into the original workspace; Fork
materializes into a NEW workspace (filesystem-only, no shared
history). Both ops take a protective "pre-restore" snapshot first, so
the destructive op is itself reversible from the same History modal.

### Retention (the quotas)

- ≤10 snapshots per workspace (FIFO by `created_at`; the 11th evict
  the oldest).
- 2 GiB total tar-store quota (cheapest-first eviction when a new
  one would overflow).
- 30-day sweep of untouched entries.
- The engine sweeps opportunistically on every `create()` — there's
  no separate daemon.

If you hit a quota refusal on a new snapshot, the operator's path
forward is: open the workspace's History modal → delete the oldest
entries → retry the snapshot. The audit log (`audit_log` table) records
every create/delete/restore/fork.

### Auto-snapshot (CP-D lever)

The governor config has an `auto_snapshot` lever with three positions:

- **off** (the default — the honest v1 starting posture): no
  pre-run auto-snapshots fire.
- **scheduled**: only Automations fires (cron / every / at) get a
  protective snapshot in `_run_job` BEFORE the subprocess spawns.
  Trigger-fired jobs (WP7) are excluded — those are operator-trusted
  one-shots, not background unattended traffic.
- **all**: every background fire gets a snapshot.

Every auto-snapshot is rate-limited to ≥15 min/workspace (a flooding
scheduler can't saturate the store). When the rate-limit engages
the engine emits a stderr line; the live `snapshots_last_auto.json`
file is the operator-readable state.

### What can go wrong

- **"refusing path escape"** in `restore`: the snapshot contains a
  member with a `..` or absolute path. The engine refuses to
  extract (defense-in-depth). The snapshot is corrupted — open the
  History modal and `Delete` it.
- **"tar-store quota exceeded"** on `create`: clean up old
  snapshots per workspace.
- **Running-job refusal** on `restore` or workspace **Delete**: a
  scheduled job is currently running with its workdir under the
  workspace. Wait for it to finish (or cancel it from Automations)
  before retrying. (Before the middle-way plan's Step 3 ttyd cut,
  2026-07-06, this guard checked live tmux sessions instead — the
  job-based check replaced it since the terminal surface it guarded
  against no longer exists.)

## Troubleshooting

| Symptom                                            | Cause / fix                                                                                              |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| 502 from Cloudflare / Traefik                      | container down or WireGuard down on kloud; `task outpost:remote-status` (check container `ps` + `/ping`)  |
| Box was healthy, then went unreachable (502) for a bit | The auth gate (oauth2-proxy, a background child) died. The dashboard's watchdog polls its `/ping` and exits after ~45s of misses, so `restart: unless-stopped` rebuilds a reachable box; the healthcheck also flips `unhealthy` in `docker ps`. Read the gate's exit reason in `task outpost:remote-logs` (oauth2-proxy now logs to the container stdout, not a `/tmp` file). |
| Can't delete/restore a workspace — "has running job(s)" | A scheduled job's workdir is under the workspace and it's currently running. Wait for it to finish or cancel it from Automations, then retry. |
| A page's CSRF-gated action 403s with "missing or invalid CSRF token" after opening a second dashboard tab/page | The dashboard's CSRF token is a per-browser double-submit value; it must be **reused** across renders, never re-minted. `_index` / `_run_view` call `csrf.ensure(request)` (reuse the `_dashboard_csrf` cookie, mint only when absent) — a fresh mint per render rotated the shared cookie out from under the already-open dashboard tab, whose in-memory token then 403'd. Don't "simplify" `ensure` back to `mint`. Regression-tested in `tests/test_outpost/` ("reuses the existing csrf cookie"). |
| TLS cert not issuing (`https` warns / fails)       | LE still validating on first request or ACME backing off; the `*.kinra.ai` wildcard already resolves `outpost.kinra.ai` → `172.233.215.158`, so confirm the Traefik `kin-outpost` router exists + the box answers `:4180`, then wait a few minutes |
| Browser hangs on `auth.kinra.ai` forever           | Pocket ID client's redirect URI doesn't match `https://outpost.kinra.ai/oauth2/callback` exactly            |
| oauth2-proxy won't boot, log says cookie_secret    | `secrets/oauth-cookie-secret` isn't 16/24/32 bytes; regenerate with `openssl rand -hex 16`               |
| Redirect loop / login bounces back to login        | `--reverse-proxy=true` not trusting Traefik's `X-Forwarded-*`, or `OUTPOST_PUBLIC_URL` mismatched; confirm the deploy passed `https://outpost.kinra.ai` |
| `task outpost:build` fails on `apt install`         | Ubuntu base moved; bump `FROM ubuntu:24.04` to current                                                   |
| kin in container can't reach inference             | Test public health first (`curl https://api.kinra.ai/healthz`), then the stable private fallback (`curl https://kinra-inference-001.mist-hake.ts.net:8001/v1/models`). The box runs no Tailscale daemon; private traffic is NAT'd through the kloud host's Tailnet. A public 401 from `/v1/models` means reachability is healthy but the Outpost-specific bearer is absent/wrong. |
| `/workspace` is empty                              | **By design** — the dashboard lets the user create or clone a workspace from the landing page (the old first-boot repo seed is gone, on purpose — a general user should onboard into a clean box, not into a stranger's checkout). |
| Need a clean box                                   | Local: `task outpost:reset`; prod on kloud: `task outpost:remote-reset` (both `docker compose down -v` — the bare `reset` only ever touches the LAPTOP's volumes, it does nothing to prod). Next `up`/deploy starts from an empty box |
| 403 from CrowdSec                                   | your IP tripped a WAF decision; check the CrowdSec decisions on kloud-vps                                |
| Everything fronted by kloud-vps looks dead at once — dashboard and sign-in — even though the container itself is healthy | A CrowdSec ban of the box's own egress IP can bite when the docker host shares a NAT/premises IP with other traffic. The ban 403s both inbound browser requests and the box's outbound calls to Pocket ID OIDC discovery. Check CrowdSec decisions on kloud-vps for that egress IP and unban / allowlist it if it is trusted infrastructure. |
| A typo'd / stale dashboard URL shows a themed "Page not found" page | Expected — unknown non-`/api/` paths (and unhandled errors, which show a reference id matching the server log's `request_id`) render the Graphite+ error page for browser navigations; API clients still get the plain 404 / JSON-500 contract. |

## The machine door (`/api/v1/`)

Alongside the human door (browser + Pocket ID cookie), the dashboard
mounts a separate **machine door** at `/api/v1/` — a Bearer-token-only
aiohttp sub-app for service-to-service job submission. The wire contract
lives in the repo's `PROTOCOL.md`; the short version:

- **Auth is a Logto JWT** (ES384, verified against
  `logto.kinra.ai`'s JWKS) **or an HMAC bearer**
  (`svc_<service_id>.<secret>`, checked constant-time against a
  0600 flat file at `~/.kin/secrets/services.json` inside the
  container). Never a cookie session — oauth2-proxy carries
  `--skip-auth-route='^/api/v1/'` so machine clients aren't 302'd to a
  human login page, and the sub-app's own middleware chain is the sole
  auth boundary for that prefix.
- **Rate limiting is two-stage**: a coarse global ceiling before auth
  (bounds the CPU an unauthenticated flood can burn on signature
  verification) and the real per-service token bucket (default
  60 req/min) after auth, keyed on the *verified* identity — so forged
  tokens can never consume a real service's quota. Over-limit requests
  get `429` + `Retry-After`.
- **`POST` requests honor `Idempotency-Key`** (the Stripe 3-layer
  pattern): retries replay the stored response; the same key with a
  different body is rejected.
- Errors are RFC 9457 `application/problem+json` throughout, and every
  response carries an `X-Request-Id` (client values are echoed when
  well-formed).

**Job submission is real** (PROTOCOL.md r9): `POST /api/v1/jobs` with
`{kind: "agent_turn", prompt, workspace}` validates strictly, checks the
service's `allowed_workspaces` ACL and `max_concurrent_jobs` quota (both
configured per service in `services.json`), then creates a one-shot job
on the same scheduler the dashboard's Jobs card uses — fired immediately,
with the scheduler's retry/dead-letter machinery behind it. The `202`
returns `status_url` / `report_url` / `cancel_url`; clients poll
`GET /api/v1/jobs/<id>` to a terminal state (run payloads include
`token_usage: {input, output, total}` when the run's
[headless sidecar](/docs/kin/guide/headless/#output) carried counts), fetch the
sanitized markdown report from `GET /api/v1/runs/<id>/report`, and can
`POST /api/v1/jobs/<id>/cancel` (idempotent, routed through the
scheduler — never a raw pid kill).

**Webhooks are real** (PROTOCOL.md r11): submit with a `webhook_url`
(or register a service-level default) and Outpost POSTs a
[Standard Webhooks](https://www.standardwebhooks.com/)-signed
`job.terminal` event on every terminal transition, and `job.halted`
(with the structured `needs_human` questions from the run's
[headless sidecar](/docs/kin/guide/headless/#output)) when a run blocks on a human.
The service entry needs a `webhook_secret` in `whsec_` form — verify
with any Standard Webhooks library against the raw request bytes.
Deliveries retry on the Stripe-shaped schedule (0s → 24h tail) with a
stable `webhook-id`, land in a per-service dead-letter queue after
exhaustion or backpressure, and every delivery hop is SSRF-guarded
with DNS pinning (https-only, private/tailnet/metadata address space
refused, ≤5 redirect hops).

**Services are registered with the `kin service` CLI** (PROTOCOL.md
r12): `kin service create --name purchase-advice --workspace <slug>
[--webhook-url …] [--quota rate_per_min=60]` mints the bearer token and
the `whsec_` webhook signing secret, prints both **exactly once**, and
stores them Fernet-encrypted in `services.db` under a KEK generated at
first dashboard startup (`~/.kin/keystore/service_kek.bin`, 0600;
`KIN_MASTER_KEY` env override). `kin service list/disable/enable`
manage the registry; `kin service rekey` (with
`KIN_OLD_MASTER_KEY`/`KIN_MASTER_KEY` set) re-encrypts every stored
secret under a new KEK in one transaction. Run it inside the container
(`docker exec -u blake outpost kin service …`) or from a repo checkout.
The old flat `~/.kin/secrets/services.json` still works as a read-only
fallback for hand-provisioned services (a DB row with the same id
wins).

The read surface is complete: `GET /api/v1/jobs` lists this service's
jobs newest-first with opaque `cur_…` cursor pagination (`limit` 1–200,
`state` and `since` filters; cursors are service-scoped — reusing
another service's cursor is a `400 invalid-cursor`),
`GET /api/v1/jobs/<id>/runs` returns the runs alone, and
`GET /api/v1/quota` reports live usage (`rate_per_min` remaining from
the same token bucket the rate limiter spends, `max_concurrent_jobs`
current, `per_run_token_budget` limit). Browser-based clients get CORS
from each service's registered `allowed_origins`
(`--allowed-origin` at `kin service create`; exact `https://` origins
only, no wildcards — an unregistered origin simply gets no CORS
headers).

## Security notes

- **Pocket ID is the real front door.** Outpost is publicly reachable at
  `https://outpost.kinra.ai`, so the OIDC gate is the actual authentication
  boundary — not a redundant layer behind a tailnet. `--code-challenge-method=S256`
  matches the 2025 OAuth2 Security BCP, and oauth2-proxy gates every browser
  request before the dashboard is ever reached.
- **CrowdSec WAF sits in front** (the `crowdsec` Traefik middleware), plus
  the `secure-headers` middleware — the same protection every kloud service
  gets.
- **The dashboard sets its own security headers** (an aiohttp middleware), so
  the hardening holds even on the container-loopback path Traefik doesn't see:
  `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, a
  `Permissions-Policy` denying camera/mic/geolocation/usb/etc, and a
  per-request **nonce-CSP** (`default-src 'none'; script-src 'nonce-…'; …`) on
  the dashboard's own HTML pages. JSON/API responses stay CSP-exempt (a
  script-src nonce policy is meaningless for JSON). The CSP collapses the XSS
  blast radius for `connect-src` / `frame-src` / `object-src` / `base-uri`
  even though the JS already HTML-escapes every server value.
- **A dead auth gate self-heals.** oauth2-proxy runs as a background child; if it
  dies the dashboard would keep answering `:7681` while the public `:4180` path is
  dead. A watchdog in the dashboard polls the gate's `/ping` and exits the process
  after ~45s of misses, so `restart: unless-stopped` rebuilds a reachable box; the
  compose healthcheck also probes `:4180/ping` so the failure shows in `docker ps`.
  Container logs are bounded (json-file `50m × 5`; oauth2-proxy logs to stdout, not
  an unbounded `/tmp` file) and `memswap_limit` matches `mem_limit` so memory
  pressure is a clean OOM-restart, not a swap-thrash stall.
- **oauth2-proxy secrets are file-based**, never env-exported
  (`--client-secret-file` / `--cookie-secret-file`) — an exported secret would
  re-expose itself to `docker inspect` and `/proc/<pid>/environ`. `start.sh`
  normalizes each into a private 0600 temp copy (CR/LF-stripped) and passes the
  path. oauth2-proxy is pinned to **7.15.3** (clears CVE-2025-54576 and
  CVE-2025-64484).
- **Only Traefik is trusted to set forwarded headers.** `--reverse-proxy` alone
  trusts `X-Forwarded-*` from every IP (`0.0.0.0/0`) — the spoofing surface
  CVE-2025-64484 flagged. `--trusted-proxy-ip` (default `10.0.0.1/32`, the VPS
  WireGuard addr over which Traefik is the only peer that reaches `:4180`;
  override via `OUTPOST_TRUSTED_PROXY_IP`) narrows that to the real proxy. Other
  hardening flags: `--real-client-ip-header`, `--cookie-csrf-per-request`,
  `--cookie-csrf-expire`, `--whitelist-domain`.
- **No docker socket mounted, no host bind-mounts.** Outpost can't start
  sibling containers and can't touch the host filesystem — both deliberate.
- **One container, single user** (`blake`) **with passwordless `sudo` — by
  design.** kin runs unbounded inside; root-inside is the point. The isolation
  boundary is **no host mounts + no docker socket + the passkey gate + the
  `pids_limit` / `mem_limit` / `cpus` caps** in compose — the container is the
  blast radius, and with no host mounts a gate-bypass just lands in a disposable
  box. We deliberately do **not** set `no-new-privileges:true` (it would block
  setuid `sudo`) or enable daemon `userns-remap` (it's daemon-wide on kloud,
  which runs other containers — userns only buys the container-escape tail case,
  already defanged by the no-mounts posture). A per-container userns (e.g.
  Podman) is a possible future tier, not current.
- **The container is the sandbox** (`KIN_SANDBOX=container`, baked into the
  Dockerfile). kin's OS sandbox (bubblewrap on Linux) is both redundant here —
  the box is already the disposable blast radius — and unworkable: `bwrap` needs
  user namespaces a standard Docker container blocks. Without this value kin
  can't find `bwrap`, so `auto` mode would fail-close to asking for every
  non-trivial shell command. `KIN_SANDBOX=container` tells the loop to trust the
  boundary: `auto`-mode shell runs unconfined instead of prompting. It relaxes
  **only** the shell gate — MCP still asks and the planning freeze still denies.
  See [Trust the container](/docs/kin/guide/auto-mode-and-sandbox/#trust-the-container).
- **Secrets never enter git.** The files under `container/secrets/` are
  gitignored **fail-closed** (`container/secrets/*` except `.gitkeep`), mode
  600, and dropped onto kloud by hand; `reset --hard` on deploy preserves them.

## What this is NOT

- **Not a second place to work.** It is not a browser terminal onto a live,
  interactive `kin` — that surface existed (ttyd + tmux) and was cut
  wholesale (middle-way plan Step 3, 2026-07-06,
  `docs/decisions/0033-outpost-automation-layer.md`). Terminal geography is
  local, fixed, where you work; the Outpost is the always-on automation
  layer that works while you don't — scheduled runs, alerts, the inbox —
  connected thin (a tool from kin; a concierge reached through Outpost Chat,
  the middle-way plan). Different kinds of work, connected thin — never two
  windows onto one Kin.
- **Not** an IDE. It's an automation manager, Project surface, and run inbox;
  the exact substrate remains workspaces plus the scheduler.
- **Not** a replacement for the laptop. `uv run kin` on the laptop is still
  the fast loop for interactive work. The Outpost is for the runs that
  should happen without you sitting there.
- **The escape hatch**, if you ever need a real interactive shell on the
  box: `ssh kloud` + `docker exec -it outpost bash`. An operator path, not
  a product surface — it isn't gated by Pocket ID, isn't reachable from the
  public URL, and exists precisely because the product surface deliberately
  doesn't offer this anymore.

## What's on the landing page

The dashboard serves the manager at `GET /` through five destinations shaped
around the operator's intent:

- **Chat** — the default concierge; see [Chat](/docs/kin/guide/chat/).
- **Activity** — Needs you, Running now, and Recent.
- **Automations** — saved automations, ready-made Recipes, and the Projects
  they run in. **Project** is the ordinary product name; APIs, paths, and
  operator details retain the exact term **workspace**.
- **Computer** — Rig posture, login handoff, glance, peek/control, journal,
  and memory. Rig degrades to unavailable without affecting the other views;
  glance is click-to-load and peek begins view-only behind the same SSO cookie.
- **More** — routine Settings plus an Advanced area for services, triggers,
  and machine-door administration. Raw API examples live in the guide and
  versioned protocol rather than the product UI.

Desktop keeps those destinations in a persistent labelled rail. At phone
width the same five destinations dock to the bottom edge as a thumb-reachable
bar, clear of the home indicator; the bar steps aside while the on-screen
keyboard is open. The information architecture does not change with width.
Connection health stays quiet while **live** (the last poll succeeded and
EventSource is open). The header surfaces a pill only when degraded:
**polling** (static amber: the push channel is connecting or down, so updates
ride the 10s poll) or **offline** (the poll itself is failing). Hovering shows
the last good update; on a phone the compact degraded pill exposes the same
state on tap. Deep links survive a refresh and the back
button: `#chat` / `#activity` / `#automations` / `#computer` / `#more`
land on each destination. Legacy `#run`, `#jobs`, `#developers`,
`#configure`, `#rig`, and `#card-<id>` bookmarks route to the new owner;
any unknown hash falls back to `#chat` (the home view).

First-use help follows the data rather than a permanent onboarding state. With
no Project, Automations points to creating a blank Project or connecting
GitHub. With a Project but no automation, it points to a Recipe or the simple
composer. The guidance disappears as soon as that next step exists; there is
no tour, coach layer, or separate onboarding ledger.

See [Rig](/docs/kin/guide/rig/#peek-from-outpost) for the control hold and credential flow.

### Activity — Needs you, Running now, and Recent (`card-inbox`, `card-activity`)

Activity is the supervision view: the actionable queue first, genuinely live
work second, then settled history. The existing feed card supplies the Running
now and Recent groups without turning a historical result into current
attention:

- **Needs you** (`card-inbox`): open, actionable Inbox items — a run halted
  on a question/approval/plan/proposal, or a Rig login wall that needs direct
  human control. Each payload carries its valid typed actions. Run rows offer
  **Open run**, **Reply**, and **Dismiss**; selecting one arms the reply
  shortcuts in the **sticky action bar** (see below). A Rig login row offers
  only **Open Computer** and never a synthetic run link or answer controls. A
  permanently failed pre-launch continuation appears as its own item with the
  sole action **Retry continuation**. The count badge next to the
  header mirrors the Activity navigation indicator. Data source:
  `GET /api/inbox` (human door; `{items}`; the count is `items.length`, and each item carries
  the enriched `job_name` / `job_workdir` from a LEFT JOIN against
  `scheduled_jobs`).
- **Activity** (`card-activity`): the unified feed — one normalized row
  per kin run, job-backed (the only source since the middle-way plan's
  Step 3 ttyd cut retired the live-tmux-session and ended-terminal-row
  sources). Each row carries a state dot (reusing the dashboard's
  existing `.dot.*` grammar — `running` green pulse, `waiting` static amber
  for needs-human, `ok` solid green, `dying` red for
  error/killed/lost/timeout, `idle` grey otherwise — including
  `cancelled`, which is "stopped by choice", not a failure), a source
  chip (`scheduled` / `api` / `trigger` / `rig`), the title (a deep link to
  `/runs/<job_id>/<run_id>` for runs or the semantic replay for a closed Rig
  excursion), the workspace, token counts, and a
  relative age (hover for the absolute time). While a run is in flight
  its row also carries a **ticking elapsed counter** ("· 42s") —
  deliberately only on running rows, never on waiting ones (a ticking
  clock on human latency reads as nagging). Relative times across the
  dashboard re-derive on a coarse background tick, so "just now" can't
  sit stale for minutes. When a row's state changes between renders
  (running → ok, a question opening), the row briefly **flashes then
  settles** — a calm one-shot acknowledgment, never a toast. Data
  Rig contributes one row per closed excursion, never one row per browser or
  desktop action; a down Rig contributes no rows and does not break the feed.
  Data source: `GET /api/activity?limit=50` (human door; the D3 row shape —
  see `docs/decisions/0022-outpost-foundations.md`).

Running work cannot be filtered away. Every running row in that response stays
visible in **Running now**; **Find recent activity** and **Outcome** apply only
to the settled **Recent** rows below it. Search recognizes both the friendly
title or Project and the exact stored name, workspace, and source. Recent
starts at ten matches; **Show more** reveals ten at a time and **Show fewer**
returns to ten. The live result summary keeps the two scopes honest, for
example “Showing 10 of 34 recent from the latest 50 items · 2 running now.”
A no-match message names that same bounded window, offers
**Clear filters**, and is distinct from a genuinely empty Activity feed.

These controls search only the settled rows in the latest 50-item
Running/recent window, not all historical runs. The same response independently
bounds Inbox-owned attention at 50, so an attention burst cannot consume the
Running/recent window; additive `item_window` metadata names those two bounds.
Run rows also carry additive `stored_workspace` data so exact path/slug search
does not depend on reconstructing a Project's friendly label.
The filters remain client-side presentation: no infinite scroll, background
history fetch, cursor, or search query parameter. The current query, outcome,
and revealed count survive live refreshes while the page is open; new matching
results update the count without resetting the view.

Both surfaces are **live**: the existing `GET /api/events` SSE stream
publishes typed events from the 2s runs+inbox watcher — `{"type":"runs"}`
when a run started/finished/settled, `{"type":"inbox","open":N}` when the
open-inbox count changed — and the Activity + Inbox refreshes are
**type-gated** to those events (never a blanket refresh on every SSE
message, never a second fetch storm). The 10s fallback poll carries the load
while EventSource is connecting or retrying; returning to a previously hidden
tab triggers one immediate refresh. Each renderer owns its own
`_lastInbox` / `_lastActivity` JSON-stringify change-guard: on a tick,
the render compares the new payload's JSON against the guard; only if it
ACTUALLY changed does the DOM get touched, and the DOM is built via
`createElement` + `textContent` + `setAttribute` — never `innerHTML` for
model text (defense-in-depth on top of the WP0 capture-time
sanitization). The same change-guard discipline the Automations indicator uses,
applied to
the first new live surfaces since the dashboard redesign.

Only an **open Inbox item** creates a Needs you count or attention indicator.
Answering, dismissing, or automatically resolving it clears that signal while
the run remains in Recent as static history. Running work is derived from its
live execution claim, not from the latest row's historical status.

#### Typed Inbox actions, the sticky reply bar, and `GET /inbox/<id>`

`GET /api/inbox` provides an `actions` list for each open item. The renderer
does not infer attention verbs from nullable job/run ids; a run's stable
detail link remains ordinary navigation. The server re-reads the live item
before every mutation. The advertised capability is a UI contract, not
authorization.

Selecting a **run** item docks the reply bar (`inbox-action-bar`) with four
shortcuts — **Open run**, **Revise**, **Reject**, **Approve** — alongside the
row's **Dismiss** action. Selecting it again deselects it. The bar re-arms when
a fresh fetch confirms the item remains open and clears when it was answered
or dismissed elsewhere. A **Rig login** item does not arm this bar: its sole
action is **Open Computer** → `/rig/peek`, where the operator types the
credential directly and releases control. Credential text never crosses an
Inbox answer or model transcript. A **continuation failure** likewise bypasses
the reply bar: **Retry continuation** atomically resolves the failure item and
requeues the already-saved answer under a fresh bounded retry window.

- **Approve** / **Reject** open the existing confirm dialog, then post the
  CANONICAL answer strings `"approved"` / `"rejected"` to
  `POST /api/inbox/{id}/answer` — the same route + CSRF discipline the
  Answer form always used. These two literal strings are pinned by tests so
  Chat and Activity send the same answer for the same action.
- **Revise** expands an inline textarea in the bar; submitting posts the
  typed text as the answer (the same free-text path the run-detail page's
  answer form uses).
- **Open run** navigates to `/runs/<job_id>/<run_id>` — the same
  destination the row's Answer link points at, useful when you want the
  full report before deciding.

No new mutation route exists. Run replies use the same CSRF-gated
`inbox.answer()` service and create a durable continuation intent. “Recorded”
means the answer is safely stored; it does not falsely promise that a process
already launched. Immediate admission starts the continuation when possible;
a collision or pre-launch failure remains `pending` for bounded scheduler
retry, with `running` / `complete` / `failed` recorded for recovery and audit.
A terminal pre-launch failure creates the typed Retry continuation item rather
than disappearing into logs or pretending the run is queued.

`GET /inbox/{item_id}` is a thin, unauthenticated-shape-preserving
redirect, not a second render path: a known item id 303s to
`/#inbox-item-<id>`, which the landing page's hash router turns into “scroll to
that item and open its valid action surface” (or, if the item was resolved
elsewhere, a toast + a scroll to Needs you instead). An unknown id 303s
straight to `/#card-inbox`.
This is the stable link a PWA push notification's tap lands on (see
below and **[Messaging](/docs/kin/guide/messaging/)**'s push section).

#### Add to Home Screen (PWA)

The landing page is installable: `static/manifest.webmanifest` (name,
`start_url: "/"`, `display: standalone`, the Graphite+ theme color, plus a
full-bleed **maskable** icon pair so Android/iOS squircle masks don't
double-round the self-rounded tile icons) is
linked in the page head alongside an apple-touch-icon and the iOS
standalone metas (`black-translucent` status bar — the page draws its own
graphite under the clock — and the Home-Screen title **Kin**). Every
dashboard document sets `viewport-fit=cover`, which is what makes the
`env(safe-area-inset-*)` padding (composer, action bar, bottom tab bar)
actually clear the notch and home indicator on iPhones. A service
worker registers itself at `/sw.js` on every load (`skipWaiting` +
`clients.claim`, plus a `push` + `notificationclick` handler — D5, the
Push notifications card above). The worker is served from the origin
root rather than `/static/sw.js` specifically so its default scope is
`/` and it can see navigations to `/inbox/<id>`, not just `/static/*`.
The dashboard's CSP (`Content-Security-Policy` on the landing + run-detail
pages only) grants `worker-src 'self'` and `manifest-src 'self'` for
exactly this. The push payload is a typed, content-free envelope.
Compatibility `title` / `body` fields contain only fixed per-kind copy;
question, prompt, report, and other model text never ride the wire. Numeric
fields name the authenticated target (`id` for Inbox, `job` + `run` for a run
update), while the service worker constructs the same-origin path. An Inbox
tap focuses an existing dashboard tab or opens a fresh one at `/inbox/<id>`,
which scrolls to and focuses the live item. Only a run reply item arms the
sticky bar; Rig and continuation-failure items expose their sole typed action
inline.

### Run-detail pages (`GET /runs/{job_id}/{run_id}`)

Each job-backed run has its own server-rendered page at
`/runs/<job_id>/<run_id>` (a separate document — not a landing fetch). The page shows the run
header (automation name, source, state, timestamps, token usage), the markdown
report (sanitized + HTML-escaped, in a `<pre>`), the structured questions
+ an answer form when the run is halted pending input, and the Transcript /
Trace / Files / Diff / Memory inspectors.
Back-link to `/#activity` (the Activity supervision view). The report read is
runs-root-contained: a corrupted `output_path` row pointing outside the
runs root renders the page without report content, never reads an
arbitrary file. Unknown job/run ids redirect to `/` (the link may be
stale). The same deep link is the `outpost_url` the v1 `job.halted`
webhook carries so a receiver can jump straight to the run page, and the
`url` the scheduler's halt-hook delivery event carries to notification
adapters.

#### The Inbox answer flow (resume-by-continuation)

When a scheduled run halts on a question (`needs_human`, exit 2 — the
model called the `ask` tool, or hit an approval/plan gate), the run lands
in the dashboard Inbox (one item per halted run, structured `questions[]`
matching the `job.halted` webhook payload). The run-detail page renders
the questions + a **functional answer form** that posts to
`POST /api/inbox/{id}/answer` (the CSRF-gated human door). Operator-scoped
machine clients use the corresponding bearer-gated
`POST /api/v1/inbox/{id}/answer`; both compose the same store and continuation
service.

Submitting the form:

1. For exactly one current structured question, records the answer and a
   durable `pending` continuation intent on the Inbox item. A second answer,
   stale item, or multi-question bundle is rejected with 409; a “skip” is a
   Dismiss, not an Answer.
2. Attempts immediate admission. If another run owns the job or a pre-launch
   step fails, the scheduler retries the durable intent on later ticks with a
   bounded budget; restart recovery distinguishes safe pre-launch retry from a
   child that may already have produced side effects. While the action remains
   current, the scheduler owns the continuation independently of the HTTP
   request, so a browser disconnect cannot cancel accepted human input.
3. Once admitted, `scheduler.resume_run` launches a real `kin -p --resume
   <session_id> -- <framed answer>` subprocess that **continues the
   halted session by resume-by-continuation**: the saved journal already
   contains the model's `ask` tool call + its structured needs-human
   tool error, so the model sees its own question, the error, then the
   framed answer — a natural continuation. **No history is rewritten.**
4. The resumed run stamps `resumed_from_run_id` (the lineage of the
   original halted run); on its terminal completion it fires the
   `job.terminal` webhook (closing the §9.1 lifecycle). The new run's
   report replaces the halt's on the run-detail page after a reload.

The continuation lifecycle is explicit (`pending` / `running` / `complete` /
`failed`). The UI confirms that an answer was **recorded**; it does not claim a
new run is queued or running until the durable state says so.

Response history and action authority are separate. An item can remain
`answered` for audit while its validity becomes `superseded` or `invalidated`,
with a durable timestamp/reason and optional superseding run. Cancel retires
every active item/intent for that job and revokes unspent approval capability;
a newer run or re-halt supersedes older items. Answer, cancel, admission, run
creation, and capability redemption share one jobs.db writer lock, so whichever
commits first is the single durable winner. Restart recovery sees only active
pending intents. Delayed human input is authority, so it must expire when its
action loses ownership rather than resurrecting historical work.

Deployment is an additive jobs.db migration: first open adds the permanent job
lifecycle state/generation and Inbox validity/audit columns in place. Legacy
rows that already lost ownership are backfilled fail-closed from durable evidence
(`job_cancelled`, `run_terminal`, `missing_owner`, or `newer_run`), pending
intents are retired, and issued capabilities are revoked. No row is deleted and
there is no operator-run migration command.

One narrow approval case carries its answer into execution. When the item is
exactly one root `git-push` approval and the canonical answer is `approved`,
Outpost grants only that exact structured call once. The grant is random,
short-lived, bound to this item/session/continuation/workspace, and burned
before the resumed model starts. It is not an “always allow” grant and never
enters the journal. Changed arguments, a repeated call, a subagent-origin
approval, expiry, restart ambiguity, or a broker failure all fall back to the
normal gate and re-halt. Other answers resume as context but authorize no tool
call automatically.

The framed answer is built server-side from the inbox item's
`questions_json`:

```
[Operator answer to your pending question(s)]
Q1 (<header>): <your answer>
Continue the task; do not re-ask.
```

If the resumed run **re-halts** (the model asks a follow-up question), a
NEW inbox item is created and older items are explicitly superseded — the
operator can answer the new one, same flow. A resume does NOT advance the
job's schedule (the original halt already advanced `next_run_at`); it's a
continuation, not a new scheduled fire.

Historically verified on the retired tailnet vLLM (`Qwen3.6-35B-A3B-FP8`) — the model
uses the framed answer and completes, 5/5 in the pass^5 spike
(`research/2026-07-05-wp2-resume-spike.md`) + the scheduler-chain proof
(`scripts/live_proof_resume_vllm.py`). See `docs/decisions/0024-resume-seam.md`
for the design + the why-not-history-rewrite rationale.

#### The Transcript pane (live journal tail)

The run-detail page's Transcript pane is the operator's window into a
live run. It tails the run's append-only JSONL journal
(`<kin_home>/projects/<workdir-slug>/<session_id>.jsonl` — the same
file the resume-by-continuation flow replays from) over a per-pane SSE
stream. Each user / assistant / tool_call / tool_result record arrives
as a sanitized render-event; the pane is empty while the run is fresh,
fills as `kin -p` writes, and closes with an `eof` frame once the run
row reaches a terminal status AND the journal stops growing.

How it works:

- The client opens `EventSource("/runs/<job_id>/<run_id>/transcript")`
  on pane mount and tears it down on `beforeunload` (per-pane channel
  — the LANDING SSE spine stays untouched; row 9).
- The status pill (`connecting` / `live` / `error` / `eof`) makes a
  stuck connection visible at a glance; an EventSource auto-reconnects
  flip it from `error` back to `live` on the next successful open.
- When the stream ends (the `eof` frame — the run reached a terminal
  status), the page header refreshes in place: the state badge, the
  State / Finished cells, the Stop button's visibility, and a quiet
  "report ready — reload to view" link when the run wrote a report the
  page rendered without.
- Autoscroll pins to the bottom unless the user has scrolled up (a
  24-px slack flag in the scroll listener — the standard chat-log
  posture). While following is paused and new events land below the
  fold, a quiet "↓ following paused — jump to latest" chip overlays
  the list's bottom edge — click it (or scroll back down) to resume
  following; it disappears on `eof`.
- Every render-event is appended via `createElement` + `textContent`
  + `setAttribute` (DOM-API only — defense-in-depth on top of the
  server-side sanitization + 4 KB cap per event). The change-guard
  (`_lastTranscriptSeq`, a JSON-stringify key) compares each new
  payload's JSON against the previous frame; only if it ACTUALLY
  changed does the DOM get touched — same discipline as the landing
  page's `_lastBadge` (row 8).

What the pane shows:

- **assistant text** — `kind: text`, role chip in the reason-violet
  color.
- **user text** — `kind: text`, role chip in the primary-cyan color
  (carriers that are pure tool_results with no text block produce no
  row — the matching tool_result surfaces on its tool_use line).
- **tool_call** — `kind: tool_call`, formatted as `name(args)` with
  the args sanitized + 4 KB-capped.
- **tool_result** — `kind: tool_result`, formatted as `(result) <text>`
  (or `(error) …` when the result carried the error flag).

Security:

- The session_id is gated by a charset regex (`[A-Za-z0-9_.-]{1,128}`)
  that refuses path separators + control characters BEFORE the
  filesystem is touched.
- The journal path's realpath is checked against the projects root
  (`<kin_home>/projects`) — a corrupted row pointing outside the
  projects root is rejected; an arbitrary-file read is structurally
  impossible (row 13).
- Every text render-event is sanitized via `sanitize_model_id` at
  the parse seam (C0/DEL/C1/bidi/zero-width/BOM stripped) AND
  capped at 4 KB per event. A hostile model can't balloon the SSE
  stream (row 14).

The pane ships with the Transcript pane filled; the three sibling
panes (Trace / Files / Memory) plus a new Diff pane land in WP8 —
see the next section. See `docs/decisions/0028-transcript.md` for the
design + trade-offs + the ship-without-it rule (the polling posture
is the hedge against kloud's overlay-FS inotify quirks).

#### The Trace pane (spans JSONL timeline)

WP8 fills the Trace placeholder from WP0.4. The pane reads the same
metadata-only span JSONL the harness already writes next to the
journal (`<project_dir>/<session_id>.spans.jsonl`, opt-in via
`KIN_SPANS=1` / `spans_enabled = true` — `src/kin/harness/spans.py`).
The spans are OTel-GenAI-shaped: one JSON object per FINISHED span,
fields `name` / `kind` / `trace_id` / `span_id` / `parent_span_id` /
`start_time_unix_nano` / `end_time_unix_nano` / `status` / `attributes`.
**No prompt text, no tool args/results** — timing + names + token
counts only, so the render path is plain `textContent` (defense-in-
depth on top of the metadata-only guarantee).

The pane branches on the JSONL's parent-linkage presence:

- **Branch A — tree** — when ≥2 spans exist AND at least one span's
  `parent_span_id` matches another span's `span_id`, the renderer
  nests by parent. A `tool` span hangs under its `chat` span, which
  hangs under its `invoke_agent`. Real parent/child structure when
  the spans carry the linkage.
- **Branch B — flat timeline** — when parent ids are absent (a root-
  only file, or a future schema variant), the renderer falls back to
  a flat time-ordered timeline grouped under each `invoke_agent`. An
  "N orphan span(s)" note surfaces any parent_span_id that's set
  but whose parent isn't in the file (a partial tail or a streaming
  artifact).

The explicit **Load spans** button is keyboard-operable; clicking the pane is
retained as a pointer convenience. The fetch is one-shot (NOT SSE — the spans
file is append-only + complete once the run terminates; live tail belongs to
the Transcript pane which already covers journal feeds). When
`KIN_SPANS` is OFF the file is absent; the route returns
`{present: false, mode: "none"}` and the pane renders an empty-state
honestly. Every render uses `createElement` + `textContent` +
`setAttribute` only (row 8); the change-guard `_lastTraceKey` is a
JSON-stringify of the most recent payload — same shape as
`_lastTranscriptSeq`.

Path containment is structural: the spans file path comes from
`kin.harness.persistence.project_dir(workdir)` + session_id; the
chassis regex (`[A-Za-z0-9_.-]{1,128}`) refuses separators + control
chars before the filesystem is touched; the realpath check
(`_is_under`) rejects any path outside the projects root.

#### The Files pane (Project tree + text preview)

The Files pane reads the run's workdir through a depth-capped tree
walk + a text-file preview route. Two endpoints, both human-door
(behind the SSO gate via the dashboard's `/api/*` prefix):

- **`GET /api/workspaces/{name}/tree?path=`** — recursive listing,
  depth-capped (8), entry-capped (500), hidden-dot dirs + entries
  skipped. **Symlinks are skipped, NOT followed** — a planted link
  inside the workspace could point at `/etc/passwd`, and silently
  following it via `os.listdir` would expose external content.
- **`GET /api/workspaces/{name}/file?path=`** — text preview ≤ 256 KB.
  Binary detection (NUL byte in the first 8 KB) → `kind: "binary"` +
  typed notice. Oversize → `kind: "oversize"` + typed notice. **Bytes
  NEVER cross the wire** for non-text or oversized content — the run
  page cannot become an arbitrary-file viewer.

The pane behaves as a small file browser rather than an eagerly expanded
listing. Folders are keyboard-operable disclosures with an accessible expanded
state, and the initial view keeps descendant content collapsed. Opening a
nested file preserves its complete relative path; that path appears as quiet
context above the preview with a copy action. Sizes use human units. Long names
lead with the recognizable filename and retain the complete value through
focusable/copyable context rather than a hover-only tooltip.

The tree and preview keep filesystem identity separate from presentation. The
JSON `path` is the exact relative fetch key; `display_path` is the separately
sanitized human label. Spaces and ordinary Unicode remain intact. When hidden
control, bidi, or zero-width characters make those values differ,
`path_copyable` disables copy rather than putting a deceptive value on the
clipboard. File bodies use the multiline `sanitize_block` path so newlines and
tabs survive, then the browser assigns the body through DOM `textContent`.

Path containment reuses the WP10 snapshots primitive
(`_safe_under_root` for the workspace name, `_safe_path_under` for
the exact nested `path`). `_valid_relative_path` is the lexical first gate: it
accepts real filenames with spaces and ordinary Unicode, but rejects absolute
paths, NULs, backslashes, paths over 4096 characters, empty components, and
`.` / `..` components. `_safe_path_under` is the second, authoritative
realpath guard; it catches symlinks that resolve outside the workspace.

The run page derives its Project only when the job's workdir realpath exactly
matches that workspace under `WORKSPACE_ROOT`. A shared basename is not
identity: a run in `/tmp/demo` cannot make `/workspace/demo` readable. For an
outside workdir, Files stays unavailable rather than guessing a Project.

#### The Diff pane (journal edits + git diff vs HEAD)

The Diff pane is honest about its labels. It stacks two sections,
both with explicit captions so the operator never confuses them:

- **"files this run edited (from the journal)"** — parsed from the
  run's journal (cross-provider: OpenAI flat `{role, content,
  tool_calls}` AND Anthropic block-list `{content: [{type: tool_use,
  ...}]}`). Edit-tool allowlist = `{write_file, str_replace, insert,
  create_file, edit_file}` — `read_file` / `ls` are NOT surfaced
  (they don't mutate state). Each entry carries the tool name + a
  4 KB-capped preview of the intended contents.
- **"working tree vs HEAD"** — `git diff --no-color --no-ext-diff -M -C
  --unified=3` + `git status --porcelain` on the workspace, run on
  the executor (30s timeout, 512 KB cap enforced while Git is running, with
  stdout and stderr drained together). When `.git` is missing →
  `is_repo: false` + typed notice. When the diff is empty (working
  tree matches HEAD) → empty-state.

**Per-run exact diffs ride WP10 snapshot refs** — the WP8 diff pane
deliberately does NOT claim to be "this run's diff". WP10's
`refs/kin-snapshots/<ts>` model gives true per-run byte-identical
diffs; WP8 ships the working-tree-vs-HEAD view because that's what
the run page can compute today without depending on a snapshot.

The route is `GET /runs/{job_id}/{run_id}/diff` — a single fetch
returns BOTH sections in one round-trip. Journal previews, git diff, and git
status use the multiline `sanitize_block` path and render into `<pre>` nodes
with DOM `textContent`; one-line path/tool labels use the bounded label
sanitizer. The same exact-workdir identity guard applies here: an outside run
with the same basename still exposes its contained journal-derived edit list,
but never borrows the unrelated workspace's git diff.

#### The Memory pane (gated body-crossing surface)

The Memory pane is the FIRST body-crossing exception to the memory
API's stats-only posture — intentionally narrow. See
[`memory.md`](/docs/kin/guide/memory/#on-outpost) for the full contract; the short
version:

- The pane carries a persistent **"Agent-written content — treat as
  data"** banner.
- `GET /api/memory/search?q=` returns top-10 recall (path, category,
  title, snippet) — body NEVER crosses the wire.
- `GET /api/memory/body?path=` returns the body through the SAME
  `store.resolve_path` containment guard the `memory` tool uses
  (backslash, URL-encoded, `..`, dot-leading, symlink-escape all
  refused at the seam). 64 KB cap on body size; bytes NEVER cross
  for oversized content.
- **One `audit_log` row per body read** (action `body_read`, target
  the memory rel-path).
- Both endpoints are human-door only (under `/api/`, NOT `/api/v1/`)
  — the v1 machine door's wire contract is unchanged.

Recall rows are native disclosure buttons, so click, ++enter++, and ++space++
all load the body in a labeled preview block below the row. The explicit
**Load recall** button is likewise keyboard-operable.
The preview is sanitized via `sanitize_model_id` at the route seam (row 14)
and assigned through DOM `textContent`. The change-guard `_lastMemoryKey` is a
JSON-stringify of the most recent payload (row 8).

See `docs/decisions/0031-run-inspection.md` for the full design +
the four-pane verification matrix.

### Truthful state projection

Outpost keeps five facts separate instead of collapsing them into one automation
badge:

| Fact | Authoritative source | Visible meaning |
| --- | --- | --- |
| Attention | Open, active Inbox items | Needs you now |
| Execution | Live run claims | Queued, running, paused, or settled |
| Schedule | Job definition | Active, paused, disabled, next run |
| Last result | Latest settled run | Historical outcome |
| Connection | EventSource transport + successful poll | Live, polling, or offline |

Only the first row creates a Needs you count, navigation indicator, or
attention ordering. A dismissed, answered, superseded, or invalidated
`needs_human` run remains visible as history but becomes static; Activity
never mirrors a historical Jobs result into present-tense attention. Real
running work may still mark Activity and Automations as active. The **browser
title** follows the same authority:
`● N need you — Outpost` for open Inbox work, `▸ running — Outpost` for real
execution, and plain `Outpost` at rest. See
[docs/decisions/0021](https://github.com/kinra-ai/kin/blob/dev/docs/decisions/0021-lively-tab-titles.md),
[docs/decisions/0023](https://github.com/kinra-ai/kin/blob/dev/docs/decisions/0023-outpost-activity-inbox.md),
and [docs/decisions/0107](https://github.com/kinra-ai/kin/blob/dev/docs/decisions/0107-outpost-truth-and-information-architecture.md).

### Automations — Projects (`card-workspaces`)

A Project is the place whose files an automation can use. Existing
workspace slugs receive a conservative presentation fallback (`weekly-repo-health`
becomes `Weekly repo health`) without renaming the directory. The exact slug
and `/workspace/<slug>` path remain available in Details and copy/accessibility
text. Outpost does not store a second display-name alias: clone, fork, snapshot,
and API identity continue to use the one workspace name.

The underlying workspace table exists so automations (and a manual
clone/create) have somewhere to run, nothing more. Each `/workspace/*`
directory is a row showing cheap stats — its git branch
(parsed from `.git/HEAD`, no subprocess), relative mtime (hover for the
absolute time), and entry count — with **Snapshot** (floppy-disk),
**History** (snapshot restore/fork/delete), and **Delete** (trash)
buttons. **Delete** (`DELETE /api/workspaces/<name>`, CSRF-gated)
`rmtree`s the directory after two guards: a `realpath`-under-`/workspace`
check (same as create) and a **refuse-if-running** check — it returns
`409` if any *scheduled job currently running* has its workdir inside the
workspace (wait for it to finish, or cancel it from Automations first),
so it can't orphan a running job. The **+ New project** action creates a
workspace directory (mkdir; DNS-1123 name regex
`[a-z0-9][a-z0-9-]{0,62}` + realpath traversal guard); it and **Clone a
repo** (`gh repo clone`) live in the section footer.
CSRF-gated. The surface refreshes on a 10s poll that pauses while the tab
is hidden, keeps the last-known-good rows through a transient blip (the
header's **polling / offline** control appears when connection health
degrades), and — when
the oauth2-proxy cookie expires — swaps the whole failure mode for a
single **"Session expired — sign in again"** banner with the polls
suspended. A **server-sent-events push channel** (`GET /api/events`,
consumed by the landing page via `EventSource`) delivers job/inbox
changes in ~2s instead of the 10s cadence (the `runs_watch.py` watcher +
the create/delete/clone handlers publish instantly). The 10s poll stays
as the fallback + an independent liveness check (the broad refresh skips
while EventSource is open);
SSE streams through oauth2-proxy with `X-Accel-Buffering: no` so proxies
don't buffer it.

The dashboard surfaces the logged-in identity (the verified Pocket ID
email) with a **Sign out** link — in the desktop sidebar rail and the
mobile top bar on the landing page, and in the run-detail page header.
**Sign out** navigates to oauth2-proxy's `/oauth2/sign_out`, which clears
the proxy cookie and then redirects (`rd`) through Pocket ID's
end-session endpoint to end the IdP session too — so the user actually
signs out rather than sailing straight back in. (That `rd` redirect only
fires because the IdP host is in oauth2-proxy's `--whitelist-domain`;
without it the redirect is silently dropped and the Pocket ID session
lives on. Both halves derive from the same `OUTPOST_OIDC_ISSUER_URL`
env var — the whitelist entry in `start.sh` and the Sign out `rd`
target in the dashboard — so an issuer override moves issuer,
whitelist, and sign-out together.) This is the *voluntary* sign-out
affordance — the complement
to the reactive "Session expired" banner above, which fires only when
the cookie expires on its own.

> **Historical (retired 2026-07-06, middle-way plan Step 3,
> `docs/decisions/0033-outpost-automation-layer.md`):** this tab used to
> be **Workspaces & Sessions** — every workspace row also carried a
> **+ New session** button, with live tmux sessions nested underneath
> (status dot for transport `starting`/`alive`/`dying` + kin-level
> `waiting`/`running`/`idle`, a **Kill** button, an **open** link to a
> Graphite+ terminal chrome at `GET /session/<id>/view`), plus an
> **Ended sessions** drawer (an additive sqlite sidecar at
> `~/.kin/outpost/history.db`) with **Restart** + relabel, and a header
> **Upload** modal (`POST /api/sessions/<id>/upload`). The chrome owned an
> xterm.js `Terminal` (vendored `@xterm/xterm` + addons) talking to a WS
> reverse-proxy in front of a per-session `ttyd -W -O -m 1` wrapping a
> `tmux kin-<id>` session. All of it — `sessions.py`, `proxy.py`,
> `status.py`, `status_watch.py`, `history.py`, the vendored xterm assets,
> `tmux.conf`, the header session badge, `/api/sessions`, `/api/history`
> — is deleted wholesale. `GET /runs/{job_id}/{run_id}` (above) is now the
> only per-run deep-link page. The escape hatch for a real interactive
> shell is `ssh kloud` + `docker exec` — an operator path, not a product
> surface.

### Automations — Your automations and Recipes (`card-jobs`)

The ordinary composer asks what Kin should do, which Project it should use,
when it should run, and a friendly name. Common schedules use human choices
with a plain-language next-run preview. Mode, timeout, retries, token budget,
model pin, and raw cron remain available together under **Advanced options**;
editing uses the same form and preserves untouched exact values.

Using a Recipe asks only for that recipe's configuration. Its Project field is
a friendly select whose option value remains the exact workspace path. For a
schedule-bound Recipe, the bundled schedule is shown in Technical details and
is not changed in the enable dialog; after Outpost creates the automation,
Edit opens the normal composer where its schedule can be changed.

At rest, each row leads with the presentation name, readable schedule,
Project, current execution or attention truth, last result, and next run.
Slug-shaped legacy names may render with the same conservative fallback as
Projects, but editing and APIs retain the exact stored name. Raw workspace
path, cron expression, mode, model id, and recipe id/version live in Details
unless one explains a current problem; long values never displace actions and
remain recoverable without hover.

The saved list defaults to **All automations** and shows six rows initially.
Matching automations that are running or need attention are pinned ahead of
ordinary rows; if those present-tense rows alone exceed six, none are hidden.
**Show more** reveals six more and **Show fewer** returns to the bounded view.
**Find an automation** searches friendly and exact names, Project and exact
workspace path, readable and raw schedule, time zone, Recipe, model pin, and
last result. The **View** menu selects Needs you, Running now, Scheduled,
Paused, Needs fixing, or Finished without inventing one aggregate status: an
automation can truthfully satisfy more than one of those independent axes.

The result count describes the rows already returned by `GET /api/jobs` (the
complete dashboard list under the existing 100-automation structural cap).
Filtering and progressive disclosure happen in the browser; they add no API
fields or pagination and never change a stored automation. A no-match state
offers **Clear filters**, while a genuinely empty list keeps the next-step
guidance described above. Filter choices and the revealed count survive live
refreshes for the current page.

Under that presentation, each automation remains a recurring (or one-shot)
**headless `kin -p` job** in a workspace, driven by the dashboard's own 60s
tick over the WAL sqlite job store (`~/.kin/outpost/jobs.db`). **Run now**,
pause/resume, run history, model re-pin, edit, and delete retain their existing
contracts. Creation, catch-up coalescing, single-flight,
retries + dead-lettering, the fail-closed model-drift pin, and needs-human
handling are all documented on their own page — **[Scheduled
automations](/docs/kin/guide/scheduled-jobs/)**. Job alerts (needs-human / dead-letter / drift)
ride the same delivery.send seam as session notifications (see
[Messaging](/docs/kin/guide/messaging/)). Job CRUD is
dashboard-only by design — there is no harness tool for schedule management,
so a scheduled agent can't schedule agents.

### More → Advanced — Services and triggers (`card-services`, `card-triggers`)

The Advanced area is the **browser-based front door** for the machine door
(`/api/v1/`). It lets you register services, inspect webhook deliveries,
replay dead-lettered events, and manage triggers — all behind the Pocket
ID SSO gate (the human door), strictly separate from the Bearer-token machine
door. The same Advanced area owns per-service inbound-trigger management.

- **Services** (`card-services`) — lists every registered service with its
  public id, name, webhook-secret badge, and disabled state. **Register new
  service** opens a form (name + allowed workspaces + optional webhook URL)
  that mints the bearer token (`svc_<id>.<secret>`) + the webhook signing
  secret (`whsec_…`) and shows them **exactly once** in a copy-panel with the
  warning "Store these now — they cannot be retrieved again." Click **I've
  stored these** to dismiss the panel. This mirrors `kin service create`
  byte-for-byte (PROTOCOL §2.2 — secrets can never be retrieved again;
  rotation mints new values).

  Per-service **Regenerate** (confirm-modal) re-mints both secrets — the old
  bearer token stops authenticating immediately (the live
  `secret_ciphertext` is overwritten; `prev_secret_ciphertext` is preserved
  for KEK-rekey continuity only). **Disable** / **Enable** toggle the
  service's auth (disabled → 401 `revoked-token` on the v1 door). Each
  credential-sensitive action writes an `audit_log` row.

  Expanding a service shows its **deliveries** (the per-service webhook
  outbox — event, state, attempt count, delivered-at, DLQ reason). DLQ rows
  carry a **Replay** button (confirm-modal) that resets the row to pending
  with the original `webhook-id` preserved (PROTOCOL §10.5). The deliveries
  table + services list carry a focus guard: if you're keyboard-focused
  on a button inside the card, an SSE tick won't rebuild the DOM under you
  (no mid-press element rip-out).

Raw curl examples do not live in the authenticated control plane. Use
`PROTOCOL.md` and this guide for the versioned machine-door contract, keeping
operational UI focused on live services, deliveries, and triggers.

The machine door itself (`/api/v1/`) is unchanged by this area. The Advanced
portal adds only human-door routes under `/api/services/*` (CSRF + SSO); the
v1 middleware chain, endpoints, and the 30 PROTOCOL acceptance criteria stay
exactly as they were.

- **Triggers** (`card-triggers`) — per-service inbound webhooks with
  signature-auth receivers: an external source (GitHub, Slack, generic
  curl) fires a governed one-shot job by POSTing a signed payload to
  `POST /api/v1/triggers/{id}`. Managed from this card (create /
  regenerate / disable / delete); see the dedicated
  [Triggers](/docs/kin/guide/triggers/) guide for the full surface — the
  two-halves-of-a-key model, per-source setup, the fire path, and the
  safety model.

### More → Settings

The cards sit in four groups: **Model & keys** (Providers, Endpoint,
Model & tuning), **Integrations** (GitHub, Push, MCP Servers),
**Memory**, and **Reliability** (Governor).

- **Providers** (`card-providers`) — one row per configured provider: the
  built-in presets (direct OpenAI/Anthropic, MiniMax, Z.ai) plus any custom `[[providers]]` rows. Each has
  a **paste-key field + Save**, a green **✓ key set** badge, a **● default** badge
  when it's the active launch provider, and a **get a key ↗** link. Keys are
  stored per-provider in a `provider_keys` settings table (`{<preset id>:
  "<key>"}`) — so MiniMax and Z.ai each keep their own key, and the factory
  resolves `provider_keys[active preset]` ahead of the generic `api_key`. The
  table is GLOBAL_ONLY + secret: a cloned repo's project file can neither inject
  nor read it, it's dropped from the `/api/settings` read entirely (only the
  `has_key` boolean is exposed, via `GET /api/providers`), and it's redacted
  wholesale from any `/edit-settings` diff. **Set as default** writes
  `provider_preset`. Tier-aware providers also show a capability selector in
  `provider_tiers`: MiniMax and Z.ai size ordinary subagent capacity for the
  selected plan. Saving a key
  *stores* it — it isn't verified here (use
  Endpoint's Test for that). A **trash** button on a has-key row deletes that
  provider's stored key (POSTs an empty value; the table merge treats empty as
  delete). CSRF-gated. The card also hosts the **Web search
  (Brave)** key — an external service credential (not an LLM provider, so it's a
  separate row below the provider list) with the same **paste-key + Save** and a
  green **✓ key set** badge driven off the `[SET]` mask. It's a `_SECRET_SETTINGS_KEYS`
  value (`brave_api_key`), masked to `[SET]` on read.

- **Endpoint** (`card-endpoint`) — the custom / vLLM raw endpoint: `base_url` +
  `api_key` (the generic key for a non-preset endpoint) + **Use this endpoint**
  (clears `provider_preset`). The **Test connection** button calls
  `_fetch_model_ids` from `kin.harness.backends.base` (the same probe the
  `/model` picker uses) with a link-local SSRF guard (`169.254/16` + `fe80::/10`
  only — the Tailnet inference host's 10.x/100.x address is allowed because the dashboard's
  threat model is "user configures their own endpoint", not "untrusted fetch").
  It now shows a loading spinner, then a prominent green/red result box (reachable
  + model list, or the error) instead of a one-line note. The probe is an
  *unauthenticated* `/v1/models` GET — meaningful for the OpenAI-compat/vLLM
  endpoint, not the bearer presets (which is why Test lives here, not on the
  Providers card).
- **Model & tuning** (`card-model`) — `model`, `context_window` (e.g. `1000000`
  for a 1M-context tier), `max_tokens`; an **Advanced sampling** disclosure
  (`temperature`/`top_p`/`top_k` — OpenAI-compat wire only; `effort` — per-serve;
  `enable_thinking` — OpenAI-compatible or an explicit Poolside profile); a `search_enabled`
  toggle for the Tier-1 FTS5 [`search_workspace`](/docs/kin/guide/workspace-search/) index;
  and the launch **mode** (`auto`/`strict`). (The optional `brave_api_key` for web search moved
  to the **Providers** card — it's an external service credential.) Values are
  validated server-side (`mode ∈ {auto,strict}`, numerics coerced to type). All
  go through `kin.harness.settings.save_global_settings` — the single source of
  truth, the same writer the in-TUI `/edit-settings` modal uses; `api_key` is
  masked to `[SET]` on read, the user re-types to change it. All mutating routes
  are CSRF-gated via `X-CSRF-Token` ↔ `_dashboard_csrf` cookie (SameSite=Strict).
- **GitHub Connect** (`card-github`) — an optional card that links the box's
  `git push` + `gh` to a GitHub account via **OAuth Device Flow** — no client
  secret, no callback URL, no redeploy to connect. Click **Connect GitHub**;
  the card shows a one-time `user_code` to enter at `github.com/login/device`
  (a server-side poller redeems the token; the `device_code` never reaches the
  browser). On success the token is stored via `gh auth login`, and the card
  offers **Disconnect**. The token persists on the `outpost-gh` volume;
  `~/.gitconfig` is ephemeral so `start.sh` re-runs `gh auth setup-git` at boot
  when a token is present. Requires the OAuth App's **Device Flow** enabled (see
  the secrets section above); `device_flow_disabled` surfaces as a card error
  naming the fix. `POST /api/github/device` + `/disconnect` are CSRF-gated.
- **Push notifications** (`card-push`) — a browser Web Push subscription
  that fires a content-free notification for a new inbox item or alert and
  navigates back to the authenticated Outpost. Dormant until the box
  has a VAPID key pair — generate one and drop the private half in as a
  Docker file secret (never an env var):

  ```bash
  uv run --extra outpost python -m py_vapid --gen   # writes private_key.pem + public_key.pem
  mv private_key.pem container/secrets/vapid-private-key
  chmod 600 container/secrets/vapid-private-key
  rm -f public_key.pem   # the dashboard derives the public key from the private one at request time
  ```

  Redeploy (`task outpost:deploy-dev` / `-main`) so `start.sh` normalizes
  the new secret into `VAPID_PRIVATE_KEY_FILE`. The card's badge flips
  `dormant` → `ready` and shows the current subscribed-device count; the
  **Subscribe this browser** button requests notification permission,
  calls `pushManager.subscribe` with the card's public key, and POSTs the
  resulting endpoint + keys to `/api/push/subscribe`. `VAPID_SUBJECT`
  (compose env, not a secret — an operator contact URI) defaults to
  `mailto:support@kinra.ai`; override per deployment. Full lane details
  (payload shape, the iOS constraint) are in **[Messaging](/docs/kin/guide/messaging/)**.
- **MCP Servers** (`card-mcp`) — add/enable/disable/delete entries in the
  **global** `~/.kin/mcp.json` (`container/dashboard/mcp_api.py`): paste one
  server's `mcpServers` JSON snippet (the same shape its README shows) +
  Save; each listed row shows the masked endpoint (never a resolved secret —
  every read goes through `kin.harness.mcp.safe_endpoint`/`masked_headers`)
  with an enable/disable toggle, an **edit** pencil (prefills the add-form with
  a masked spec — secrets shown as `[SET]`, which round-trip back to the on-disk
  value on save; a new literal replaces), and a delete button. See
  [MCP servers](/docs/kin/guide/mcp/#adding-servers-from-the-outpost-dashboard) for the
  full shape + security notes. CSRF-gated; applies to sessions started after
  the change (an open terminal needs a restart).
- **Memory** (`card-memory`) — the agent's persistent cross-session
  [memory](/docs/kin/guide/memory/) (`container/dashboard/memory_api.py`): the
  `memory_enabled` on/off toggle (new sessions only), the memory root, and
  per-category stats with a file list. **Names, titles, and stats only** —
  memory file *bodies* never cross this API (read them in a session with the
  `memory` tool). The store lives under `~/.kin/memory/` in the `outpost-kin`
  named volume, so memories survive redeploys. CSRF-gated toggle.
- **Governor** (`card-governor`) — provider-independent admission control over
  background runs. The card's **Background limits** section shows the live
  census and tunes api/scheduled max-running caps, queue depth, and per-class
  token/hour budgets for the next admission. The optional waiting-queue badge
  is unavailable on the current DS4 fleet; it activates only when an operator
  explicitly configures a compatible vLLM metrics endpoint. Full semantics,
  including the dormant vLLM compatibility switches, are in
  [The governor — cross-run GPU fairness](/docs/kin/guide/scheduled-jobs/#the-governor-cross-run-gpu-fairness).
