Kin / Maintainers

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.

Read as Markdown

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

The scheduler/governor details are in Automation operations; persistent-computer deployment and VM detail are in Rig operations.

For moving active work between a local session and Outpost, see Beam work to Outpost. /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.

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 <(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.ai172.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):

    $ 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. Deploytask 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 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.

# 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, 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.ai172.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 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-signed job.terminal event on every terminal transition, and job.halted (with the structured needs_human questions from the run’s headless sidecar) 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.
  • 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.
  • 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 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’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 textkind: text, role chip in the reason-violet color.
  • user textkind: 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_callkind: tool_call, formatted as name(args) with the args sanitized + 4 KB-capped.
  • tool_resultkind: 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 = truesrc/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 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, docs/decisions/0023, and docs/decisions/0107.

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) rmtrees 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. Job alerts (needs-human / dead-letter / drift) ride the same delivery.send seam as session notifications (see 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 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 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):

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

  • 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 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 (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.