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.aiand the10.0.0.xWireGuard 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-sessionttyd(-W -O -m 1, port 9000+) wrapping atmuxsession — a real browser terminal onto a livekinprocess. 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 isssh 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:
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:
- DNS — nothing to add:
outpost.kinra.aiis already covered by the wildcard*.kinra.ai→172.233.215.158(Cloudflare, DNS-only) that points at the VPS Traefik. Let's Encrypt issues the per-host cert on first request. - Pocket ID (
auth.kinra.ai) — a confidential OIDC client with PKCE (S256), redirect URIhttps://outpost.kinra.ai/oauth2/callback, scopesopenid email profile. (Already done.) - 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.tomlon 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/configmodal, or the dashboard's Endpoint form) live on the volume and survive restarts. compose requires the file to exist, buttask 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 forkin-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. Deploy — task outpost:deploy-dev (or task outpost:deploy-main). The
build bakes the repo into /opt/kin inside the image
(uv sync --extra outpost --locked); /workspace starts empty, and the
user creates or clones a workspace from the dashboard's landing page.
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 defaultsecure-headers: this dates from when the session page (/session/<id>/view) embedded the terminal in a same-origin<iframe>— the defaultsecure-headersmiddleware setsframeDeny: 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.jsTerminaldirectly 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: SAMEORIGINmakes no functional difference today. The Traefik router still useskinra-secure-headersin prod (~/kloud/traefik/dynamic/routes.ymlon the VPS) — harmless rather than load-bearing now, and there's no urgency to revert it to the defaultsecure-headersmiddleware.
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 tool — playwright 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 -Ahonors your.gitignore— files you explicitly.gitignorewill not round-trip through snapshot/restore; this is documented behavior, seedocs/decisions/0030-workspace-snapshots.md), commits it to arefs/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_jobBEFORE 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 andDeleteit. - "tar-store quota exceeded" on
create: clean up old snapshots per workspace. - Running-job refusal on
restoreor 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.ai → 172.233.215.158, so confirm the Traefik kin-outpost router exists + the box answers :4180, then wait a few minutes |
Browser hangs on auth.kinra.ai forever |
Pocket ID client's redirect URI doesn't match https://outpost.kinra.ai/oauth2/callback exactly |
| oauth2-proxy won't boot, log says cookie_secret | secrets/oauth-cookie-secret isn't 16/24/32 bytes; regenerate with openssl rand -hex 16 |
| Redirect loop / login bounces back to login | --reverse-proxy=true not trusting Traefik's X-Forwarded-*, or OUTPOST_PUBLIC_URL mismatched; confirm the deploy passed https://outpost.kinra.ai |
task outpost:build fails on apt install |
Ubuntu base moved; bump FROM ubuntu:24.04 to current |
| kin in container can't reach 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.jsoninside 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. POSTrequests honorIdempotency-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+jsonthroughout, and every response carries anX-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=S256matches the 2025 OAuth2 Security BCP, and oauth2-proxy gates every browser request before the dashboard is ever reached. - CrowdSec WAF sits in front (the
crowdsecTraefik middleware), plus thesecure-headersmiddleware — 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, aPermissions-Policydenying 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 forconnect-src/frame-src/object-src/base-urieven 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
:7681while the public:4180path is dead. A watchdog in the dashboard polls the gate's/pingand exits the process after ~45s of misses, sorestart: unless-stoppedrebuilds a reachable box; the compose healthcheck also probes:4180/pingso the failure shows indocker ps. Container logs are bounded (json-file50m × 5; oauth2-proxy logs to stdout, not an unbounded/tmpfile) andmemswap_limitmatchesmem_limitso 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 todocker inspectand/proc/<pid>/environ.start.shnormalizes 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-proxyalone trustsX-Forwarded-*from every IP (0.0.0.0/0) — the spoofing surface CVE-2025-64484 flagged.--trusted-proxy-ip(default10.0.0.1/32, the VPS WireGuard addr over which Traefik is the only peer that reaches:4180; override viaOUTPOST_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 passwordlesssudo— by design. kin runs unbounded inside; root-inside is the point. The isolation boundary is no host mounts + no docker socket + the passkey gate + thepids_limit/mem_limit/cpuscaps 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 setno-new-privileges:true(it would block setuidsudo) or enable daemonuserns-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:bwrapneeds user namespaces a standard Docker container blocks. Without this value kin can't findbwrap, soautomode would fail-close to asking for every non-trivial shell command.KIN_SANDBOX=containertells 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 --hardon 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 kinon 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 enrichedjob_name/job_workdirfrom a LEFT JOIN againstscheduled_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 —runninggreen pulse,waitingamber pulse for needs-human,oksolid green,dyingred for error/killed/lost/timeout,idlegrey otherwise — includingcancelled, 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 — seedocs/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"toPOST /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 Matrixapprove/rejectcommand 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:
- Records the answer on the inbox item (single-answer: a second answer is rejected with 409; a "skip" is a Dismiss, not an Answer).
- Dispatches
scheduler.resume_run— a realkin -p --resume <session_id> -- <framed answer>subprocess that continues the halted session by resume-by-continuation: the saved journal already contains the model'sasktool 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. - The resumed run stamps
resumed_from_run_id(the lineage of the original halted run); on its terminal completion it fires thejob.terminalwebhook (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 onbeforeunload(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 fromerrorback toliveon the next successful open. - When the stream ends (the
eofframe — the run reached a terminal status), the page header refreshes in place: the state badge, the State / Finished cells, the Stop button's visibility, and a quiet "report ready — reload to view" link when the run wrote a report the page rendered without. - Autoscroll pins to the bottom unless the user has scrolled up (a
24-px slack flag in the scroll listener — the standard chat-log
posture). While following is paused and new events land below the
fold, a quiet "↓ following paused — jump to latest" chip overlays
the list's bottom edge — click it (or scroll back down) to resume
following; it disappears on
eof. - Every render-event is appended via
createElement+textContent setAttribute(DOM-API only — defense-in-depth on top of the server-side sanitization + 4 KB cap per event). The change-guard (_lastTranscriptSeq, a JSON-stringify key) compares each new payload's JSON against the previous frame; only if it ACTUALLY changed does the DOM get touched — same discipline as the landing page's_lastBadge(row 8).
What the pane shows:
- assistant text —
kind: text, role chip in the reason-violet color. - user text —
kind: text, role chip in the primary-cyan color (carriers that are pure tool_results with no text block produce no row — the matching tool_result surfaces on its tool_use line). - tool_call —
kind: tool_call, formatted asname(args)with the args sanitized + 4 KB-capped. - tool_result —
kind: tool_result, formatted as(result) <text>(or(error) …when the result carried the error flag).
Security:
- The session_id is gated by a charset regex (
[A-Za-z0-9_.-]{1,128}) that refuses path separators + control characters BEFORE the filesystem is touched. - The journal path's realpath is checked against the projects root
(
<kin_home>/projects) — a corrupted row pointing outside the projects root is rejected; an arbitrary-file read is structurally impossible (row 13). - Every text render-event is sanitized via
sanitize_model_idat the parse seam (C0/DEL/C1/bidi/zero-width/BOM stripped) AND capped at 4 KB per event. A hostile model can't balloon the SSE stream (row 14).
The pane ships with the Transcript pane filled; the three sibling
panes (Trace / Files / Memory) plus a new Diff pane land in WP8 —
see the next section. See docs/decisions/0028-transcript.md for the
design + trade-offs + the ship-without-it rule (the polling posture
is the hedge against kloud's overlay-FS inotify quirks).
The Trace pane (spans JSONL timeline)¶
WP8 fills the Trace placeholder from WP0.4. The pane reads the same
metadata-only span JSONL the harness already writes next to the
journal (<project_dir>/<session_id>.spans.jsonl, opt-in via
KIN_SPANS=1 / spans_enabled = true — src/kin/harness/spans.py).
The spans are OTel-GenAI-shaped: one JSON object per FINISHED span,
fields name / kind / trace_id / span_id / parent_span_id /
start_time_unix_nano / end_time_unix_nano / status / attributes.
No prompt text, no tool args/results — timing + names + token
counts only, so the render path is plain textContent (defense-in-
depth on top of the metadata-only guarantee).
The pane branches on the JSONL's parent-linkage presence:
- Branch A — tree — when ≥2 spans exist AND at least one span's
parent_span_idmatches another span'sspan_id, the renderer nests by parent. Atoolspan hangs under itschatspan, which hangs under itsinvoke_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 viaos.listdirwould 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/lsare 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 --porcelainon the workspace, run on the executor (30s timeout, 512 KB cap). When.gitis 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 SAMEstore.resolve_pathcontainment guard thememorytool 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_logrow per body read (actionbody_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 transportstarting/alive/dying+ kin-levelwaiting/running/idle, a Kill button, an open link to a Graphite+ terminal chrome atGET /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.jsTerminal(vendored@xterm/xterm+ addons) talking to a WS reverse-proxy in front of a per-sessionttyd -W -O -m 1wrapping atmux 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 isssh 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 mirrorskin service createbyte-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_TOKENplaceholder (replace with yoursvc_<id>.<secret>bearer token). The blocks are server-rendered, not fetched — click copy to grab thecurlone-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 toPOST /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 aprovider_keyssettings table ({<preset id>: "<key>"}) — so MiniMax and Z.ai each keep their own key, and the factory resolvesprovider_keys[active preset]ahead of the genericapi_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/settingsread entirely (only thehas_keyboolean is exposed, viaGET /api/providers), and it's redacted wholesale from any/edit-settingsdiff. Set as default writesprovider_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_KEYSvalue (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 (clearsprovider_preset). The Test connection button calls_fetch_model_idsfromkin.harness.backends.base(the same probe the/modelpicker uses) with a link-local SSRF guard (169.254/16+fe80::/10only — the tailnetkin-one10.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/modelsGET — 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.1000000for 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); asearch_enabledtoggle for the Tier-1 FTS5search_workspaceindex; and the launch mode (auto/strict). (The optionalbrave_api_keyfor 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 throughkin.harness.settings.save_global_settings— the single source of truth, the same writer the in-TUI/edit-settingsmodal uses;api_keyis masked to[SET]on read, the user re-types to change it. All mutating routes are CSRF-gated viaX-CSRF-Token↔_dashboard_csrfcookie (SameSite=Strict). - GitHub Connect (
card-github) — an optional card that links the box'sgit push+ghto 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-timeuser_codeto enter atgithub.com/login/device(a server-side poller redeems the token; thedevice_codenever reaches the browser). On success the token is stored viagh auth login, and the card offers Disconnect. The token persists on theoutpost-ghvolume;~/.gitconfigis ephemeral sostart.shre-runsgh auth setup-gitat boot when a token is present. Requires the OAuth App's Device Flow enabled (see the secrets section above);device_flow_disabledsurfaces as a card error naming the fix.POST /api/github/device+/disconnectare CSRF-gated. - Messenger (
card-messenger) — the Matrix bot reaches you on your phone when a session goes waiting on you or a job settles. SetMX_HOMESERVER+MX_TOKEN+MX_ROOM+MX_ALLOWLISTon the dashboard container to activate; absent → the adapter stays dormant (no Matrix import, no sync loop). The pill showsdormant/ok/error; a dead sync loop flips toerrorafter 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
dormant → ready and shows the current subscribed-device count; the
Subscribe this browser button requests notification permission,
calls pushManager.subscribe with the card's public key, and POSTs the
resulting endpoint + keys to /api/push/subscribe. VAPID_SUBJECT
(compose env, not a secret — an operator contact URI) defaults to
mailto: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.