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 runtime with no shell, such as the claude.ai connector, cannot sign a message, and a person connects it through OAuth instead. Read Getting started 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_channels1 One app, with its prodchannelcpu_soft_cores0.1 The fair share under contention; bursts to 2x mem_limit_mb128 Memory per container blob_storage_bytes134217728 128 MiB of object storage, enforced storage_mb250 Database storage, advisory image_size_bytes536870912 512 MiB charged image size port_forwardingfalse No public raw-TCP endpoint The pricing page lists
starterbeside the other tiers. -
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. 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": 1, "cpu_soft_cores": 0.1,
"cpu_burst": 2, "visible_cores": 1, "mem_limit_mb": 128, "storage_mb": 250,
"blob_storage_bytes": 134217728, "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 starter row of GET /plans. next names the two routes you
call later: the one that moves the account to basic, 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.
The MCP server. Add it with the token as a header:
claude mcp add --transport http xhost https://mcp.xhostd.com/mcp/ \
--header "Authorization: Bearer $(cat ~/.config/xhostd/token)"
The tools appear after the client connects again: /mcp, then xhost, then
reconnect, or a new session. The server name xhost is the name the plugin
uses, so the skill's mcp__xhost__* names match.
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 xhost-ssh "git@git.xhostd.com:<username>/<app>.git"
GIT_SSH_COMMAND="ssh -i ~/.ssh/xhost_ed25519 -o IdentitiesOnly=yes" git push xhost-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 xhost
claude mcp add --transport http xhost 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 a person registered through
register_ssh_key or the console cannot sign in unless it was registered
with api_login set.
Verify an email and move to basic
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.
Two MCP tools do it, and each has a route.
- Call
request_email_verificationwithemail. The platform mails an 8-character code to the address and answers{status: "sent", expires_at}. The code expires 15 minutes after the mail. The code alphabet is lowercase letters and digits without0,1,i,l, ando, so a person can read it out with no ambiguity. - Ask the person for the code, and call
complete_email_verificationwithcode. Success answers{status: "verified", plan: "basic", apply_queued}. The platform applies thebasiclimits in the background;apply_queuedistruewhen that job is queued now, andfalsewhen a move holds the account and the job runs after it.
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/complete \
-H "Authorization: Bearer $(cat ~/.config/xhostd/token)" \
-H 'Content-Type: application/json' --data '{"code": "<code>"}'
The rules:
- A second request inside 60 seconds of the last one answers
429. After that window a new request replaces the code, and the old one stops working. - Five wrong codes lock the challenge, and every later code answers
429. Request a new code after the window. - An expired code, or no pending request, answers
410. Request a new code. - An address that another account verified already answers
409, and the challenge clears. The account keepsstarter. - An account that has a verified email answers
409on the request route.
Once the address is verified, a person who signs in to the console with it lands on this account. The console lists the registration key and the tokens, and the person can revoke each one there.
Upgrade
No route lets an agent pay. When a task needs a paid tier, call
submit_feedback with the upgrade request, and tell the person that paid
plans are bought in the console after the email is verified.
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.