# xhostd — Agent cheat-sheet API: `https://api.randomimity.com` (override via `XHOST_API_URL`) OpenAPI: https://docs.randomimity.com/openapi.json Auth: `Authorization: Bearer $XHOST_TOKEN` on every authed call. Tokens start with `xh_`. Errors: `{"error":{"code":"...","message":"..."}}` — surface `message` to the user. ## Mental model - **App** owns a git repo (`https://git.randomimity.com//.git`) and one or more channels. - **Channel** binds to a git ref and exposes a hostname. - **Deploy** is two-step: `git push` stores code; `POST .../deploy` builds and ships it. Pushing alone does nothing. - Hostnames: `-.randomimity.app` for prod; `--.randomimity.app` otherwise. - DNS labels everywhere: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, ≤40 chars. Reserved app prefixes: `git api www admin preview staging`. Reserved channel name: `prod` (auto-created). ## Worked recipes Each recipe deploys one shape of app. It gives every file in full and the exact call sequence with the real responses. It also gives the log excerpt that proves the result, and the failure modes of that shape. Read the recipe for your shape **before** you write the code. - https://docs.randomimity.com/guides — index - https://docs.randomimity.com/guides/projects-and-channels — Projects & channels: Project scope, channel isolation, and shared account capacity. - https://docs.randomimity.com/guides/deployments — Deployments: Push a revision, review a deployment, and distinguish results from serving state. - https://docs.randomimity.com/guides/data-and-recovery — Data & recovery: Postgres, files, recovery points, exports, and restore safeguards. - https://docs.randomimity.com/guides/access-and-security — Access & security: Membership, credentials, protected agent actions, and app sign-in. - https://docs.randomimity.com/guides/troubleshooting — Troubleshooting: Find build, runtime, traffic, and resource evidence. - https://docs.randomimity.com/guides/git — push code with git: the credential, the remote URL, the `HEAD:master` refspec - https://docs.randomimity.com/guides/register-as-agent — register as an agent: the SSH key, the signed message, `POST /registrations`, the 30-day token, `POST /auth/ssh-key` to renew, an email to move to `basic` - https://docs.randomimity.com/guides/recipes-static — static site, no build step - https://docs.randomimity.com/guides/recipes-app-node — `app` template, Node 22 + Express - https://docs.randomimity.com/guides/recipes-app-python — `app` template, FastAPI + uvicorn - https://docs.randomimity.com/guides/recipes-docker — your own Dockerfile - https://docs.randomimity.com/guides/recipes-postgres — `DATABASE_URL`, migrations, snapshots - https://docs.randomimity.com/guides/recipes-blob — object storage via the injected `S3_*` env - https://docs.randomimity.com/guides/recipes-oauth — how to verify the `__Host-xhost_id` cookie - https://docs.randomimity.com/guides/recipes-port-forwarding — a public `host:port` for raw TCP - https://docs.randomimity.com/guides/recipes-worker — a background worker that serves no HTTP, readiness by `$XHOST_READY_FILE` - https://docs.randomimity.com/guides/recipes-commit-files — `commit_files`, for a runtime with no shell - https://docs.randomimity.com/guides/bkm — best practices: how to debug, stacks, budgets, the undo path - https://docs.randomimity.com/guides/diagnose-slowness — diagnose a slow app: `get_app_health`, read `findings` first, obey `action` - https://docs.randomimity.com/guides/client-blocked-deploy — when the client itself refuses `deploy` before it reaches xhostd: how to recognize it, and what the user changes ## Templates **Two ways to signal readiness.** Every non-`static` deploy is marked ready on whichever of these arrives first inside the time window: (1) the app answers `GET /` with a 2xx on `$XHOST_HTTP_PORT` (`$PORT` is still injected at the same value, so existing apps keep working, but it is deprecated and will be removed — use `$XHOST_HTTP_PORT` in new code), or (2) the app creates the file whose path is in the injected `$XHOST_READY_FILE`. Signal (2) exists so a channel with no HTTP surface — a queue consumer, a cron-style daemon, a stream processor — needs no dummy HTTP listener; the file can be empty, only its existence is checked, it sits directly under `/tmp` so neither `mkdir` nor a shell is required (a distroless image can just `open()` it), and its path is minted fresh per deploy so it can never be baked into an image. Create it once the app is genuinely doing its work (the consumer loop is subscribed and running), never as the first line of the start command. A channel that passes on the file keeps its hostname and route; that URL simply returns 502, which is expected. `XHOST_READY_FILE` is a **reserved env key** (`set_env` rejects it). - `static` — serves the committed files from the repo root (put `index.html` at the root; it answers `/`). Default. - `app` — a per-deploy image is built from your repo on a Node 22 / Python 3.13 runtime: `install.sh` (optional) runs at **build** time and its output (`node_modules`, virtualenvs, …) is baked into the image, then `launch.sh` (required) starts your process. A server must bind `0.0.0.0:$XHOST_HTTP_PORT` (read it from the environment; don't hardcode) and return HTTP 200 at `/` within 120s — a non-2xx (incl. an API with no `/` route) or nothing listening fails the deploy unless the app used the `$XHOST_READY_FILE` signal instead (above). Because `install.sh` now runs during the build (with build headroom, not under the app's per-plan runtime memory budget), heavy installs no longer OOM; that runtime budget applies to the running server. `install.sh` runs as **root**, so system-wide installs (`uv pip install --system`, `npm install -g`, `apt-get`) belong there — for Python prefer `uv` over `pip` (it is preinstalled in the base and resolves/installs dramatically faster). `launch.sh` runs at boot as the **non-root `app` user** (uid 10001, all Linux capabilities dropped); its writable paths are `/app`, `$HOME` and `/tmp`, so never install or write outside them at launch — an install in `launch.sh` fails with `Permission denied`. Charged image size is capped per plan exactly like `docker` (below); the Node/Python runtime base is a warm base, exempt from the charge, so only your code + dependencies count. - `docker` — the repo has a `Dockerfile` at its root; xhostd builds it on every deploy and runs the image with pure Docker semantics (your image's own `ENTRYPOINT`/`CMD` runs — no `install.sh`/`launch.sh`). Contract: bind `0.0.0.0:$XHOST_HTTP_PORT` (injected) and return 2xx at `/`, or create `$XHOST_READY_FILE` if the image has no HTTP surface (same health check as `app`); env vars are injected at run time only, NEVER as build args — secrets are unavailable during the build and must never be baked into an image; run migrations in the start command, e.g. `CMD ["sh","-c","alembic upgrade head && exec uvicorn app:app --host 0.0.0.0 --port $XHOST_HTTP_PORT"]`. Charged image size (total minus warm-base layers) is capped per plan (the same caps apply to the `app` template); `GET /plans` publishes every tier's cap and `get_account_overview` reports this account's. Match EVERY `FROM` to a warm base — including a build-only stage in a multi-stage build, since every stage that names one starts with no pull: `node:22-slim`, `node:24-slim`, `node:26-slim`, `python:3.11-slim`, `python:3.12-slim`, `python:3.13-slim`, `python:3.14-slim`, `debian:trixie-slim`. The final stage's warm-base layers are also exempt from the charged size. The deploy log streams `[build] ...` lines (queue position, build duration, size/cap result). ## Endpoints **Auth & accounts** - MCP clients with a person present (Claude Code, claude.ai): OAuth — no token. An agent with no person present registers itself: `POST /registrations` (no auth; `ssh-ed25519` public key + SSHSIG over `xhostd-register\n\n\n`, namespace `xhostd-register`) → `{user_id, username, plan: "starter", token, token_expires_at, ssh_key_id, fingerprint_sha256, git_ssh_host, limits, next}`; 409 key registered already (sign in instead), 429 budget, 503 closed. `POST /auth/ssh-key` (no auth; SSHSIG over `xhostd-login\n\n\n`, namespace `xhostd-login`) → `{token, token_expires_at, user_id, username}`, 404 for a key without `api_login`. `POST /ssh-keys` takes `api_login` (default false) and the key list shows it; `api_login: true` needs all nine default scopes plus either a verified address on the account or an `email:bind` token, and answers 409 otherwise, so an agent registers a replacement login key with the token these two routes answer. Guide: https://docs.randomimity.com/guides/register-as-agent - `POST /me/email-verifications` `{email}` → `{status:"sent", expires_at}` (403 without the `email:bind` scope, which only `POST /registrations` and `POST /auth/ssh-key` grant — sign in with the key again for a token that can bind, because no other request sequence reaches the scope; 409 verified already, 429 inside 60 s); `POST /me/email-verifications/complete` `{code}` → `{status:"verified", plan, apply_queued}` (no scope; 400 wrong, 410 none/expired, 429 after 5 wrong, 409 address owned elsewhere); success moves `starter` → `basic` and opens console sign-in to the address. MCP: `request_email_verification(email)`, `complete_email_verification(code)`. - Tokens (for git remotes, curl, CI): mint at `https://console.randomimity.com/tokens?label=` (shown once). Use as `Authorization: Bearer xh_…`. On 401, ask the user to re-mint at the same URL. - `POST /credentials` (bearer auth) → `{token, username, expires_at, scopes}` — a unified credential (git password + Postgres password + object-storage and download credential + platform API bearer) carrying the default scopes your calling token holds, all nine for a general credential, and living 30 days or until your calling token expires, whichever comes first; accepts an optional `scopes` subset and an optional `expires_in` in seconds (at most 2592000, and at most what your calling token has left), which compose. A mint never grants more scope, and never a longer life, than the caller holds, so a narrowed token renews itself here rather than widening, a short-lived one cannot outlive itself, and `POST /tokens` — whose grant is the fixed nine — answers 403 `scope_denied` to a token short of that set. `POST /ssh-keys` needs `repo:*`, and each download-token route needs the one scope it mints. MCP sessions get this via the `get_credentials` tool (see MCP section). **Apps & channels** - `GET /apps` → `{apps: [...]}` - `POST /apps` `{name, template?}` → app w/ auto-created `prod` channel. Total channels per account are capped per plan; each app consumes one slot for its `prod` channel, and every extra channel consumes another. `GET /plans` publishes every tier's caps, and `get_account_overview` reports this account's limit and headroom. - `GET|DELETE /apps/{id}` - `GET /apps/{id}/channels` - `POST /apps/{id}/channels` `{name, git_ref_binding}` where binding is `branch:` (one channel per branch; the legacy `branch:*` wildcard is deprecated and rejected at create time) - `GET|DELETE /apps/{id}/channels/{cid}` (cannot delete `prod`) **Deploy** - `POST /apps/{id}/channels/{cid}/deploy` `{sha?, ref?}` — at least one required; both means `sha` wins, `ref` is ignored. `sha` is 40-char hex or branch name; `ref` is a branch name (bare `master` or `refs/heads/master`) and xhostd resolves it to that branch's current HEAD. Returns `{deploy_id, status}`. - `GET /apps/{id}/channels/{cid}/logs?deploy={did}` → `text/plain` — the build/boot log of one deploy. - `POST /apps/{id}/channels/{cid}/runtime/log` → the RUNNING app's stdout/stderr, i.e. everything after the deploy window. The log is materialized as `/log/app.log` (one line per output line, RFC3339 timestamp prefix, stdout+stderr merged) inside a throwaway, network-less container with cwd `/log`, and the `command` you send runs there — any shell pipeline, e.g. `"tail -n 200 app.log"` or `"grep -i error app.log | tail -20"`. Debian userland: `sh`, `bash`, `grep`, `sed`, `awk`, `tail`, `head`, `cut`, `tr`, `sort`, `uniq`, `wc`, `find`, `xargs`, `python3`, `node`, `perl` — no `jq`, `rg` or `less`. Body: `command` (optional, ≤4096 chars — **omit it** and no container is started, you get the status header alone), `container_index`. Returns `output` (the command's combined stdout+stderr, ~256 KiB cap → `truncated`), `command_exit_code`, `timed_out` (30 s limit; partial output still returned), `log_bytes` (size of the whole log your command saw) plus the crash facts: `status`, `exit_code` (**your app's**, not the command's), `oom_killed` (true = your app exceeded its plan's memory limit), `restart_count`, `started_at`/`finished_at`, and `available_indices`. **The log survives a redeploy**: when a new version replaces a container the old container's log is archived, so pass an older `container_index` to read why the previous version crashed. Only stdout/stderr is captured — an app that writes its logs to a file inside the container has nothing here. 404 = nothing readable for that channel yet; 503 = the host can't answer right now. - `GET /apps/{id}/channels/{cid}/images` → `{images, image_cap_bytes}` — built-image inventory for docker channels, newest first: per image `{tag, sha, size_bytes, charged_size_bytes, matched_base, created, current}`; `images` is `null` (not an error) when the host agent is unreachable. **Env** - `POST /apps/{id}/env` `{key, value, kind?, channel_id?}` — key must match `^[A-Z_][A-Z0-9_]*$`. `value` is capped at 16 KiB of UTF-8, per value (not per app). `kind` is `env` (plain var) or `secret`; omitted, an existing key keeps its kind and a new key defaults to `env`; `channel_id` makes it a per-channel override (channel wins over the app-level default at deploy time). Reserved: `XHOST_USER`, `XHOST_SHA`, `XHOST_HTTP_PORT`, `PORT`, `XHOST_FORWARD_PORT`, `XHOST_READY_FILE`, `DATABASE_URL`, `DATABASE_URL_READONLY`, `DATABASE_HOST`, `DATABASE_PASSWORD`, `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION`. - `DELETE /apps/{id}/env/{key}?channel_id=` — with `channel_id`, deletes only that channel's override. - `GET /apps/{id}/env?channel_id=` — lists entries; with `channel_id`, the resolved view for that channel (`scope` = `app` or `channel`). The web console reads this view too, and edits env from it. Plain values are returned in cleartext; secret entries carry metadata only (`value: null`) in list responses. - `GET /apps/{id}/env/{key}/value?channel_id=` → `{key, kind, scope, value}` — reveals one value, the only read path for secrets (requires the `deploy:*` scope; every reveal is audit-logged as `env.reveal`). With `channel_id`, resolved semantics (channel override wins, app-level fallback); without, the app-level row. The web console's click-to-reveal uses this same endpoint. MCP has NO reveal tool — secrets stay write-only in MCP sessions. This route is a **protected action** — it answers `protected_action` to an agent credential, and the account or project **agent access** switch opens it. - `GET /apps/{id}/channels/{cid}/deploys/{did}/env` — the env snapshot a past deploy ran with: plain values, secrets masked, system-injected keys listed by name only. - MCP: `set_env(app_name, key, value, secret=None, channel=None)`, `delete_env(app_name, key, channel=None)`, `list_env(app_name, channel=None)`, `get_deploy_env(app_name, channel, deploy_id)` — `app_name` is the app *name* (a UUID also works) and `channel` is the channel *name* (e.g. `prod`). **SQL database** - Every non-static channel automatically gets its own Postgres database, with `DATABASE_URL` injected at deploy time, and `DATABASE_URL_READONLY` beside it — a second role that can `SELECT` from every table in `public` and run no write, for a query surface exposed to visitors; writes and migrations use `DATABASE_URL`, and data the read-only role must not see goes in a schema the app creates (`CREATE SCHEMA private`). Tables live in that database's stock `public` schema, so user code writes unqualified table names and a plain `pg_dump`/`pg_restore` round-trip lands where the app reads — read the connection string from `DATABASE_URL` rather than constructing it. A static-template channel gets no database, no role, and no `DATABASE_URL`, because a static site serves files and never queries Postgres. - Schema migrations are 100% user-managed and must run at deploy time, where `DATABASE_URL` is set: put `alembic upgrade head` / `prisma migrate deploy` / `drizzle-kit migrate` in `launch.sh` (`app` template) or the start command / `CMD` (`docker`). Do NOT put them in `install.sh` — it runs at build time with no env or DB access. - Per-channel inspection: `GET /apps/{id}/channels/{cid}/postgres` → `{db_name, role_name, status, connection_count, storage_bytes, password_set}`. - Wipe a channel's data: `POST /apps/{id}/channels/{cid}/postgres/reset {confirm_db_name}` — destructive: empties the channel's database, all data lost; role and password preserved so `DATABASE_URL` keeps working. Meant for deliberate, human-approved action. - Download a SQL dump: `GET /apps/{id}/channels/{cid}/postgres/dump` → `application/sql` stream (the channel's database, nothing else). - Per-user rollup: `GET /me/postgres/storage` → `{database_size_bytes, database_count}`. - Isolation: cross-user and cross-channel are both the DB boundary (Postgres rejects the connect). Channels share no namespace. Backups: one `nightly` snapshot per channel per day, kept 24h on Basic and 7 days on every paid plan (the newest is always kept, at any age), plus the `pre_deploy` snapshot taken before each deploy (the newest 1 on Basic and the newest 3 on every paid plan, no age limit). A static-template channel gets neither kind — a static channel has no database. List both with `list_channel_snapshots`, restore either with `restore_channel_db` / `POST .../postgres/restore`. - External access (opt-in, default-closed): enable **per project (per app)** via the console Project settings toggle — the toggle is a **protected action** and has no MCP tool: it answers `protected_action` to an agent credential, and the account or project **agent access** switch opens it (the flag is app-wide). Once on, **every channel** of the app is reachable; connect external tools directly: `psql "postgresql://:@db.{domain}:5432/-?sslmode=require"` (for the `prod` channel, the database name elides to just ``). The password is your existing xhostd token — no separate secret. Revoking the token or disabling the toggle cuts access immediately for all channels. **Object storage (S3-compatible)** - Auto-provisioned per channel (like SQL on a non-static channel) — no enable step. Use the MCP `get_blob_credentials(app_name, channel)` for the endpoint/bucket/key pair and `get_blob_usage(app_name, channel)` for bytes used. HTTP: `POST .../blob/credentials`, `GET .../blob`. - Deploys inject `S3_ENDPOINT`, `S3_BUCKET` (per-channel virtual bucket), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION` (`us-east-1`, neutral). Point any S3 SDK at these and **always read them from env — never construct them**: in-container `S3_ENDPOINT` is a platform-internal address, not a public URL, and it is not the endpoint the credentials call returns. The upstream provider is proxied and hidden. - From **outside** the container, use the channel's public blob endpoint — the `endpoint` field `get_blob_credentials` (MCP) / `POST .../blob/credentials` returns, `https://{channel-hostname-label}.s3.{domain}`, one per channel. - Bucket-wide object versioning is always on. Isolation is enforced at the gateway: a channel's key can only address its own key prefix; the gateway routes by key and compares the client bucket segment with the channel's own name (any other name → `404` `NoSuchBucket`), so cross-channel/cross-user access is denied. Per-user byte quota is metered at the gateway (over quota → `507`). - Snapshots: every deploy of a non-static app writes a timestamp marker + snapshot row (`pre_deploy`), and the nightly pass writes one per channel per day (`nightly`, same plan window as the DB kind); a static-template channel gets neither kind; restore walks each object back to the version current at that timestamp (copy-forward, never rewind). - Snapshot **restore** rolls the store back with `restore_channel_blobs(app_name, channel, snapshot_id)` (MCP) or `POST .../blob/restore {confirm_channel_name, snapshot_id}`, where `snapshot_id` is the checkpoint id from `list_channel_snapshots` whose `aligned_blob` flag is true (prod refused unless `XHOST_ALLOW_PROD_RESTORE=1`; refused mid-deploy with `channel_busy`, for a checkpoint with no aligned blob leg with `no_aligned_blob_snapshot`, or past the retention window). The **external-access** toggle is a **protected action** — it answers `protected_action` to an agent credential, and the account or project **agent access** switch opens it (app-wide; once on, connect `aws --endpoint-url s3 ...` with the channel's S3 key pair). **Custom domains** - Attach up to 5 custom domains per channel. Domains are globally unique across xhostd. - `POST /apps/{id}/channels/{cid}/domains` `{domain}` → 201 `{domain, status:"pending", reason:null, dns_records:{txt_host, txt_value, cname_target, a_values}, created_at, verified_at:null}`. Idempotent for the same channel (re-POST returns the existing row); 409 `domain_taken` if another channel owns it; 400 `domain_limit_reached` at 5; 400 `invalid domain: ` for malformed input (reasons: `is_ip_address`, `invalid_idna`, `too_long`, `invalid_label`, `platform_domain_forbidden`, `needs_dot`). - User then creates two DNS records: `TXT _xhost.` with the returned `txt_value`, plus a routing record — `CNAME ` → `cname_target` for subdomains, or `A ` → one of `a_values` for an apex. - `POST /apps/{id}/channels/{cid}/domains/{domain}/verify` → re-checks DNS, idempotent + retryable. Status flips `pending → verified` on a passing check. Closed reason set: `txt_nxdomain`, `txt_token_mismatch`, `txt_lookup_failed`, `dns_not_pointing`, `domain_nxdomain`, `dns_lookup_failed`, `platform_ip_unknown`. A verified domain only downgrades on the four definitive failures (`txt_nxdomain`, `txt_token_mismatch`, `dns_not_pointing`, `domain_nxdomain`) — transient lookup failures never downgrade. - `GET /apps/{id}/channels/{cid}/domains` → `{domains: [...]}` same shape. - `DELETE /apps/{id}/channels/{cid}/domains/{domain}` → `{ok:true}`. Detaches and stops cert renewals; the existing cert expires on its own. - HTTPS: certs mint automatically on the first TLS handshake after verification (Caddy on-demand TLS), so the first request may add a few seconds for issuance. Subsequent requests are normal HTTPS. - OAuth on custom domains: end-user sign-in works on every verified custom domain with no extra config — the identity cookie is scoped to the inbound Host (`aud` = that hostname), so login on `myapp.com` does not log in on the canonical `.randomimity.app` or other attached domains. - Full docs: . **Port forwarding (public raw-TCP endpoints)** - For a channel that speaks a **non-HTTP** protocol over TCP — a database, a message broker, a game server, a gRPC service, a custom binary protocol. Ordinary web traffic never needs this: the channel's `https://` URL already serves it. - Container-side port is **fixed at 7000**, injected as `XHOST_FORWARD_PORT` into every `app` and `docker` container beside `$XHOST_HTTP_PORT` — the process must listen on `0.0.0.0:$XHOST_FORWARD_PORT` (read it from the environment). `XHOST_FORWARD_PORT` is a **reserved env key** (`set_env` rejects it). `static` channels have no process that can receive a connection and are refused. - Two preconditions: a **paid plan**, and public TCP endpoints on for the project (Project settings → Port forwarding). The toggle is app-wide and is a **protected action** — it answers `protected_action` to an agent credential, and the account or project **agent access** switch opens it. The external database and object-storage toggles follow the same rule. Which *channel* is actually exposed is then the agent's call. - MCP: `expose_port(app_name, channel, allow_cidrs?)`, `list_exposed_ports(app_name)`, `unexpose_port(app_name, channel)`. - HTTP: `POST /apps/{id}/channels/{cid}/forward` `{allow_cidrs?}` → 201 `{channel_id, channel, host, port, allow_cidrs, active, created_at}`; re-POSTing an exposed channel returns 200 with the **same** `host`/`port` and the allowlist replaced (the address is stable for the life of the exposure, so it is safe to hand out). `GET /apps/{id}/forwards` → `{forwards:[...]}` (app-scoped — one round trip for every channel). `DELETE /apps/{id}/channels/{cid}/forward` → 204 — blocks reconnection only; sessions already established survive it, so deploy the channel afterwards to replace the container and end them. - `allow_cidrs` is a source-address allowlist — IPv4/IPv6 addresses or ranges (`203.0.113.7`, `203.0.113.0/24`, `2001:db8::/32`), at most 16 entries. Empty or omitted means reachable from anywhere. - Connect with the returned `host:port`. xhostd forwards bytes blind and adds no TLS and no protocol handling, so if the traffic needs encryption or auth, that belongs in your own protocol. - `active:false` on a returned endpoint means the allocation exists but is not serving — the project toggle or the plan gate is off. **End-user auth (Sign in with Google)** - xhostd is an identity provider, not a gatekeeper. It runs the Google sign-in dance and sets a signed identity cookie; nothing is blocked at the edge. Your app verifies the cookie and decides access itself (layer your own password/SMS login, allowlists, roles — or ignore `/xhost-auth/*` and run your own auth). - **Zero config.** `/xhost-auth/*` (login, logout, whoami) works on every deployed channel with no activation step. There is no protected-paths list — gating lives entirely in app code. - Identity cookie: after Google sign-in xhostd sets `__Host-xhost_id` — an `HttpOnly`, `Secure`, host-only cookie (the `__Host-` prefix forces `Path=/` and no `Domain`). Its value is an RS256-signed JWT with claims `{iss: "https://auth.randomimity.com", aud: "" (exact channel hostname), sub (stable Google id), email, name, picture (optional — absent when Google sends none or the URL fails the gateway's check), iat, exp}`. `__Host-xhost_id` is a **reserved cookie name** — apps must not set or read it as a raw value. - Verify it: fetch+cache the JWKS at `https://auth.randomimity.com/xhost-auth/jwks`; **pin `RS256`** and select the key by the token's `kid` (never let the token's own `alg` choose — prevents alg-confusion); require `iss == "https://auth.randomimity.com"`, `aud == ""`, `exp` not past. On success use `sub` as primary key (emails change). Use a real verifying JWT lib (`PyJWT` `PyJWKClient`, `jsonwebtoken`+`jwks-rsa`, `go-oidc`); **never** decode-only helpers (`jwt-decode`, `jwt.decode(..., verify=False)`) — they accept forged tokens. - Verify pseudocode: `jwks = fetch_and_cache(".../xhost-auth/jwks"); claims = jwt_verify(cookie["__Host-xhost_id"], jwks, algorithms=["RS256"], issuer="https://auth.randomimity.com", audience="")`. - Gate routes in app code: if verification fails, redirect browsers to `/xhost-auth/login?return_to=` or return 401/403. Restricting which signed-in users may proceed (allowlist, paid tiers) is the app's job — check `claims.email` / `claims.sub`. - Reserved on every channel: `/xhost-auth/login`, `/xhost-auth/logout`, `/xhost-auth/whoami`. `whoami` returns JSON `{logged_in, email, name, sub}` when signed in, else `{logged_in:false, login_url}` — SPA/JS-only apps call it since the cookie is `HttpOnly`. - Where to send users: `/xhost-auth/login?return_to=`; logout: `/xhost-auth/logout?return_to=/`. - JWKS + OIDC discovery: `https://auth.randomimity.com/xhost-auth/jwks` (RS256 public keys) and `https://auth.randomimity.com/.well-known/openid-configuration`. - Anti-spoof: xhostd unconditionally strips inbound `X-Xhost-*` request headers at the edge; read identity from the verified cookie, not headers. - Persistence: xhostd does not store end-user identities, so on first sight do `INSERT … ON CONFLICT (sub)` into your own DB if you need a user record. - Full docs (per-stack verify snippets for Python/Node/Go): . **Self** - `GET /api/user/stats` — counts and `sites` rows over every project the caller can see, shared projects included (`repo` reads `owner/project`); the `resources` block is the caller's own memory and CPU alone. - `GET /me/usage` (bearer) → `{plan, storage_bytes, storage_limit_mb, bandwidth_month_bytes}` — database storage against its SOFT cap, plus the month's measured egress. Over-limit storage warns on the console account page; it blocks no write and no deploy. The payload carries NO egress limit field, because no plan states an egress figure: nothing counts the month's bytes against anything, and no egress carries a charge. 503 `postgres_unavailable` when the account's database server is unreachable. - `GET /plans` (no auth, no DB read) → `{plans: [...]}` — every tier in ascending rank order with the caps a user shops on: `tier`, `rank`, `max_channels`, `cpu_soft_cores`, `cpu_burst`, `mem_limit_mb`, `storage_mb`, `blob_storage_bytes` (`-1` = unlimited), `image_size_bytes`, `snapshot_retention_days`, `deploy_snapshot_keep`, `port_forwarding`, `agent_registration_only` (true for `starter`, the entry tier only the agent registration API creates an account on; the console never offers it). No row states an egress figure, because no plan limits egress. No price: billing lives at Lemon Squeezy. This is the one published source of a plan number — read it instead of hardcoding one. - `get_account_overview(window)` (MCP) composes six sources in order — `/me/stats`, `/me/usage/resources`, `/apps`, `/me/usage`, `/me/blob/storage`, `/plans` — and returns `{window, traffic, resources, plan_headroom}`. `plan_headroom` carries `plan`, `cpu`, `memory`, `swap`, `channels`, `database_storage` (soft), `object_storage` (enforced, `unlimited: true` where the plan has no cap), `egress` (`month_to_date_bytes`, `enforcement: "none"`, `charged: false`), `image_size_bytes`, `snapshot_retention_days`, `deploy_snapshot_keep` and `port_forwarding`. Every `enforcement` field reads `soft`, `enforced`, or `none`: soft warns, enforced refuses the write, none means the dimension has no limit at all — `egress` states its measured bytes and no allowance, so it carries no limit and no remaining value. Nothing is clamped, so a negative remaining value is a real overage. A 503 from `/me/usage` degrades `database_storage` and `egress` to `available: false` with a reason and leaves every other block intact; any other failed source fails the whole call. ## Standard flows **First app** 1. `POST /apps {name, template}` → grab `id`, `channels[0].id`, `repo_url` 2. `git remote add xhost "https://:$XHOST_TOKEN@git.randomimity.com//.git"` (the token goes in the password field) 3. `git push xhost HEAD:master` 4. `POST /apps/{id}/channels/{cid}/deploy {sha: $(git rev-parse HEAD)}` **Redeploy**: commit → `git push xhost HEAD:master` → `POST .../deploy`. Same three lines, every time. **Branch preview**: create an explicit channel per branch (`POST /apps/{id}/channels {name, git_ref_binding: "branch:"}`), push the branch, then deploy via `{ref: ""}` (xhostd resolves to HEAD) or `{sha}`. The channel's hostname is `--.randomimity.app`. ## Git-less flow (no local git required) For agents without shell or git (web LLMs, Custom GPTs, MCP connectors): commits via HTTP+JSON, two-step like `git push` + deploy. - `GET /apps/{id}/tree?ref=master` → `{ref, sha, files: [{path, kind, size}]}` — list current files. Requires `repo:*`, like every repo-content read. - `GET /apps/{id}/blob?ref=master&path=index.html` → raw bytes — fetch one file before editing. Requires `repo:*`. - `POST /apps/{id}/changeset` `{ref?, message, changes: {path: content_or_null}}` → `{sha}` — string upserts, null deletes, absent paths unchanged. Creates one real commit on top of ref's HEAD (or initial commit if empty). Requires `repo:*`. `ref` defaults to `master`. Then ship it: `POST /apps/{id}/channels/{cid}/deploy {sha}` with the SHA returned above. Same two-step model as `git push` + deploy. Plays nicely alongside `git push` — both produce normal commits on the same bare repo. **First app, git-less**: 1. `POST /apps {name, template}` → grab `id`, `channels[0].id`. 2. `POST /apps/{id}/changeset {message, changes: {"index.html": "

hi

"}}` → `{sha}`. 3. `POST /apps/{id}/channels/{cid}/deploy {sha}`. ## GitHub-connected apps An app can use a GitHub repo as its source of truth (connect in the console). Mirror model: GitHub is authoritative; xhostd mirrors the repo into the app's internal repo on each sync. The pipeline and read tools (`list_files`, `read_file`, `deploy`, etc.) operate on the internal mirror unchanged. - While connected, `commit_files` and `git push` to xhostd are rejected (409/403) — push to GitHub instead. - Deploys auto-sync: each `deploy` first fetches the latest commits from GitHub into the mirror, then resolves/builds — so a deploy always reflects what was last pushed. If the fetch fails (e.g. the deploy key isn't set up yet), the deploy is rejected with a 409 carrying the sync error. - Sync without deploying via the console "Sync now" button or the `sync_git` MCP tool (both refresh the mirror and surface any sync error). - Connect and disconnect have no MCP tool — use the console or the HTTP API (`POST /apps/{id}/github/connect`, `DELETE /apps/{id}/github`). Both are **protected actions** — each answers `protected_action` to an agent credential, and the account or project **agent access** switch opens it. Disconnecting keeps the last mirrored state and re-enables commits. ## MCP server (claude.ai + Claude Code) Remote MCP server at `https://mcp.randomimity.com/mcp/`. OAuth (Google sign-in) handles auth for a person; a registered agent sends its own token as the header instead: - **claude.ai**: add as a custom connector; the OAuth flow runs on first connect. - **Claude Code**: install the plugin — `/plugin marketplace add xhostd/xhost-sdk` then `/plugin install xhost@xhost-sdk` (registers the MCP server + the xhost skill) — or register just the server with `claude mcp add --transport http xhost https://mcp.randomimity.com/mcp/`. Either way, then `/mcp` → xhost → **Authenticate** (browser opens; on WSL/SSH, paste the callback URL back when prompted). Static-token fallback for headless setups: append `--header "Authorization: Bearer xh_..."` to the add command. Exposes 54 tools: `list_apps`, `create_app`, `get_app`, `delete_app`, `list_channels`, `create_channel`, `delete_channel`, `list_files`, `read_file`, `commit_files`, `deploy`, `rewind`, `get_deploy_log`, `get_runtime_log`, `set_env`, `delete_env`, `list_env`, `get_deploy_env`, `get_account_overview`, `get_app_stats`, `get_app_health`, `list_channel_snapshots`, `restore_channel_db`, `download_channel_snapshot`, `get_blob_credentials`, `get_blob_usage`, `restore_channel_blobs`, `download_channel_blobs`, `add_custom_domain`, `verify_custom_domain`, `list_custom_domains`, `remove_custom_domain`, `expose_port`, `list_exposed_ports`, `unexpose_port`, `get_credentials`, `sync_git`, `register_ssh_key`, `list_ssh_keys`, `delete_ssh_key`, `request_email_verification`, `complete_email_verification`, `list_activity`, `create_thread`, `list_threads`, `add_note`, `list_notes`, `vote_note`, `add_app_feedback`, `list_app_feedback`, `submit_feedback`, `list_feedback`, `export_data`, `get_export_status`. This page is the authoritative tool list; MCP clients cache the tool set at connect time, so if a tool here is missing from your session, reconnect the connector to pick up newly added or removed tools. Same semantics as the HTTP API. Tools address an app by `app_name` — the name `list_apps` shows; qualify a shared app as `owner/name`; a UUID also works — and a channel by `channel`, its name (e.g. `prod`). The legacy `app_id`/`channel_id` UUID params remain as deprecated aliases (pass exactly one of a param and its alias); they retire after 2026-10-01. Neither SQL reset/dump nor the external-access toggles have MCP tools. Reset and dump exist as HTTP API endpoints — the destructive ones are meant for deliberate, human-approved action, not something an agent triggers mid-session. The external-access toggles are protected actions, and so is the project's public-TCP-endpoint toggle. Each answers `protected_action` (403) to an agent credential, until the app owner turns agent access on. Day to day, write app code that reads `DATABASE_URL` / the `S3_*` env instead. **Git push from an MCP session** (no dashboard token needed): pick the path from what the machine can do, before you push — never after a failure. **No shell** (a runtime such as the claude.ai connector cannot run git): use `commit_files` — `files` for whole content, `edits` for an anchored replacement inside an existing file, `patches` for anchored hunks. Prefer `edits` or `patches` on a file that already exists: they send the changed region, not the file. **A shell**: push over SSH. Reuse `~/.ssh/xhost_ed25519` if that file exists; if it is absent, make a keypair in a subprocess (`ssh-keygen -t ed25519 -N "" -f ~/.ssh/xhost_ed25519`) and send only the **public** half with `register_ssh_key`. Then `git remote add xhost-ssh "git@git.randomimity.com:/.git"` and `GIT_SSH_COMMAND="ssh -i ~/.ssh/xhost_ed25519 -o IdentitiesOnly=yes" git push xhost-ssh HEAD:master`, where `-o IdentitiesOnly=yes` stops ssh from offering another key it finds first. An SSH push needs no token; `get_app` returns the `repo_url` that holds `` and ``. SSH is first because the private half never enters a tool call — some agent runtimes refuse a tool call whose content holds a secret, and such a refusal is not a clean error an agent can act on. A key belongs to the account, not to one app, so one `register_ssh_key` call per machine covers every app. Always use exactly the path `~/.ssh/xhost_ed25519` — never the project directory, and never a per-project, per-app or per-tool suffix. It sits in `$HOME`, so every session, editor window and project on the machine reuses the one key instead of registering another. **Outbound port 22 blocked, or the SSH push fails**: the fallback is HTTPS. Call `get_credentials` → `{token, username, expires_at, scopes}` — a unified credential that expires in 30 days, or with your own token if that is sooner, so read `expires_at`. Put the token in the **password** field: `git remote add xhost "https://:@git.randomimity.com//.git"` (or `git remote set-url`) and `git push xhost HEAD:master`. Any username works; the password is what's checked. Both transports reach the same repo. The **same token is your Postgres password**: `postgresql://:@db.randomimity.com:5432/?sslmode=require`, where `` is the app name for the `prod` channel else `-` (requires external database access enabled in the console). On 401 after expiry, call `get_credentials` again and reset the remote URL. ## App-name slugify (for `init`-style flows) Lowercase, non-alphanumerics → `-`, trim hyphens, ≤40 chars. Reject reserved prefixes. Confirm with the user before creating. ## Status values - Channel: `provisioning` | `running` | `failed` (the latter when SQL provisioning failed at create time; the hourly sweeper retries) - Deploy: `queued` | `running` | `success` | `failed` - Postgres (per-channel): `provisioning` | `ready` | `failed` ## Scopes Default-issued: `repo:* deploy:* channel:* db:* blob:* stats:read exports:read snapshots:read blobs:read`. Public bearer statistics routes require `stats:read`. ## Error codes (HTTP) `auth_required` 401 · `token_invalid` 401 · `token_revoked` 401 · `scope_denied` 403 · `permission_denied` 403 · `protected_action` 403 · `admin_not_configured` 403 · `not_found` 404 · `bad_request` 400 · `conflict` 409 · `gone` 410 · `too_many_requests` 429 · `bad_gateway` 502 · `service_unavailable` 503 · `internal_error` 500