Skip to content

Outpost

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. Workspaces you create or clone, scheduled jobs (kin -p on cron/every/at) that run inside them, a unified Activity feed + inbox for anything that needs you, and a Matrix bot so you hear about it without opening a browser. 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, the Activity feed + inbox, the Matrix messenger, 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.

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-textual, and stages the five 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.

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-textual/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

Two are optional:

  • container/secrets/kin-settings — seeds ~/.kin/settings.toml on first boot only. Point it at the tailnet vLLM (base_url = "http://kin-one:8000/v1", model = "default") so the box reaches a model out of the box. Later in-session settings edits (the /config modal, or the dashboard's Endpoint form) live on the volume and survive restarts. compose requires the file to exist, but task outpost:deploy-* self-heals an empty one if you don't drop it (configure via the dashboard's Endpoint card on the landing page after sign-in).
  • 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 kin-settings. 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. The deploy also builds the publish docroots (the mkdocs site + the get.kinra.ai installer bundle) in the build worktree and brings the compose publish profile up alongside: two nginx:alpine services, get on :7018 (get.kinra.ai — page, install scripts, wheel + simple/ index, sha256s) and docs on :7019 (docs.kinra.ai, the public user guide). 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-textual); OUTPOST_PUBLIC_URL and OUTPOST_BIND_IP override the deploy values above.

Lifecycle

There is one container and one subdomain. Deploying 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 --extra browser --locked from the build context into /opt/kin/.venv (the dashboard, the kin command, and a tiny aiohttp back-end share the venv). The image also bakes Chromium (headless shell) for the browser toolplaywright install --with-deps --only-shell chromium into a fixed PLAYWRIGHT_BROWSERS_PATH — so browsing works out of the box; the disposable container is exactly the blast radius a hostile page deserves (kin launches it with --no-sandbox in-container: Docker's default seccomp blocks the user namespaces Chromium's own sandbox needs, and KIN_SANDBOX=container already declares the container the boundary). /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-textual 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 + settings.toml, seeded first-boot-only from the kin-settings secret), 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 Jobs-tab 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 the Jobs tab) 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 the Jobs tab, 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 the vLLM The box runs NO tailscale of its own — outbound to kin-one:8000 is NAT'd through the kloud HOST's tailnet. task outpost:remote-shell then curl http://kin-one:8000/v1/models; if MagicDNS doesn't resolve kin-one, point kin's base_url at kin-one's tailscale IP
/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, Matrix, sign-in — even though the container itself is healthy A CrowdSec ban of the box's own egress IP, not just a browser's — bites when the docker host shares a NAT/premises IP with other traffic (e.g. an office/home network). The ban 403s every kloud-vps-fronted hostname the box calls outbound too (Pocket ID's OIDC discovery, the Matrix /sync loop — see Messaging), not just inbound requests. Check the CrowdSec decisions on kloud-vps for that egress IP and unban / allowlist it if it's trusted infra.
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 over Matrix, R4/R7 of the middle-way plan). Different kinds of work, connected thin — never two windows onto one Kin.
  • Not an IDE. It's a workspace manager + a scheduler + a run inbox.
  • 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 / — a sticky header over five tabs: Activity (the unified feed of every scheduled/API/triggered run + the actionable Inbox), Run (workspaces), Jobs (scheduled jobs), Developers (the service registry + API examples — the browser-based front door for the machine door), and Configure (providers, endpoint, model, GitHub, notifications, MCP, memory). The header also carries the connection pill with three honest states — live (the 10s poll and the SSE push channel are both healthy), polling (static amber: the push channel is stale or down, so updates ride the 10s poll), and offline (the poll itself is failing) — and hovering it shows the time of the last good update. Deep links survive a refresh and the back button: #activity / #run / #jobs / #developers / #configure land on each tab; a legacy #card-<id> bookmark still lands on the right card inside the right tab; any unknown hash falls back to #activity (the home tab).

Activity tab — Activity feed + Inbox (card-inbox, card-activity)

Activity is the dashboard's home — the cross-source feed of every kin run plus the actionable queue for runs that need you. Two cards side by side:

  • Needs you (card-inbox): the open inbox items — runs that halted pending a question, approval, plan, or proposal. Each row shows the first question's header, the workspace, a relative age chip, the full question body, an Answer link (deep-links to the run-detail page /runs/<job_id>/<run_id> — inline-answer ships with the resume seam), and a Dismiss button (moves the row out of Needs you; the row stays for audit). Tapping anywhere else on a row selects it and arms the sticky action bar (see below). The count badge next to the header mirrors the Activity tab's "N need you" suffix. Data source: GET /api/inbox (human door; {items, open_count}; 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 amber pulse 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), the title (a deep link to /runs/<job_id>/<run_id>), 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 source: GET /api/activity?limit=50 (human door; the D3 row shape — see docs/decisions/0022-outpost-foundations.md).

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 when SSE is stale or hidden. 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 discipline the Jobs tab note uses, applied to the first new live surfaces since the dashboard redesign.

The Activity tab's state suffix follows the inbox-first precedence: when the inbox has open items, the tab reads Activity — N need you (amber, waiting); with an empty inbox, it mirrors the Jobs tab's state (the only other liveness signal reachable without session data).

The sticky action bar + GET /inbox/<id> (one-tap answers)

Whenever the inbox has at least one open item, a bar docks to the bottom of the viewport (inbox-action-bar) with four buttons — Open run, Revise, Reject, Approve — alongside the per-row Dismiss from above. It starts with every button disabled; tapping an inbox row (anywhere except the Answer link or Dismiss button) selects it and arms the bar with that item's question header as the label. Tapping the already-selected row again deselects it. The bar re-arms automatically when a fresh fetch confirms the selected item is still open, and clears when it isn't (answered/dismissed on another surface).

  • 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 on the dashboard side (D8 of the matrix-as-communication-fabric plan) so a later Matrix approve/reject command can't drift from what the dashboard sends 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 — every button composes the same inbox.answer() + scheduler.resume_run() pair described below.

A fifth button, Confirm held approval, appears ONLY when the selected item carries a live high-risk hold from Matrix (a pending approval badge on the row is the same signal, so you can spot it before selecting). It posts to POST /api/inbox/{id}/high-risk-confirm instead of /answer — a different route because it applies the hold's already-decided answer rather than a fresh one; see "The high-risk hold" in docs/guide/messaging.md for the full flow (why a Matrix approve/reject/revise on a risky item holds instead of dispatching, the TTL, and how a direct dashboard answer during a hold wins and moots it).

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 row, select it, arm the bar" (or, if the item was answered on another surface in the meantime, a toast + a scroll to the Needs You card 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) is linked in the page head alongside an apple-touch-icon, and 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 an id-only envelope ({"type":"inbox","id":<n>} — no titles/bodies/model text ever ride the wire); a tap focuses an existing dashboard tab or opens a fresh one at /inbox/<id>, which selects the row and arms the sticky action bar.

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 (job 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 placeholder panes for the Transcript / Trace / Files / Memory inspectors (Wave 2-3). Back-link to /#activity (the Activity tab home). 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 webhook receiver or a Matrix notification jumps straight to the run page), and the url the scheduler's halt-hook delivery event carries for the Matrix adapter (WP6).

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 (CSRF-gated human-door route — NOT under /api/v1/; no machine-door answer endpoint exists in v1).

Submitting the form:

  1. Records the answer on the inbox item (single-answer: a second answer is rejected with 409; a "skip" is a Dismiss, not an Answer).
  2. Dispatches scheduler.resume_run — 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.
  3. 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 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 — the operator can answer again, 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.

Verified live on the 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.

A held item shows a "pending high-risk approval" note above the questions when a Matrix approve/reject/revise on this item was classified high-risk and is waiting on a dashboard confirm (see "The high-risk hold" in docs/guide/messaging.md). The answer form below the note stays fully functional — submitting it answers the item directly and MOOTS the hold (the hold is a proposal, not a lock); the one-tap Confirm held approval button that applies the hold's own answer verbatim lives on the landing page's Needs You card, not here.

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 fetch is one-shot on pane-open (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 (workspace 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's preview block loads on row click (the tree is a flat list with depth-indented rows; the preview sits below the active row). All text is sanitized via sanitize_model_id at the route seam + HTML-escaped at render — defense-in-depth on top of the underlying text file.

Path containment reuses the WP10 snapshots primitive (_safe_under_root for the workspace name, _safe_path_under for the nested path). Symlinks inside the workspace that point outside are caught by the realpath check. Absolute paths + .. segments are rejected by the _REL_PATH_RE regex + an explicit ..-segment check before the filesystem is touched.

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). 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. All diff text is sanitized at the seam (sanitize_model_id row 14 — strips C0/DEL/C1/bidi/zero- width/BOM) and rendered as a <pre> with textContent.

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.

Click a recall row → the body loads in a preview block below the row. The preview is sanitized via sanitize_model_id (row 14) + HTML-escaped at render. 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.

Tab state — Activity and Jobs projection

The Activity and Jobs tabs each carry a live state suffix next to their label, computed server-side inside activity.tabs() from jobs + their latest runs + inbox state — zero session inputs. At rest the suffix is empty and the tab is plain grey; when something is happening the suffix and colour flip in lockstep:

Tab Idle Busy Waiting (needs input)
Activity Activity Activity — busy Activity — N need you (amber, when the inbox has open items)
Jobs Jobs Jobs — busy ● Jobs — needs input (amber)

Activity's waiting state takes precedence from the inbox (the actionable queue outranks a busy job); with an empty inbox, it mirrors Jobs' state (the only other liveness signal reachable without session data). The Run tab has no live-state note — before the middle-way plan's Step 3 ttyd cut (2026-07-06), Run's note was derived from the live-session roster; that roster no longer exists, and Run is now the workspace-centric surface (see below), which has no meaningful "busy/waiting" projection of its own. Configure is static — there is no client-local dirty state to surface (no unsaved / beforeunload exists anywhere in the dashboard forms; all writes go through explicit "Save" buttons). The colour shift is a static data-state selector (no @keyframes, no transition) — reduced-motion is satisfied by construction, and a glance at the tab row answers "where is something happening?" at once. The browser tab title derives from the same server-computed tabs payload (waiting > busy > idle): ● N need you — Outpost when the inbox has open items, ▸ running — Outpost while the Activity state is busy, plain Outpost at rest. The derivation and the TUI's terminal-tab title share one grammar (kin.harness.titles) so the two surfaces can never disagree on the vocabulary. See docs/decisions/0021, docs/decisions/0023, and docs/decisions/0033 (the Step 3 relocation).

Run tab — Workspaces (card-workspaces)

A workspace table — the surface that exists so scheduled jobs (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 the Jobs tab first), so it can't orphan a running job. The + New workspace (mkdir; DNS-1123 name regex [a-z0-9][a-z0-9-]{0,62} + realpath traversal guard) and Clone a repo (gh repo clone) actions 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 live / polling / offline pill reports connection health instead), 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 SSE-liveness check (it skips while SSE is fresh); SSE streams through oauth2-proxy with X-Accel-Buffering: no so proxies don't buffer it.

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.

Jobs tab — Scheduled jobs (card-jobs)

The autonomous lane: recurring (or one-shot) headless kin -p runs in a workspace, driven by the dashboard's own 60s tick scheduler over a WAL sqlite job store (~/.kin/outpost/jobs.db). Each row shows the schedule, the next fire, the model pin, and the latest run's status, with Run now / enable-disable / per-job Runs history (each run links its markdown report) / re-pin / edit (a pencil that prefills the create form and saves as an update — including the optional per-run token budget) / delete. 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 jobs. 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.

Developers tab — Service registry + API examples (card-services, card-api-examples)

The Developers tab 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 copy curl examples — all behind the Pocket ID SSO gate (the human door), strictly separate from the Bearer-token machine door. The tab is the home for the inbound-triggers UI (WP7) too — per-service trigger CRUD lands there next.

  • 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-focuseded on a button inside the card, an SSE tick won't rebuild the DOM under you (no mid-press element rip-out).

  • API examples (card-api-examples) — six static, copyable curl blocks for the v1 machine door endpoints (submit, list, runs, report, cancel, quota from PROTOCOL §5/§6/§7/§12.5). Each carries the $KIN_TOKEN placeholder (replace with your svc_<id>.<secret> bearer token). The blocks are server-rendered, not fetched — click copy to grab the curl one-liner.

The machine door itself (/api/v1/) is unchanged by this tab. The Developers 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.

Configure tab

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

  • Providers (card-providers) — one row per configured provider: the built-in presets (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. 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 kin-one 10.x/100.x 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 — Anthropic wire only; enable_thinking — OpenAI-compat wire only); 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.
  • Messenger (card-messenger) — the Matrix bot reaches you on your phone when a session goes waiting on you or a job settles. Set MX_HOMESERVER + MX_TOKEN + MX_ROOM + MX_ALLOWLIST on the dashboard container to activate; absent → the adapter stays dormant (no Matrix import, no sync loop). The pill shows dormant / ok / error; a dead sync loop flips to error after a reconnect backoff. Full operator runbook (stack layout, Element/Element X onboarding, threat notes) is in Messaging.
  • Push notifications (card-push) — the second delivery lane (D5 of the matrix-as-communication-fabric plan): a browser Web Push subscription that fires a one-tap notification for a new inbox item only (run reports/alerts stay Matrix-primary). 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:blake@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) — admission control over background runs sharing the same vLLM as the live TUI: interactive sessions are never governed (priority 0), the api class is mid-tier (priority 5), scheduled jobs run as background (priority 10) — lower number wins in vLLM's --scheduling-policy=priority queue. The card shows a live pressure badge + census and a Levers panel (api/scheduled max-running caps, queue depth max, per-class token/hour budgets, and an experimental defer-under- pressure toggle), live-tunable and taking effect on the next admission. Full semantics (classes, overload behavior, the KIN_GPU_PRIORITY wire-flip, fairness limits) are in The governor — cross-run GPU fairness.