Kin / Maintainers
Outpost operations
This is the exhaustive operator and implementation reference retained from the original Outpost guide. For everyday browser tasks, start with the Outpost overview.
Read as MarkdownThis is the exhaustive operator and implementation reference retained from the original Outpost guide. For everyday browser tasks, start with the Outpost overview.
The scheduler/governor details are in Automation operations; persistent-computer deployment and VM detail are in Rig operations.
For moving active work between a local session and Outpost, see
Beam work to Outpost. /beam is a sequential, same-feature-branch
handoff; it does not attach the TUI to a remote session.
For AI-peer development, task live-beam-setup maintains a dedicated
svc_ai_peer_live credential and disposable beam-scratch checkout. Its
operator grant permits profile and inbox discovery, but writes remain scoped
to that workspace and quota-clamped. kin doctor reports readiness without
revealing the token, and task live-beam fails rather than silently skipping
when the credential is absent. The gate works from an isolated temporary clone
of origin/beam-smoke, submits through the immutable controller, deliberately
discards one already-accepted response, and retries the exact returned plan.
It does this because the accepted-but-lost ambiguity—not an ordinary clean
retry—is where an idempotency bug would create a duplicate job. A genuine HTTP
failure is never disguised as loss. The gate then answers only the exact
disposable structured push approval, validates the native report and remote
stamp, and returns through production outpost-send into a real Session. It
requires the local fast-forward count, one beam_returned event, and the
durable journal marker before passing. Temporary checkout, locator, and journal
state are removed. If the gate fails while its job is still nonterminal, it
cancels that exact disposable job before cleanup, retries the stale answer
through the v1 door, and requires a conflict, unchanged run count, and
invalidated Inbox item. That proves delayed capacity recovery or human input
cannot revive an ownerless push; only the intended beam-smoke Git advance
lasts. The freshly cloned and re-read remote head counts as the native sync:
the brief forbids a redundant pull/fetch because that would add a second
approval question beside the sole exact push this fail-closed gate can answer.
The machine door exposes execution profiles derived from the same built-in presets and custom provider rows configured on Outpost. Operator clients can discover profile/model/effort catalogues, quota, and timeout bounds, then submit a validated reference. Raw endpoint URLs and credentials never cross that door; a removed profile fails explicitly instead of changing models.
A single Ubuntu container on the kloud host that runs your always-on
automation layer at https://outpost.kinra.ai — gated by
oauth2-proxy in front of auth.kinra.ai (Pocket ID), so the page is a
login prompt before it’s anything else. It rides the standard kloud service
pattern: Cloudflare → kloud-vps Traefik (+ CrowdSec WAF) → WireGuard → the
container.
This page documents Blake’s own live deployment as the worked example —
kloud/kloud-vps/outpost.kinra.ai/auth.kinra.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. Create or connect a Project, describe an
automation, and choose when it should run. Activity keeps every result and
anything that needs you in one place; sovereign Chat and notifications bring
you back when attention is required. Underneath, Projects are workspaces and
automations are scheduled kin -p jobs on the existing cron/every/at
contract. See “What this is NOT” below for the shape this deliberately isn’t
— a second place to sit down and drive Kin interactively
(docs/decisions/0033-outpost-automation-layer.md, the middle-way plan).
What’s running
One Ubuntu container runs an aiohttp dashboard in the foreground and
oauth2-proxy in the background, both via container/start.sh. The
dashboard serves the landing page + REST API — workspaces, the scheduler,
Chat, the Activity feed + inbox, notifications, and the v1 machine door.
Everything public-facing is the existing kloud-vps Traefik — Outpost is
just another service behind it.
browser → https://outpost.kinra.ai
→ Cloudflare DNS-only / grey cloud, A record → 172.233.215.158
→ kloud-vps:443 Traefik v3 + CrowdSec WAF, Let's Encrypt TLS
→ WireGuard 10.0.0.1 (vps) → 10.0.0.2 (kloud)
→ 10.0.0.2:4180 oauth2-proxy (in the container) — Pocket ID OIDC, PKCE / S256
→ 127.0.0.1:7681 dashboard (aiohttp) — landing page, /runs/<job>/<run> pages, /api/*
One container. Two binaries (oauth2-proxy + dashboard). A handful of env
vars. The VPS terminates TLS, runs the WAF, and forwards over the tailnet
to oauth2-proxy on kloud’s WireGuard IP 10.0.0.2:4180; oauth2-proxy proves
you to Pocket ID and then proxies to the dashboard on container loopback.
The image also carries the Outpost concierge’s canonical guidance and a
curated help corpus compiled from this guide. On every boot start.sh
idempotently refreshes /workspace/ranger/AGENTS.md and
/workspace/ranger/help/, so a persistent workspace volume receives support
updates with the image while docs/ remains the source of truth.
Historical: before the middle-way plan’s Step 3 (2026-07-06,
docs/decisions/0033-outpost-automation-layer.md), the dashboard also reverse-proxied WebSocket terminal traffic to a per-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:
bash <(curl -fsSL https://get.kinra.ai/outpost-install.sh)
It checks docker + compose v2 + git + the GitHub SSH key, clones the repo
to ~/kin, and stages the six primary secret files as empty mode-600
stubs. With the three required oauth secrets filled it runs
docker compose up -d --build (plain — no --profile publish; the
get/docs static services below are kloud-only) and waits for the
healthcheck; with any of them empty it stops before compose up and
prints the manual checklist — “staged, awaiting secrets” is its designed
success state, and it never generates a secret value or touches
Traefik/DNS. Re-run it after filling the secrets. --check walks the
whole decision tree mutating nothing.
An Outpost installed before the repository rename may still live at
~/kin-textual. When ~/kin is absent, the installer reuses that checkout
and its existing container/secrets/ in place rather than cloning a second,
apparently unconfigured Outpost.
On kloud itself, the infra (Traefik, CrowdSec, WireGuard, Pocket ID) already exists on kloud-vps, so standing Outpost up is a short checklist:
-
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/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-secretThree are optional:
container/secrets/kin-settings— seeds~/.kin/settings.tomlon first boot only. The current Kinra profile selects authenticatedhttps://api.kinra.ai/v1, Responses,deepseek-v4-flash, and thekinra-visionQwen route generated bykin connect.container/secrets/kin-credentials— independently seeds the 0600~/.kin/credentials.toml. A named hosted provider needs both files; settings without this credential store cannot authenticate. Mint a distinct Outpost key and never paste it into documentation or shell argv. Existing volume files win over both seeds, so later changes survive restarts. Compose requires source files to exist; deploy self-heals empty placeholders when either seed is omitted.container/secrets/vapid-private-key— the Web Push signing key (D5, the Push notifications card). Absent/empty → the push lane stays dormant; nothing else on the box is affected.task outpost:deploy-*self-heals an empty placeholder the same way it does for the Kin config files. One-time keygen + the exact drop-in steps are in the Push notifications card section below.
GitHub Connect needs no secret file — it’s OAuth Device Flow. The only setup is a one-time toggle on the OAuth App (
Ov23liaiAOroQzuvNs4Oby default): in the GitHub OAuth App settings, enable Device Flow (off on a new app). Without it every connect errorsdevice_flow_disabled(and the card names the fix). The publicclient_idlives inOUTPOST_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 16yields 32 printable hex chars (= 32 bytes);openssl rand -base64 32yields a 44-char string oauth2-proxy rejects as the wrong length (and can embed a NUL when decoded).start.shfails 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. -
Deploy —
task outpost:deploy-dev(ortask outpost:deploy-main). The build bakes the repo into/opt/kininside the image (uv sync --extra outpost --locked);/workspacestarts empty, and the user creates or clones a workspace from the dashboard’s landing page. A main deploy also rebuilds the public docs and atomically publishes Kin’s allowlist into the durable get.kinra.ai root (normally~/.local/share/kinra-publish/get; initialize it once from the legacy live root withtask outpost:publish-init). The wheel +version.jsonrelease channel additionally requires HEAD to carry the matchingv<version>tag (DR 0121). Paddock owns its disjoint nested release paths and publishes them independently; a Kin deploy cannot sweep them. The composepublishprofile brings up twonginx:alpineservices,geton:7018(get.kinra.ai — product pages and release channels) anddocson:7019(docs.kinra.ai, the public user guide). Dev deploys leave both public roots untouched. See Multi-machine setup for the artifact-build detail. -
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. Thekin-get/kin-docsrouters (→http://10.0.0.2:7018/:7019) sit in the same file, mirroring thekin-outpostblock 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); OUTPOST_PUBLIC_URL and
OUTPOST_BIND_IP override the deploy values above.
Lifecycle
Outpost itself is one container and one subdomain. Rig is a separate,
profile-gated warm service so an Outpost rebuild never churns its browser.
Deploying Outpost is a branch swap:
task outpost:deploy-dev SSHes to kloud, git fetch + checkout +
reset --hard origin/dev (which keeps the gitignored secrets/ files), then
docker compose up -d --build with the prod env. deploy-main does the same
from origin/main. There’s no blue/green or per-branch URL — the two deploy
tasks just choose which branch the single box mirrors. That kloud checkout is
only the build source + secrets dir — it is not mounted into the container.
The box is disposable and self-contained — no host bind-mounts, no git
clone. The code is installed into the image: the build runs
uv sync --extra outpost --locked from the build context into
/opt/kin/.venv (the dashboard, the kin command, and a tiny aiohttp
back-end share the venv). Chromium has moved to Rig, whose separate
image carries the headful display and persistent profile. Outpost receives only
Rig’s internal URL and secret-file path so scheduled kin -p runs can use the
same tools. /workspace starts empty — the user creates or
clones a workspace from the dashboard’s landing page. (The old
first-boot-from-/opt/kin-seed repo seed is gone; the general-user
“onboard into a clean box” flow replaces Blake’s personal-use “clone
kin in for me” path.) The dashboard imports kin.harness.settings
directly, so the Endpoint form on the landing page is a thin UI over
the same writer (save_global_settings) the in-TUI /edit-settings
artifact editor uses — single source of truth. State persists in three
named volumes, not host paths: outpost-workspace → /workspace (user
workspaces — created/cloned by the dashboard, no seed), outpost-kin →
/home/blake/.kin (kin session history + split settings/credentials, seeded
independently on first boot), and outpost-gh →
/home/blake/.config/gh (the gh token, so GitHub Connect survives a
restart). They survive down/up
and reboots; task outpost:reset (docker compose down -v, LOCAL
container) or task outpost:remote-reset (the same over SSH, against
the prod box on kloud) nukes them for a clean box. restart: unless-stopped
brings the box back after a reboot.
Workspaces & snapshots
Every workspace in /workspace is reversible from the dashboard.
The History button on a workspace row opens a modal listing that
workspace’s snapshots (newest first) — every entry has a Restore
and Fork action; the Snapshot button on the same row creates
a fresh snapshot on demand (you’ll be prompted for a label).
How snapshots work
Two substrate paths, picked automatically at create() time:
- Git — when the workspace has a
.git/directory, the engine builds a temp git index, captures the tree (git add -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 Automations 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 Automations) before retrying. (Before the middle-way plan’s Step 3 ttyd cut, 2026-07-06, this guard checked live tmux sessions instead — the job-based check replaced it since the terminal surface it guarded against no longer exists.)
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| 502 from Cloudflare / Traefik | container down or WireGuard down on kloud; task outpost:remote-status (check container ps + /ping) |
| Box was healthy, then went unreachable (502) for a bit | The auth gate (oauth2-proxy, a background child) died. The dashboard’s watchdog polls its /ping and exits after ~45s of misses, so restart: unless-stopped rebuilds a reachable box; the healthcheck also flips unhealthy in docker ps. Read the gate’s exit reason in task outpost:remote-logs (oauth2-proxy now logs to the container stdout, not a /tmp file). |
| Can’t delete/restore a workspace — “has running job(s)” | A scheduled job’s workdir is under the workspace and it’s currently running. Wait for it to finish or cancel it from Automations, then retry. |
| A page’s CSRF-gated action 403s with “missing or invalid CSRF token” after opening a second dashboard tab/page | The dashboard’s CSRF token is a per-browser double-submit value; it must be reused across renders, never re-minted. _index / _run_view call csrf.ensure(request) (reuse the _dashboard_csrf cookie, mint only when absent) — a fresh mint per render rotated the shared cookie out from under the already-open dashboard tab, whose in-memory token then 403’d. Don’t “simplify” ensure back to mint. Regression-tested in tests/test_outpost/ (“reuses the existing csrf cookie”). |
TLS cert not issuing (https warns / fails) |
LE still validating on first request or ACME backing off; the *.kinra.ai wildcard already resolves outpost.kinra.ai → 172.233.215.158, so confirm the Traefik kin-outpost router exists + the box answers :4180, then wait a few minutes |
Browser hangs on auth.kinra.ai forever |
Pocket ID client’s redirect URI doesn’t match https://outpost.kinra.ai/oauth2/callback exactly |
| oauth2-proxy won’t boot, log says cookie_secret | secrets/oauth-cookie-secret isn’t 16/24/32 bytes; regenerate with openssl rand -hex 16 |
| Redirect loop / login bounces back to login | --reverse-proxy=true not trusting Traefik’s X-Forwarded-*, or OUTPOST_PUBLIC_URL mismatched; confirm the deploy passed https://outpost.kinra.ai |
task outpost:build fails on apt install |
Ubuntu base moved; bump FROM ubuntu:24.04 to current |
| kin in container can’t reach inference | Test public health first (curl https://api.kinra.ai/healthz), then the stable private fallback (curl https://kinra-inference-001.mist-hake.ts.net:8001/v1/models). The box runs no Tailscale daemon; private traffic is NAT’d through the kloud host’s Tailnet. A public 401 from /v1/models means reachability is healthy but the Outpost-specific bearer is absent/wrong. |
/workspace is empty |
By design — the dashboard lets the user create or clone a workspace from the landing page (the old first-boot repo seed is gone, on purpose — a general user should onboard into a clean box, not into a stranger’s checkout). |
| Need a clean box | Local: task outpost:reset; prod on kloud: task outpost:remote-reset (both docker compose down -v — the bare reset only ever touches the LAPTOP’s volumes, it does nothing to prod). Next up/deploy starts from an empty box |
| 403 from CrowdSec | your IP tripped a WAF decision; check the CrowdSec decisions on kloud-vps |
| Everything fronted by kloud-vps looks dead at once — dashboard and sign-in — even though the container itself is healthy | A CrowdSec ban of the box’s own egress IP can bite when the docker host shares a NAT/premises IP with other traffic. The ban 403s both inbound browser requests and the box’s outbound calls to Pocket ID OIDC discovery. Check CrowdSec decisions on kloud-vps for that egress IP and unban / allowlist it if it is trusted infrastructure. |
| A typo’d / stale dashboard URL shows a themed “Page not found” page | Expected — unknown non-/api/ paths (and unhandled errors, which show a reference id matching the server log’s request_id) render the Graphite+ error page for browser navigations; API clients still get the plain 404 / JSON-500 contract. |
The machine door (/api/v1/)
Alongside the human door (browser + Pocket ID cookie), the dashboard
mounts a separate machine door at /api/v1/ — a Bearer-token-only
aiohttp sub-app for service-to-service job submission. The wire contract
lives in the repo’s PROTOCOL.md; the short version:
- Auth is a Logto JWT (ES384, verified against
logto.kinra.ai’s JWKS) or an HMAC bearer (svc_<service_id>.<secret>, checked constant-time against a 0600 flat file at~/.kin/secrets/services.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 through Outpost Chat, the middle-way plan). Different kinds of work, connected thin — never two windows onto one Kin. - Not an IDE. It’s an automation manager, Project surface, and run inbox; the exact substrate remains workspaces plus the scheduler.
- Not a replacement for the laptop.
uv run 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 / through five destinations shaped
around the operator’s intent:
- Chat — the default concierge; see Chat.
- Activity — Needs you, Running now, and Recent.
- Automations — saved automations, ready-made Recipes, and the Projects they run in. Project is the ordinary product name; APIs, paths, and operator details retain the exact term workspace.
- Computer — Rig posture, login handoff, glance, peek/control, journal, and memory. Rig degrades to unavailable without affecting the other views; glance is click-to-load and peek begins view-only behind the same SSO cookie.
- More — routine Settings plus an Advanced area for services, triggers, and machine-door administration. Raw API examples live in the guide and versioned protocol rather than the product UI.
Desktop keeps those destinations in a persistent labelled rail. At phone
width the same five destinations dock to the bottom edge as a thumb-reachable
bar, clear of the home indicator; the bar steps aside while the on-screen
keyboard is open. The information architecture does not change with width.
Connection health stays quiet while live (the last poll succeeded and
EventSource is open). The header surfaces a pill only when degraded:
polling (static amber: the push channel is connecting or down, so updates
ride the 10s poll) or offline (the poll itself is failing). Hovering shows
the last good update; on a phone the compact degraded pill exposes the same
state on tap. Deep links survive a refresh and the back
button: #chat / #activity / #automations / #computer / #more
land on each destination. Legacy #run, #jobs, #developers,
#configure, #rig, and #card-<id> bookmarks route to the new owner;
any unknown hash falls back to #chat (the home view).
First-use help follows the data rather than a permanent onboarding state. With no Project, Automations points to creating a blank Project or connecting GitHub. With a Project but no automation, it points to a Recipe or the simple composer. The guidance disappears as soon as that next step exists; there is no tour, coach layer, or separate onboarding ledger.
See Rig for the control hold and credential flow.
Activity — Needs you, Running now, and Recent (card-inbox, card-activity)
Activity is the supervision view: the actionable queue first, genuinely live work second, then settled history. The existing feed card supplies the Running now and Recent groups without turning a historical result into current attention:
- Needs you (
card-inbox): open, actionable Inbox items — a run halted on a question/approval/plan/proposal, or a Rig login wall that needs direct human control. Each payload carries its valid typed actions. Run rows offer Open run, Reply, and Dismiss; selecting one arms the reply shortcuts in the sticky action bar (see below). A Rig login row offers only Open Computer and never a synthetic run link or answer controls. A permanently failed pre-launch continuation appears as its own item with the sole action Retry continuation. The count badge next to the header mirrors the Activity navigation indicator. Data source:GET /api/inbox(human door;{items}; the count isitems.length, and 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,waitingstatic amber 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/rig), the title (a deep link to/runs/<job_id>/<run_id>for runs or the semantic replay for a closed Rig excursion), the workspace, token counts, and a relative age (hover for the absolute time). While a run is in flight its row also carries a ticking elapsed counter (“· 42s”) — deliberately only on running rows, never on waiting ones (a ticking clock on human latency reads as nagging). Relative times across the dashboard re-derive on a coarse background tick, so “just now” can’t sit stale for minutes. When a row’s state changes between renders (running → ok, a question opening), the row briefly flashes then settles — a calm one-shot acknowledgment, never a toast. Data Rig contributes one row per closed excursion, never one row per browser or desktop action; a down Rig contributes no rows and does not break the feed. Data source:GET /api/activity?limit=50(human door; the D3 row shape — seedocs/decisions/0022-outpost-foundations.md).
Running work cannot be filtered away. Every running row in that response stays visible in Running now; Find recent activity and Outcome apply only to the settled Recent rows below it. Search recognizes both the friendly title or Project and the exact stored name, workspace, and source. Recent starts at ten matches; Show more reveals ten at a time and Show fewer returns to ten. The live result summary keeps the two scopes honest, for example “Showing 10 of 34 recent from the latest 50 items · 2 running now.” A no-match message names that same bounded window, offers Clear filters, and is distinct from a genuinely empty Activity feed.
These controls search only the settled rows in the latest 50-item
Running/recent window, not all historical runs. The same response independently
bounds Inbox-owned attention at 50, so an attention burst cannot consume the
Running/recent window; additive item_window metadata names those two bounds.
Run rows also carry additive stored_workspace data so exact path/slug search
does not depend on reconstructing a Project’s friendly label.
The filters remain client-side presentation: no infinite scroll, background
history fetch, cursor, or search query parameter. The current query, outcome,
and revealed count survive live refreshes while the page is open; new matching
results update the count without resetting the view.
Both surfaces are live: the existing GET /api/events SSE stream
publishes typed events from the 2s runs+inbox watcher — {"type":"runs"}
when a run started/finished/settled, {"type":"inbox","open":N} when the
open-inbox count changed — and the Activity + Inbox refreshes are
type-gated to those events (never a blanket refresh on every SSE
message, never a second fetch storm). The 10s fallback poll carries the load
while EventSource is connecting or retrying; returning to a previously hidden
tab triggers one immediate refresh. Each renderer owns its own
_lastInbox / _lastActivity JSON-stringify change-guard: on a tick,
the render compares the new payload’s JSON against the guard; only if it
ACTUALLY changed does the DOM get touched, and the DOM is built via
createElement + textContent + setAttribute — never innerHTML for
model text (defense-in-depth on top of the WP0 capture-time
sanitization). The same change-guard discipline the Automations indicator uses,
applied to
the first new live surfaces since the dashboard redesign.
Only an open Inbox item creates a Needs you count or attention indicator. Answering, dismissing, or automatically resolving it clears that signal while the run remains in Recent as static history. Running work is derived from its live execution claim, not from the latest row’s historical status.
Typed Inbox actions, the sticky reply bar, and GET /inbox/<id>
GET /api/inbox provides an actions list for each open item. The renderer
does not infer attention verbs from nullable job/run ids; a run’s stable
detail link remains ordinary navigation. The server re-reads the live item
before every mutation. The advertised capability is a UI contract, not
authorization.
Selecting a run item docks the reply bar (inbox-action-bar) with four
shortcuts — Open run, Revise, Reject, Approve — alongside the
row’s Dismiss action. Selecting it again deselects it. The bar re-arms when
a fresh fetch confirms the item remains open and clears when it was answered
or dismissed elsewhere. A Rig login item does not arm this bar: its sole
action is Open Computer → /rig/peek, where the operator types the
credential directly and releases control. Credential text never crosses an
Inbox answer or model transcript. A continuation failure likewise bypasses
the reply bar: Retry continuation atomically resolves the failure item and
requeues the already-saved answer under a fresh bounded retry window.
- Approve / Reject open the existing confirm dialog, then post the
CANONICAL answer strings
"approved"/"rejected"toPOST /api/inbox/{id}/answer— the same route + CSRF discipline the Answer form always used. These two literal strings are pinned by tests so Chat and Activity send the same answer for the same action. - Revise expands an inline textarea in the bar; submitting posts the typed text as the answer (the same free-text path the run-detail page’s answer form uses).
- Open run navigates to
/runs/<job_id>/<run_id>— the same destination the row’s Answer link points at, useful when you want the full report before deciding.
No new mutation route exists. Run replies use the same CSRF-gated
inbox.answer() service and create a durable continuation intent. “Recorded”
means the answer is safely stored; it does not falsely promise that a process
already launched. Immediate admission starts the continuation when possible;
a collision or pre-launch failure remains pending for bounded scheduler
retry, with running / complete / failed recorded for recovery and audit.
A terminal pre-launch failure creates the typed Retry continuation item rather
than disappearing into logs or pretending the run is queued.
GET /inbox/{item_id} is a thin, unauthenticated-shape-preserving
redirect, not a second render path: a known item id 303s to
/#inbox-item-<id>, which the landing page’s hash router turns into “scroll to
that item and open its valid action surface” (or, if the item was resolved
elsewhere, a toast + a scroll to Needs you instead). An unknown id 303s
straight to /#card-inbox.
This is the stable link a PWA push notification’s tap lands on (see
below and Messaging’s push section).
Add to Home Screen (PWA)
The landing page is installable: static/manifest.webmanifest (name,
start_url: "/", display: standalone, the Graphite+ theme color, plus a
full-bleed maskable icon pair so Android/iOS squircle masks don’t
double-round the self-rounded tile icons) is
linked in the page head alongside an apple-touch-icon and the iOS
standalone metas (black-translucent status bar — the page draws its own
graphite under the clock — and the Home-Screen title Kin). Every
dashboard document sets viewport-fit=cover, which is what makes the
env(safe-area-inset-*) padding (composer, action bar, bottom tab bar)
actually clear the notch and home indicator on iPhones. A service
worker registers itself at /sw.js on every load (skipWaiting +
clients.claim, plus a push + notificationclick handler — D5, the
Push notifications card above). The worker is served from the origin
root rather than /static/sw.js specifically so its default scope is
/ and it can see navigations to /inbox/<id>, not just /static/*.
The dashboard’s CSP (Content-Security-Policy on the landing + run-detail
pages only) grants worker-src 'self' and manifest-src 'self' for
exactly this. The push payload is a typed, content-free envelope.
Compatibility title / body fields contain only fixed per-kind copy;
question, prompt, report, and other model text never ride the wire. Numeric
fields name the authenticated target (id for Inbox, job + run for a run
update), while the service worker constructs the same-origin path. An Inbox
tap focuses an existing dashboard tab or opens a fresh one at /inbox/<id>,
which scrolls to and focuses the live item. Only a run reply item arms the
sticky bar; Rig and continuation-failure items expose their sole typed action
inline.
Run-detail pages (GET /runs/{job_id}/{run_id})
Each job-backed run has its own server-rendered page at
/runs/<job_id>/<run_id> (a separate document — not a landing fetch). The page shows the run
header (automation name, source, state, timestamps, token usage), the markdown
report (sanitized + HTML-escaped, in a <pre>), the structured questions
- an answer form when the run is halted pending input, and the Transcript /
Trace / Files / Diff / Memory inspectors.
Back-link to
/#activity(the Activity supervision view). The report read is runs-root-contained: a corruptedoutput_pathrow 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 theoutpost_urlthe v1job.haltedwebhook carries so a receiver can jump straight to the run page, and theurlthe scheduler’s halt-hook delivery event carries to notification adapters.
The Inbox answer flow (resume-by-continuation)
When a scheduled run halts on a question (needs_human, exit 2 — the
model called the ask tool, or hit an approval/plan gate), the run lands
in the dashboard Inbox (one item per halted run, structured questions[]
matching the job.halted webhook payload). The run-detail page renders
the questions + a functional answer form that posts to
POST /api/inbox/{id}/answer (the CSRF-gated human door). Operator-scoped
machine clients use the corresponding bearer-gated
POST /api/v1/inbox/{id}/answer; both compose the same store and continuation
service.
Submitting the form:
- For exactly one current structured question, records the answer and a
durable
pendingcontinuation intent on the Inbox item. A second answer, stale item, or multi-question bundle is rejected with 409; a “skip” is a Dismiss, not an Answer. - Attempts immediate admission. If another run owns the job or a pre-launch step fails, the scheduler retries the durable intent on later ticks with a bounded budget; restart recovery distinguishes safe pre-launch retry from a child that may already have produced side effects. While the action remains current, the scheduler owns the continuation independently of the HTTP request, so a browser disconnect cannot cancel accepted human input.
- Once admitted,
scheduler.resume_runlaunches 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 continuation lifecycle is explicit (pending / running / complete /
failed). The UI confirms that an answer was recorded; it does not claim a
new run is queued or running until the durable state says so.
Response history and action authority are separate. An item can remain
answered for audit while its validity becomes superseded or invalidated,
with a durable timestamp/reason and optional superseding run. Cancel retires
every active item/intent for that job and revokes unspent approval capability;
a newer run or re-halt supersedes older items. Answer, cancel, admission, run
creation, and capability redemption share one jobs.db writer lock, so whichever
commits first is the single durable winner. Restart recovery sees only active
pending intents. Delayed human input is authority, so it must expire when its
action loses ownership rather than resurrecting historical work.
Deployment is an additive jobs.db migration: first open adds the permanent job
lifecycle state/generation and Inbox validity/audit columns in place. Legacy
rows that already lost ownership are backfilled fail-closed from durable evidence
(job_cancelled, run_terminal, missing_owner, or newer_run), pending
intents are retired, and issued capabilities are revoked. No row is deleted and
there is no operator-run migration command.
One narrow approval case carries its answer into execution. When the item is
exactly one root git-push approval and the canonical answer is approved,
Outpost grants only that exact structured call once. The grant is random,
short-lived, bound to this item/session/continuation/workspace, and burned
before the resumed model starts. It is not an “always allow” grant and never
enters the journal. Changed arguments, a repeated call, a subagent-origin
approval, expiry, restart ambiguity, or a broker failure all fall back to the
normal gate and re-halt. Other answers resume as context but authorize no tool
call automatically.
The framed answer is built server-side from the inbox item’s
questions_json:
[Operator answer to your pending question(s)]
Q1 (<header>): <your answer>
Continue the task; do not re-ask.
If the resumed run re-halts (the model asks a follow-up question), a
NEW inbox item is created and older items are explicitly superseded — the
operator can answer the new one, same flow. A resume does NOT advance the
job’s schedule (the original halt already advanced next_run_at); it’s a
continuation, not a new scheduled fire.
Historically verified on the retired tailnet vLLM (Qwen3.6-35B-A3B-FP8) — the model
uses the framed answer and completes, 5/5 in the pass^5 spike
(research/2026-07-05-wp2-resume-spike.md) + the scheduler-chain proof
(scripts/live_proof_resume_vllm.py). See docs/decisions/0024-resume-seam.md
for the design + the why-not-history-rewrite rationale.
The Transcript pane (live journal tail)
The run-detail page’s Transcript pane is the operator’s window into a
live run. It tails the run’s append-only JSONL journal
(<kin_home>/projects/<workdir-slug>/<session_id>.jsonl — the same
file the resume-by-continuation flow replays from) over a per-pane SSE
stream. Each user / assistant / tool_call / tool_result record arrives
as a sanitized render-event; the pane is empty while the run is fresh,
fills as kin -p writes, and closes with an eof frame once the run
row reaches a terminal status AND the journal stops growing.
How it works:
- The client opens
EventSource("/runs/<job_id>/<run_id>/transcript")on pane mount and tears it down 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+textContentsetAttribute(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 explicit Load spans button is keyboard-operable; clicking the pane is
retained as a pointer convenience. The fetch is one-shot (NOT SSE — the spans
file is append-only + complete once the run terminates; live tail belongs to
the Transcript pane which already covers journal feeds). When
KIN_SPANS is OFF the file is absent; the route returns
{present: false, mode: "none"} and the pane renders an empty-state
honestly. Every render uses createElement + textContent +
setAttribute only (row 8); the change-guard _lastTraceKey is a
JSON-stringify of the most recent payload — same shape as
_lastTranscriptSeq.
Path containment is structural: the spans file path comes from
kin.harness.persistence.project_dir(workdir) + session_id; the
chassis regex ([A-Za-z0-9_.-]{1,128}) refuses separators + control
chars before the filesystem is touched; the realpath check
(_is_under) rejects any path outside the projects root.
The Files pane (Project tree + text preview)
The Files pane reads the run’s workdir through a depth-capped tree
walk + a text-file preview route. Two endpoints, both human-door
(behind the SSO gate via the dashboard’s /api/* prefix):
GET /api/workspaces/{name}/tree?path=— recursive listing, depth-capped (8), entry-capped (500), hidden-dot dirs + entries skipped. Symlinks are skipped, NOT followed — a planted link inside the workspace could point at/etc/passwd, and silently following it 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 behaves as a small file browser rather than an eagerly expanded listing. Folders are keyboard-operable disclosures with an accessible expanded state, and the initial view keeps descendant content collapsed. Opening a nested file preserves its complete relative path; that path appears as quiet context above the preview with a copy action. Sizes use human units. Long names lead with the recognizable filename and retain the complete value through focusable/copyable context rather than a hover-only tooltip.
The tree and preview keep filesystem identity separate from presentation. The
JSON path is the exact relative fetch key; display_path is the separately
sanitized human label. Spaces and ordinary Unicode remain intact. When hidden
control, bidi, or zero-width characters make those values differ,
path_copyable disables copy rather than putting a deceptive value on the
clipboard. File bodies use the multiline sanitize_block path so newlines and
tabs survive, then the browser assigns the body through DOM textContent.
Path containment reuses the WP10 snapshots primitive
(_safe_under_root for the workspace name, _safe_path_under for
the exact nested path). _valid_relative_path is the lexical first gate: it
accepts real filenames with spaces and ordinary Unicode, but rejects absolute
paths, NULs, backslashes, paths over 4096 characters, empty components, and
. / .. components. _safe_path_under is the second, authoritative
realpath guard; it catches symlinks that resolve outside the workspace.
The run page derives its Project only when the job’s workdir realpath exactly
matches that workspace under WORKSPACE_ROOT. A shared basename is not
identity: a run in /tmp/demo cannot make /workspace/demo readable. For an
outside workdir, Files stays unavailable rather than guessing a Project.
The Diff pane (journal edits + git diff vs HEAD)
The Diff pane is honest about its labels. It stacks two sections, both with explicit captions so the operator never confuses them:
- “files this run edited (from the journal)” — parsed from the
run’s journal (cross-provider: OpenAI flat
{role, content, tool_calls}AND Anthropic block-list{content: [{type: tool_use, ...}]}). Edit-tool allowlist ={write_file, str_replace, insert, create_file, edit_file}—read_file/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 enforced while Git is running, with stdout and stderr drained together). 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. Journal previews, git diff, and git
status use the multiline sanitize_block path and render into <pre> nodes
with DOM textContent; one-line path/tool labels use the bounded label
sanitizer. The same exact-workdir identity guard applies here: an outside run
with the same basename still exposes its contained journal-derived edit list,
but never borrows the unrelated workspace’s git diff.
The Memory pane (gated body-crossing surface)
The Memory pane is the FIRST body-crossing exception to the memory
API’s stats-only posture — intentionally narrow. See
memory.md for the full contract; the short
version:
- The pane carries a persistent “Agent-written content — treat as data” banner.
GET /api/memory/search?q=returns top-10 recall (path, category, title, snippet) — body NEVER crosses the wire.GET /api/memory/body?path=returns the body through the 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.
Recall rows are native disclosure buttons, so click, ++enter++, and ++space++
all load the body in a labeled preview block below the row. The explicit
Load recall button is likewise keyboard-operable.
The preview is sanitized via sanitize_model_id at the route seam (row 14)
and assigned through DOM textContent. The change-guard _lastMemoryKey is a
JSON-stringify of the most recent payload (row 8).
See docs/decisions/0031-run-inspection.md for the full design +
the four-pane verification matrix.
Truthful state projection
Outpost keeps five facts separate instead of collapsing them into one automation badge:
| Fact | Authoritative source | Visible meaning |
|---|---|---|
| Attention | Open, active Inbox items | Needs you now |
| Execution | Live run claims | Queued, running, paused, or settled |
| Schedule | Job definition | Active, paused, disabled, next run |
| Last result | Latest settled run | Historical outcome |
| Connection | EventSource transport + successful poll | Live, polling, or offline |
Only the first row creates a Needs you count, navigation indicator, or
attention ordering. A dismissed, answered, superseded, or invalidated
needs_human run remains visible as history but becomes static; Activity
never mirrors a historical Jobs result into present-tense attention. Real
running work may still mark Activity and Automations as active. The browser
title follows the same authority:
● N need you — Outpost for open Inbox work, ▸ running — Outpost for real
execution, and plain Outpost at rest. See
docs/decisions/0021,
docs/decisions/0023,
and docs/decisions/0107.
Automations — Projects (card-workspaces)
A Project is the place whose files an automation can use. Existing
workspace slugs receive a conservative presentation fallback (weekly-repo-health
becomes Weekly repo health) without renaming the directory. The exact slug
and /workspace/<slug> path remain available in Details and copy/accessibility
text. Outpost does not store a second display-name alias: clone, fork, snapshot,
and API identity continue to use the one workspace name.
The underlying workspace table exists so automations (and a manual
clone/create) have somewhere to run, nothing more. Each /workspace/*
directory is a row showing cheap stats — its git branch
(parsed from .git/HEAD, no subprocess), relative mtime (hover for the
absolute time), and entry count — with Snapshot (floppy-disk),
History (snapshot restore/fork/delete), and Delete (trash)
buttons. Delete (DELETE /api/workspaces/<name>, CSRF-gated)
rmtrees the directory after two guards: a realpath-under-/workspace
check (same as create) and a refuse-if-running check — it returns
409 if any scheduled job currently running has its workdir inside the
workspace (wait for it to finish, or cancel it from Automations first),
so it can’t orphan a running job. The + New project action creates a
workspace directory (mkdir; DNS-1123 name regex
[a-z0-9][a-z0-9-]{0,62} + realpath traversal guard); it and Clone a
repo (gh repo clone) live in the section footer.
CSRF-gated. The surface refreshes on a 10s poll that pauses while the tab
is hidden, keeps the last-known-good rows through a transient blip (the
header’s polling / offline control appears when connection health
degrades), and — when
the oauth2-proxy cookie expires — swaps the whole failure mode for a
single “Session expired — sign in again” banner with the polls
suspended. A server-sent-events push channel (GET /api/events,
consumed by the landing page via EventSource) delivers job/inbox
changes in ~2s instead of the 10s cadence (the runs_watch.py watcher +
the create/delete/clone handlers publish instantly). The 10s poll stays
as the fallback + an independent liveness check (the broad refresh skips
while EventSource is open);
SSE streams through oauth2-proxy with X-Accel-Buffering: no so proxies
don’t buffer it.
The dashboard surfaces the logged-in identity (the verified Pocket ID
email) with a Sign out link — in the desktop sidebar rail and the
mobile top bar on the landing page, and in the run-detail page header.
Sign out navigates to oauth2-proxy’s /oauth2/sign_out, which clears
the proxy cookie and then redirects (rd) through Pocket ID’s
end-session endpoint to end the IdP session too — so the user actually
signs out rather than sailing straight back in. (That rd redirect only
fires because the IdP host is in oauth2-proxy’s --whitelist-domain;
without it the redirect is silently dropped and the Pocket ID session
lives on. Both halves derive from the same OUTPOST_OIDC_ISSUER_URL
env var — the whitelist entry in start.sh and the Sign out rd
target in the dashboard — so an issuer override moves issuer,
whitelist, and sign-out together.) This is the voluntary sign-out
affordance — the complement
to the reactive “Session expired” banner above, which fires only when
the cookie expires on its own.
Historical (retired 2026-07-06, middle-way plan Step 3,
docs/decisions/0033-outpost-automation-layer.md): this tab used to be Workspaces & Sessions — every workspace row also carried a + New session button, with live tmux sessions nested underneath (status dot for 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.
Automations — Your automations and Recipes (card-jobs)
The ordinary composer asks what Kin should do, which Project it should use, when it should run, and a friendly name. Common schedules use human choices with a plain-language next-run preview. Mode, timeout, retries, token budget, model pin, and raw cron remain available together under Advanced options; editing uses the same form and preserves untouched exact values.
Using a Recipe asks only for that recipe’s configuration. Its Project field is a friendly select whose option value remains the exact workspace path. For a schedule-bound Recipe, the bundled schedule is shown in Technical details and is not changed in the enable dialog; after Outpost creates the automation, Edit opens the normal composer where its schedule can be changed.
At rest, each row leads with the presentation name, readable schedule, Project, current execution or attention truth, last result, and next run. Slug-shaped legacy names may render with the same conservative fallback as Projects, but editing and APIs retain the exact stored name. Raw workspace path, cron expression, mode, model id, and recipe id/version live in Details unless one explains a current problem; long values never displace actions and remain recoverable without hover.
The saved list defaults to All automations and shows six rows initially. Matching automations that are running or need attention are pinned ahead of ordinary rows; if those present-tense rows alone exceed six, none are hidden. Show more reveals six more and Show fewer returns to the bounded view. Find an automation searches friendly and exact names, Project and exact workspace path, readable and raw schedule, time zone, Recipe, model pin, and last result. The View menu selects Needs you, Running now, Scheduled, Paused, Needs fixing, or Finished without inventing one aggregate status: an automation can truthfully satisfy more than one of those independent axes.
The result count describes the rows already returned by GET /api/jobs (the
complete dashboard list under the existing 100-automation structural cap).
Filtering and progressive disclosure happen in the browser; they add no API
fields or pagination and never change a stored automation. A no-match state
offers Clear filters, while a genuinely empty list keeps the next-step
guidance described above. Filter choices and the revealed count survive live
refreshes for the current page.
Under that presentation, each automation remains a recurring (or one-shot)
headless kin -p job in a workspace, driven by the dashboard’s own 60s
tick over the WAL sqlite job store (~/.kin/outpost/jobs.db). Run now,
pause/resume, run history, model re-pin, edit, and delete retain their existing
contracts. Creation, catch-up coalescing, single-flight,
retries + dead-lettering, the fail-closed model-drift pin, and needs-human
handling are all documented on their own page — Scheduled
automations. Job alerts (needs-human / dead-letter / drift)
ride the same delivery.send seam as session notifications (see
Messaging). Job CRUD is
dashboard-only by design — there is no harness tool for schedule management,
so a scheduled agent can’t schedule agents.
More → Advanced — Services and triggers (card-services, card-triggers)
The Advanced area is the browser-based front door for the machine door
(/api/v1/). It lets you register services, inspect webhook deliveries,
replay dead-lettered events, and manage triggers — all behind the Pocket
ID SSO gate (the human door), strictly separate from the Bearer-token machine
door. The same Advanced area owns per-service inbound-trigger management.
-
Services (
card-services) — lists every registered service with its public id, name, webhook-secret badge, and disabled state. Register new service opens a form (name + allowed workspaces + optional webhook URL) that mints the bearer token (svc_<id>.<secret>) + the webhook signing secret (whsec_…) and shows them exactly once in a copy-panel with the warning “Store these now — they cannot be retrieved again.” Click I’ve stored these to dismiss the panel. This 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_ciphertextis overwritten;prev_secret_ciphertextis preserved for KEK-rekey continuity only). Disable / Enable toggle the service’s auth (disabled → 401revoked-tokenon the v1 door). Each credential-sensitive action writes anaudit_logrow.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-idpreserved (PROTOCOL §10.5). The deliveries table + services list carry a focus guard: if you’re keyboard-focused on a button inside the card, an SSE tick won’t rebuild the DOM under you (no mid-press element rip-out).
Raw curl examples do not live in the authenticated control plane. Use
PROTOCOL.md and this guide for the versioned machine-door contract, keeping
operational UI focused on live services, deliveries, and triggers.
The machine door itself (/api/v1/) is unchanged by this area. The Advanced
portal adds only human-door routes under /api/services/* (CSRF + SSO); the
v1 middleware chain, endpoints, and the 30 PROTOCOL acceptance criteria stay
exactly as they were.
- Triggers (
card-triggers) — per-service inbound webhooks with signature-auth receivers: an external source (GitHub, Slack, generic curl) fires a governed one-shot job by POSTing a signed payload 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.
More → Settings
The cards sit in four groups: Model & keys (Providers, Endpoint, Model & tuning), Integrations (GitHub, Push, MCP Servers), Memory, and Reliability (Governor).
-
Providers (
card-providers) — one row per configured provider: the built-in presets (direct OpenAI/Anthropic, MiniMax, Z.ai) plus any custom[[providers]]rows. Each has a paste-key field + Save, a green ✓ key set badge, a ● default badge when it’s the active launch provider, and a get a key ↗ link. Keys are stored per-provider in 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. Tier-aware providers also show a capability selector inprovider_tiers: MiniMax and Z.ai size ordinary subagent capacity for the selected plan. Saving a key stores it — it isn’t verified here (use Endpoint’s Test for that). A trash button on a has-key row deletes that provider’s stored key (POSTs an empty value; the table merge treats empty as delete). CSRF-gated. The card also hosts the Web search (Brave) key — an external service credential (not an LLM provider, so it’s a separate row below the provider list) with the same paste-key + Save and a green ✓ key set badge driven off the[SET]mask. It’s a_SECRET_SETTINGS_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 Tailnet inference host’s 10.x/100.x address is allowed because the dashboard’s threat model is “user configures their own endpoint”, not “untrusted fetch”). It now shows a loading spinner, then a prominent green/red result box (reachable- model list, or the error) instead of a one-line note. The probe is an
unauthenticated
/v1/modelsGET — meaningful for the OpenAI-compat/vLLM endpoint, not the bearer presets (which is why Test lives here, not on the Providers card).
- model list, or the error) instead of a one-line note. The probe is an
unauthenticated
-
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— per-serve;enable_thinking— OpenAI-compatible or an explicit Poolside profile); 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. -
Push notifications (
card-push) — a browser Web Push subscription that fires a content-free notification for a new inbox item or alert and navigates back to the authenticated Outpost. Dormant until the box has a VAPID key pair — generate one and drop the private half in as a Docker file secret (never an env var):uv run --extra outpost python -m py_vapid --gen # writes private_key.pem + public_key.pem mv private_key.pem container/secrets/vapid-private-key chmod 600 container/secrets/vapid-private-key rm -f public_key.pem # the dashboard derives the public key from the private one at request timeRedeploy (
task outpost:deploy-dev/-main) sostart.shnormalizes the new secret intoVAPID_PRIVATE_KEY_FILE. The card’s badge flipsdormant→readyand shows the current subscribed-device count; the Subscribe this browser button requests notification permission, callspushManager.subscribewith 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 tomailto:support@kinra.ai; override per deployment. Full lane details (payload shape, the iOS constraint) are in Messaging. -
MCP Servers (
card-mcp) — add/enable/disable/delete entries in the global~/.kin/mcp.json(container/dashboard/mcp_api.py): paste one server’smcpServersJSON snippet (the same shape its README shows) + Save; each listed row shows the masked endpoint (never a resolved secret — every read goes throughkin.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): thememory_enabledon/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 thememorytool). The store lives under~/.kin/memory/in theoutpost-kinnamed volume, so memories survive redeploys. CSRF-gated toggle. -
Governor (
card-governor) — provider-independent admission control over background runs. The card’s Background limits section shows the live census and tunes api/scheduled max-running caps, queue depth, and per-class token/hour budgets for the next admission. The optional waiting-queue badge is unavailable on the current DS4 fleet; it activates only when an operator explicitly configures a compatible vLLM metrics endpoint. Full semantics, including the dormant vLLM compatibility switches, are in The governor — cross-run GPU fairness.