xhostddocs
Sign in Start building
On this page

Register as an agent

This guide is for an agent that works with no person at a browser. You open an xhostd account yourself, with an SSH key and no Google sign-in. The platform answers a token, and you hold every tool and every route with it.

You need a machine that runs commands. A client with no shell, such as claude.ai or ChatGPT, cannot sign a message, and a person connects it through OAuth instead. Read Connect your agent for that path.

What you get:

  • An account on the starter plan. The caps are the starter row of GET /plans, and the registration response repeats them:

    Field Value Meaning
    max_channels 3 Three channels, shared across your apps
    cpu_soft_cores 0.1 The fair share under contention; bursts to 2x
    mem_limit_mb 128 Memory per container
    blob_storage_bytes 268435456 256 MiB of object storage, enforced
    storage_mb 500 Database storage, advisory
    image_size_bytes 536870912 512 MiB charged image size
    port_forwarding false No public raw-TCP endpoint

    After connecting, call get_stats(view="overview") before work that depends on a resource limit or optional capability.

  • A private key at ~/.ssh/xhost_ed25519. The key is the durable credential. It never leaves the machine, and it renews the token without a person.

  • A 30-day xh_ token with the default scopes plus email:bind, the scope that binds an address to the account. The token is your git password over HTTPS, your Postgres password, and your bearer for the API and the MCP server. When it expires, the key mints a new one.

Before you start

You need three tools on the machine:

  • ssh-keygen from OpenSSH 8.0 or later. Older versions have no -Y sign.
  • curl.
  • python3, for the two short scripts that build the request body and store the token.

The machine's clock must sit within 300 seconds of the platform clock. The signed message carries a Unix time, and the platform refuses one outside that window.

Run every command in a subprocess. No secret then enters a tool call or the transcript.

Step 1: the key

Use exactly this path. It is the same key register_ssh_key uses, so one key serves the registration and every git push.

mkdir -p ~/.ssh; [ -f ~/.ssh/xhost_ed25519 ] || ssh-keygen -t ed25519 -N "" -f ~/.ssh/xhost_ed25519

The command makes a key only when the file is absent. An existing key on this machine is either registered already, in which case you sign in with it (read Renew the token), or it registers now.

The platform accepts ssh-ed25519 alone on this path. A person can paste an RSA or ECDSA key in the console for git, but a registration key is Ed25519.

Step 2: sign the registration message

The message is three lines of UTF-8 text, each with a trailing newline:

xhostd-register
<username or empty>
<unix time>

The second line is empty when the platform allocates the username. The namespace of the signature is xhostd-register.

WORK=$(mktemp -d); TS=$(date +%s)
printf 'xhostd-register\n\n%s\n' "$TS" > "$WORK/msg"
ssh-keygen -Y sign -f ~/.ssh/xhost_ed25519 -n xhostd-register "$WORK/msg"

ssh-keygen -Y sign writes the armored signature to $WORK/msg.sig.

To request a name, put it on the second line and send the same value as username in the next step. The two must agree, or the signature does not verify:

NAME=agentlisbon7
printf 'xhostd-register\n%s\n%s\n' "$NAME" "$TS" > "$WORK/msg"
ssh-keygen -Y sign -f ~/.ssh/xhost_ed25519 -n xhostd-register "$WORK/msg"

A name matches ^agent[a-z0-9]{5,35}$: the word agent, then 5 to 35 lowercase letters or digits. An allocated name is agent plus 8 random characters. The name is part of every hostname of the account, as in <app>-<username>.xhostd.app.

Step 3: post the registration

The body carries the public key line, the time you signed, and the signature. The response holds the token, so it goes to a file and never to stdout.

python3 - "$WORK" "$TS" <<'EOF' > "$WORK/body.json"
import json, pathlib, sys
work, ts = sys.argv[1], int(sys.argv[2])
print(json.dumps({
    "public_key": (pathlib.Path.home() / ".ssh/xhost_ed25519.pub").read_text().strip(),
    "timestamp": ts,
    "signature": pathlib.Path(work, "msg.sig").read_text(),
}))
EOF
curl -sS https://api.xhostd.com/registrations -H 'Content-Type: application/json' \
  --data @"$WORK/body.json" -o "$WORK/response.json" -w 'HTTP %{http_code}\n'

Add "username": "<name>" to the body when you signed a name, and "label": "<text>" to name the key on the account (64 characters or fewer; the default label is registration). Any other field answers 422.

The response, with the token shortened:

{
  "user_id": "3f1c2a7e-9b4d-4c1e-8a6f-2d5b7c9e0f13",
  "username": "agent7k2m9x4q",
  "plan": "starter",
  "token": "xh_...",
  "token_expires_at": "2026-10-07T12:00:00Z",
  "ssh_key_id": "b8e4d2c6-1a3f-4e5b-9c7d-0f2a4b6c8e1d",
  "fingerprint_sha256": "SHA256:x4bR5nQm7pZs2tVw9yAcE1gHjK3lMoPqR6sTuVwXyZ0",
  "git_ssh_host": "git.xhostd.com",
  "limits": {
    "tier": "starter", "rank": 0, "max_channels": 3, "cpu_soft_cores": 0.1,
    "cpu_burst": 2, "mem_limit_mb": 128, "storage_mb": 500,
    "blob_storage_bytes": 268435456, "image_size_bytes": 536870912,
    "snapshot_retention_days": 1, "deploy_snapshot_keep": 1,
    "port_forwarding": false, "agent_registration_only": true
  },
  "next": {
    "verify_email": "POST /me/email-verifications",
    "renew_token": "POST /auth/ssh-key"
  }
}

limits is the complete starter row of GET /plans. next names the two routes you call later: the one that verifies an email and the one that renews the token. git_ssh_host is the host of the SSH remote.

Step 4: store the token

Write the token to ~/.config/xhostd/token with mode 0600, and print the fields that hold no secret:

python3 - "$WORK/response.json" <<'EOF'
import json, os, pathlib, sys
r = json.load(open(sys.argv[1]))
if "token" not in r:
    print(r); sys.exit(1)          # an error envelope holds no secret
d = pathlib.Path.home() / ".config/xhostd"; d.mkdir(parents=True, exist_ok=True); d.chmod(0o700)
fd = os.open(d / "token", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.fchmod(fd, 0o600)
os.write(fd, r["token"].encode()); os.close(fd)
print({k: r[k] for k in ("username", "user_id", "plan", "token_expires_at", "fingerprint_sha256", "git_ssh_host")})
EOF
rm -rf "$WORK"

The file sits in $HOME, so every session on the machine reads it, and no repo holds it. The token never reaches stdout: a value in stdout is a value in the transcript, and a transcript is a log. The script prints the error envelope instead when the platform refused the request, because an envelope holds no secret. Read Errors for what each answer means.

Step 5: use the token

Write $(cat ~/.config/xhostd/token) in a command. Never paste the value.

Export it as XHOSTD_TOKEN too: export XHOSTD_TOKEN="$(cat ~/.config/xhostd/token)". The tool results and the guides fetch every download and credential link with $XHOSTD_TOKEN, because no tool result carries a token.

The MCP server. Add it with the token as a header:

claude mcp add --transport http xhostd https://mcp.xhostd.com/mcp \
  --header "Authorization: Bearer $(cat ~/.config/xhostd/token)"

The tools appear after the client connects again: /mcp, then xhostd, then reconnect, or a new session. Name the server xhostd.

The HTTP API. Every route in the API reference takes the same header:

curl -sS https://api.xhostd.com/apps \
  -H "Authorization: Bearer $(cat ~/.config/xhostd/token)"

Git. A push needs no token. The registration key is registered on the account already, so skip register_ssh_key; it answers 409 for this key, which is correct. Set the SSH remote and push:

git remote add xhostd-ssh "git@git.xhostd.com:<username>/<app>.git"
GIT_SSH_COMMAND="ssh -i ~/.ssh/xhost_ed25519 -o IdentitiesOnly=yes" git push xhostd-ssh HEAD:master

Push code with git explains the refspec and the HTTPS fallback.

Renew the token

The token expires 30 days after the mint. Sign a login message and post it to POST /auth/ssh-key. The message is three lines: the word xhostd-login, the key's fingerprint, and the Unix time. The namespace is xhostd-login.

xhostd-login
<fingerprint_sha256>
<unix time>

The fingerprint is the SHA256:... value ssh-keygen -lf prints, prefix included. It equals the fingerprint_sha256 field of the registration response.

FP=$(ssh-keygen -lf ~/.ssh/xhost_ed25519.pub | awk '{print $2}')
WORK=$(mktemp -d); TS=$(date +%s)
printf 'xhostd-login\n%s\n%s\n' "$FP" "$TS" > "$WORK/msg"
ssh-keygen -Y sign -f ~/.ssh/xhost_ed25519 -n xhostd-login "$WORK/msg"

Then build the body as in Step 3, post it to https://api.xhostd.com/auth/ssh-key, and store the token as in Step 4. The body has the same three fields and takes no username or label. The response:

{
  "token": "xh_...",
  "token_expires_at": "2026-11-06T12:00:00Z",
  "user_id": "3f1c2a7e-9b4d-4c1e-8a6f-2d5b7c9e0f13",
  "username": "agent7k2m9x4q"
}

A renewal changes the header the MCP client holds, so remove the server and add it again:

claude mcp remove xhostd
claude mcp add --transport http xhostd https://mcp.xhostd.com/mcp \
  --header "Authorization: Bearer $(cat ~/.config/xhostd/token)"

An account holds at most 20 tokens from this route. A 404 means the platform holds no key with this fingerprint that can sign in: register the key with Step 2 through Step 4. A key the register_ssh_key tool registered cannot sign in: that tool registers a git key and never sets api_login. A key a person added in the console can sign in only if they ticked the box that lets the key create API tokens.

Renew from the key, never from the token. A token never mints a longer-lived credential than itself: POST /credentials and POST /tokens both bound the new token by the caller's own expiry, so a credential minted from a 30-day token expires with that token. Neither route passes email:bind to the token it mints, so a credential you mint that way cannot bind an address. The 30-day clock restarts only when you sign a fresh login message with the key.

Losing the key locks the account out, and no operator can restore it. The key is the account's one durable credential. Lose ~/.ssh/xhost_ed25519 and the tokens you already hold keep working until they expire; after that nothing signs in, and nothing extends them. An account with a verified email recovers through Google sign-in on the console, where a person adds a new ssh-ed25519 key and ticks the box that lets it create API tokens. The box takes that key type alone. An account with no verified email has no recovery path: the remedy set is empty, and the platform holds no other proof of who owns the account. Two habits close the risk. Keep the key file where you wrote it, and never write it into a project directory. Verify an email as soon as a person gives you an address.

Verify an email for person-present access

A starter account has no email, so no person can open the console for it. When a person gives you an address, verify it. The account then moves to basic, and Google sign-in with that address opens the console for this account.

Use the token that POST /registrations or POST /auth/ssh-key answered. Binding an address opens the console for the account, so the request route takes a token carrying the email:bind scope, and those two routes are the only ones that grant it. A token you minted from POST /credentials reads 403 with a message that names the remedy: sign the login message again and call POST /auth/ssh-key, and use the token that one request answers.

You never handle a secret here. The platform mails a confirmation link to the address, and the person who reads that mailbox opens it in a browser and presses Confirm. Two MCP tools do your part, and each has a route.

  1. Call request_email_verification with email. The platform mails a link to the address and answers {status: "sent", expires_at}. The link works once, for 60 minutes. The answer never carries the address or the link.
  2. Ask the person to open the mail, read what the page shows, and press Confirm. Ask them to tell you when they have done it.
  3. Call get_email_verification once, after the person says they confirmed. It answers {status, expires_at, plan}. Do not call it in a loop.

status is one of five words:

status What it means What to do
verified The address is the account's sign-in email, and a starter account is now basic Nothing; tell the person they can sign in to the console with Google
pending The link is live, and nobody has confirmed it yet Ask the person to open the mail and press Confirm
expired The 60 minutes passed Request a new link
conflict The address belongs to another xhostd account Ask the person for another address
none No link was ever requested Request a link

The platform applies the basic limits in the background after the confirmation.

An account whose email address nobody confirms may be deleted after 7 days, so request a link as soon as a person gives you an address.

The same two calls over HTTP:

curl -sS https://api.xhostd.com/me/email-verifications \
  -H "Authorization: Bearer $(cat ~/.config/xhostd/token)" \
  -H 'Content-Type: application/json' --data '{"email": "<address>"}'

curl -sS https://api.xhostd.com/me/email-verifications \
  -H "Authorization: Bearer $(cat ~/.config/xhostd/token)"

The rules:

  • A token without email:bind answers 403 on the request route. Sign in again with the key through POST /auth/ssh-key and use the token it answers.
  • A second request inside 60 seconds of the last one answers 429. After that window a new request replaces the link, and the old one stops working.
  • One address receives at most 3 links in any 60 minutes, and at most 9 in all, whichever accounts ask for it. Past either cap the request route answers 429; after the 9th, use another address.
  • An account that has a verified email answers 409 on the request route.
  • POST /me/email-verifications/complete is retired and answers 410. Read GET /me/email-verifications instead.

Once the address is verified, a person who signs in to the console with it lands on this account. The person can set a password in the console and later sign in with the username or email address. The console also lists the registration key and tokens, and the person can revoke each one there.

Entitlement limits

When the current account entitlement does not permit an operation, explain that the feature or limit is unavailable, do not retry, and continue with an available alternative when possible. Do not recommend a plan change, display the subscription catalog, relay a purchase link, or call submit_feedback unless the user explicitly requests or approves the report.

Errors

Both anonymous routes answer the standard envelope, {"error": {"code": "...", "message": "..."}}.

Status Code Message What to do
400 bad_request the parser's own message Send one OpenSSH public-key line, ssh-ed25519 <blob> [comment]
400 bad_request only ssh-ed25519 keys can register or sign in Make an Ed25519 key
400 bad_request timestamp is outside the 300-second window; check the clock and sign again Fix the clock, then sign a fresh message
400 bad_request invalid signature, signature namespace mismatch, or the signature was made with a different key Sign the exact message bytes under the route's namespace with the key you send
400 bad_request an agent username is 'agent' followed by 5 to 35 lowercase letters or digits Pick a name that matches the rule, or send none
400 bad_request the label must hold 64 characters or fewer Shorten the label
404 not_found no api-login ssh key matches this fingerprint Login only: register the key, or use the key that registered
409 conflict username is taken Pick another name, or send none
409 conflict this ssh key is registered already; sign in with it through POST /auth/ssh-key Renew the token with this key; never make a second key
409 conflict could not allocate a username; retry Post the same body again
422 none FastAPI's validation body Read the field name in detail and correct the body
429 too_many_requests agent registration budget reached (global); retry later or (this source) The daily budget is spent; retry the next day
503 service_unavailable agent registration is closed An operator closed registration; a person signs up in the browser instead

404 is a login answer. The other rows apply to the registration, and the 400 rows apply to both.

What the platform stores

  • The public key, its SHA256: fingerprint, and the label. The private key never leaves the machine.
  • A keyed hash of the source address prefix, and never the address. The hash bounds how many accounts one source opens per day.
  • A digest of the token, and never the token. The response is its one copy.
  • The verified email, once you verify one. Until then the account holds no address.

If the account is deactivated, by its holder in the console or by the xhostd team, the platform removes its SSH keys and revokes its tokens. A login with that key then answers 404, and the same key can register a new account.

Enter a topic, task, or tool name.

Public documentation only · Search stays in your browser.