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
starterplan. The caps are thestarterrow ofGET /plans, and the registration response repeats them:Field Value Meaning max_channels3 Three channels, shared across your apps cpu_soft_cores0.1 The fair share under contention; bursts to 2x mem_limit_mb128 Memory per container blob_storage_bytes268435456 256 MiB of object storage, enforced storage_mb500 Database storage, advisory image_size_bytes536870912 512 MiB charged image size port_forwardingfalse 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 plusemail: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-keygenfrom 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.
- Call
request_email_verificationwithemail. 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. - 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.
- Call
get_email_verificationonce, 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:bindanswers403on the request route. Sign in again with the key throughPOST /auth/ssh-keyand 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
409on the request route. POST /me/email-verifications/completeis retired and answers410. ReadGET /me/email-verificationsinstead.
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.