# xhost — Agent cheat-sheet API: `https://api.xhostd.com` (override via `XHOST_API_URL`) OpenAPI: https://docs.xhostd.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.xhostd.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: `-.` for prod; `--.` 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). ## Templates - `static` — serves the committed files from the repo root (put `index.html` at the root; it answers `/`). Default. - `app` — runs `install.sh` (optional) then `launch.sh` (required) in a runtime with Node 22 and Python 3.12. The server must bind `0.0.0.0:$PORT` (read it from the environment; don't hardcode) and return HTTP 200 at `/` within 120s — the platform health-checks `/` on `$PORT`, and a non-2xx (incl. an API with no `/` route) or nothing listening fails the deploy. Apps run with a small memory budget (~128 MB), enforced during `install.sh` too, so heavy builds can run out of memory. - `docker` — the repo has a `Dockerfile` at its root; xhost 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:$PORT` (injected) and return 2xx at `/` (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 $PORT"]`. Charged image size (total minus warm-base layers) is capped per plan: free 512 MiB / basic 2 GiB / pro 4 GiB. Prefer these warm bases — instant builds, base size exempt from your image cap: `node:22-slim`, `node:24-slim`, `python:3.11-slim`, `python:3.12-slim`, `python:3.13-slim`, `debian:trixie-slim`. The deploy log streams `[build] ...` lines (queue position, build duration, size/cap result). ## Endpoints **Auth & accounts** - MCP clients (Claude Code, claude.ai): use OAuth — no token needed. See the MCP section below. - Tokens (for git remotes, curl, CI): mint at `https://console.xhostd.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 30-day unified credential (git password + Postgres password + platform API bearer) carrying the full default scopes; accepts an optional `scopes` subset. 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 (free 5 / basic 10 / pro 25); each app consumes one slot for its `prod` channel, and every extra channel consumes another. - `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` - `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_]*$`. `kind` is `env` (default, plain var) or `secret`; `channel_id` makes it a per-channel override (channel wins over the app-level default at deploy time). Reserved: `XHOST_USER`, `XHOST_SHA`, `DATABASE_URL`, `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`). 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. - `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_id, key, value, secret=False, channel=None)`, `delete_env(app_id, key, channel=None)`, `list_env(app_id, channel=None)`, `get_deploy_env(app_id, channel, deploy_id)` — `channel` is the channel *name* (e.g. `prod`). **SQL database** - Every channel automatically gets a Postgres schema in the user's database, with `DATABASE_URL` injected at deploy time. Its `search_path` is pinned to the channel's schema, so user code writes unqualified table names — read the connection string from `DATABASE_URL` rather than constructing it. - Schema migrations are 100% user-managed: put `alembic upgrade head` / `prisma migrate deploy` / `drizzle-kit migrate` in `install.sh`. The DB is reachable before `install.sh` runs. - Per-channel inspection: `GET /apps/{id}/channels/{cid}/postgres` → `{db_name, schema_name, role_name, status, connection_count, storage_bytes, password_set}`. - Wipe a channel's data: `POST /apps/{id}/channels/{cid}/postgres/reset {confirm_schema_name}` — destructive: drops + recreates the schema, 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 (single schema). - Per-user rollup: `GET /me/postgres/storage` → `{database_size_bytes, schema_count}`. - Isolation: cross-user → DB boundary (Postgres rejects connect). Cross-channel within a user → schema boundary (no `USAGE` granted by default). No backups in v1 — recommend regular dumps. - External access (opt-in, default-closed): enable **per project (per app)** via the console Project settings toggle — this is a deliberate human action, not available as an MCP tool (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 xhost 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, which is always on) — 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` (`https://s3.{domain}`), `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; read them from env rather than constructing them. The upstream provider is proxied and hidden. - 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 discards the client bucket segment and routes by key, so cross-channel/cross-user access is denied. Per-user byte quota is metered at the gateway (over quota → `507`). - Snapshots: every deploy writes a timestamp marker + snapshot row; restore walks each object back to the version current at that timestamp (copy-forward, never rewind). - Snapshot **restore** has no MCP tool — it is a deliberate, human-approved action via the dashboard or the HTTP API: `POST .../blob/restore {confirm_channel_name, snapshot_id}` (prod refused unless `XHOST_ALLOW_PROD_RESTORE=1`; refused mid-deploy or past the retention window). The **external-access** toggle is dashboard-only (app-wide; once on, connect `aws --endpoint-url https://s3.{domain} s3 ...` with the channel's S3 key pair). **Custom domains** - Attach up to 5 custom domains per channel. Domains are globally unique across xhost. - `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 `.xhostd.com` or other attached domains. - Full docs: . **End-user auth (Sign in with Google)** - xhost 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 xhost 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.xhostd.com", aud: "" (exact channel hostname), sub (stable Google id), email, name, 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.xhostd.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.xhostd.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.xhostd.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.xhostd.com/xhost-auth/jwks` (RS256 public keys) and `https://auth.xhostd.com/.well-known/openid-configuration`. - Anti-spoof: xhost unconditionally strips inbound `X-Xhost-*` request headers at the edge; read identity from the verified cookie, not headers. - Persistence: xhost 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` ## 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.xhostd.com//.git"` (the token goes in the password field) 3. `git push xhost master` 4. `POST /apps/{id}/channels/{cid}/deploy {sha: $(git rev-parse HEAD)}` **Redeploy**: commit → `git push xhost 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 `--.`. ## 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. - `GET /apps/{id}/blob?ref=master&path=index.html` → raw bytes — fetch one file before editing. - `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; xhost 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 xhost 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`). Disconnecting keeps the last mirrored state and re-enables commits. ## MCP server (claude.ai + Claude Code) Remote MCP server at `https://mcp.xhostd.com/mcp/`. OAuth (Google sign-in) handles auth for both clients — no manual token: - **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.xhostd.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 31 tools: `list_apps`, `create_app`, `get_app`, `delete_app`, `list_channels`, `create_channel`, `delete_channel`, `list_files`, `read_file`, `commit_files`, `deploy`, `get_deploy_log`, `set_env`, `delete_env`, `list_env`, `get_deploy_env`, `get_app_stats`, `list_channel_snapshots`, `restore_channel_db`, `get_blob_credentials`, `get_blob_usage`, `add_custom_domain`, `verify_custom_domain`, `list_custom_domains`, `remove_custom_domain`, `get_credentials`, `sync_git`, `list_activity`, `submit_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. None of SQL reset/dump, object-storage restore, or the external-access toggles have MCP tools. Reset, dump, and blob restore 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 console-only. Day to day, write app code that reads `DATABASE_URL` / the `S3_*` env instead. **Git push from an MCP session** (no dashboard token needed): call `get_credentials` → `{token, username, expires_at, scopes}` — a unified credential expiring in 30 days. Put the token in the **password** field: `git remote add xhost "https://:@git.xhostd.com//.git"` (or `git remote set-url`) and `git push xhost master`. Any username works; the password is what's checked. The **same token is your Postgres password**: `postgresql://:@db.xhostd.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:* channel:* deploy:*`. Postgres operations on owned apps/channels use the standard channel scope — no separate scope. ## Error codes (HTTP) `auth_required` 401 · `token_invalid` 401 · `token_revoked` 401 · `scope_denied` 403 · `permission_denied` 403 · `admin_not_configured` 403 · `not_found` 404 · `bad_request` 400 · `bad_gateway` 502 · `internal_error` 500