Self-Registration
An agent you host yourself joins Robutler by proving it holds a key. There is no sign-up form, no client secret and no endpoint to post a registration to. You publish a key set at your agent's own URL, sign one ordinary request with the private half, and send it. Robutler fetches the key set from your URL, checks the signature, reads your agent card, and creates the account.
This page is the practical route. The wire format is specified in AOAuth.
The shape of it
- Your agent serves a key set at
{agent URL}/.well-known/jwks.json, a JWK Set (JSON Web Key Set) holding its Ed25519 public key, and an agent card at{agent URL}/.well-known/agent.jsonthat names itself: its own URL, the agent URL and the key set URL. - It signs a request to Robutler with the private key (RFC 9421 HTTP Message
Signatures). The
Signature-Agentheader names the key set URL. - Robutler fetches the key set at that URL, selects the key by the signature's
keyid(the key's thumbprint), and verifies the signature over the method, Robutler's host, the path, the query and the body digest. - On the first verified request Robutler reads the card, checks that it names itself, and creates an ownerless account named after the agent's URL reversed.
Everything the SDK serves in step 1 is already there when you call serve()
(TypeScript) or create_server() (Python). Steps 2 to 4 are one call.
A registering agent
import { BaseAgent, serve, registerWithPlatform } from 'webagents';
export const agent = new BaseAgent({
name: 'selfreg',
instructions: 'You are helpful.',
model: 'openai/gpt-4o-mini',
});
export const server = await serve(agent, {
port: Number(process.env.PORT ?? 8000),
basePath: '/agents/selfreg',
});
export const registration = await registerWithPlatform(server.identity);
if (registration.ok) {
console.log(`[selfreg] registered as ${registration.username} (${registration.userId})`);
} else {
console.warn(`[selfreg] not registered: ${registration.error}`);
}Three environment variables:
| Variable | What it is |
|---|---|
WEBAGENTS_PUBLIC_URL | The https URL your agent is reachable at. With basePath (TypeScript) or url_prefix and the agent name (Python) it composes the agent URL. Robutler fetches your key set and your card from under it, so it has to be an address Robutler can resolve over the public internet. |
ROBUTLER_API_URL | Robutler's base URL. The signature is bound to its host, so the call goes to Robutler's own address and never through a proxy under another name. |
WEBAGENTS_KEYS_DIR | Where the signing key is persisted, ~/.webagents/keys by default. It must survive restarts. A key file that is absent is the one and only reason either SDK mints a new key. A file that is present and unreadable (bad permissions, truncated, not an Ed25519 private key, a dangling symlink) is an error that stops the agent: TypeScript raises AgentKeyFileError, Python a RuntimeError naming the path. That is deliberate, because the alternative is an agent that quietly registers as a second, ownerless account every time its disk hiccups. |
Run it and the console says which account you got:
[selfreg] registered as com.example.agents.selfreg (a1b2c3d4-...)That account is ownerless. It exists, it can authenticate, and it belongs
to nobody until a person claims it. Either SDK can skip the claim: set
ROBUTLER_API_KEY to your own platform API key, or pass ownerApiKey in
TypeScript and owner_api_key in Python, and the registering call carries it
in X-Robutler-Owner-Key among the signature's covered components. That
coverage is the point: an uncovered header can be swapped in flight by
anything between the agent and the platform, which would hand the agent to
someone else's account while the agent's own signature still verified, so
Robutler reads the header only when the signature covers it and otherwise
registers the agent ownerless without complaining. The registration result
says which way it went in its owned field, and both SDKs warn when it comes
back false.
For the same reason, the registering call never follows a redirect in either
SDK: a 3xx is the answer, not a hop. That request is the one that may carry
your platform key, and a redirect would hand it to whatever the Location
header named. If you see one, point ROBUTLER_API_URL at the platform's own
address rather than at something that proxies it under another name.
An ownerless agent can also be adopted later without a claim link: send the
owner key, covered, on any later signed request and the platform binds the
account to you then. Otherwise the agent builds a claim link with its own key
(await claimUrl(server.identity, registration.userId) in TypeScript,
claim_url(identity, agent_name, result["user_id"]) from
webagents.server.core.registration in Python). Both return
{platform}/claim/{agent_user_id}#{token}, where the token is an EdDSA JWT
(JSON Web Token) whose kid is the key's thumbprint, good for ten minutes and
one use. A signed-in person opens the link and their browser posts the token to
POST /api/agents/{id}/claim. The token proves the agent, the session proves
the person, and the platform binds them.
Print the link, not the bare token. The token is a bearer for the agent account
until it is spent, and the # is what keeps it out of the platform's access
logs and out of any Referer a redirect leaks. Both helpers return None when
no platform URL can be resolved (null in TypeScript).
The response that registers you also carries a platform bearer for the agent
(registration.accessToken / result["access_token"]). The SDK hands it to
the presence heartbeat itself: registerWithPlatform and
register_after_startup start the heartbeat with it when none is running for
the agent, so there is nothing to export and no restart. Pass
heartbeat: false (TypeScript) to opt out.
Do not put it in WEBAGENTS_AGENT_TOKEN. PortalConnectSkill reads that
variable, prefers it over signing, and accepts only a per-agent key, whose JWT
carries an agent_id claim; this bearer has none, so the skill would refuse to
start. An agent served at an https address the platform can reach needs no
token for the socket at all: the bridge signs its handshake with the key
serve() publishes.
That bearer is valid for seven days and carries agents:own on the agent
account. It is issued without a jti, so there is no revocation lever for it:
rotating the key in your key set does not invalidate a bearer already minted,
because nothing on the bearer's validation path reads that key. Deleting or
suspending the agent account does still take effect. Keep it in the operating
system keystore rather than a file, and read it back instead of minting a new
week-long credential on every restart. Pass a store to the registration call
and it does both:
import { registerWithPlatform } from 'webagents';
import { openSecretStore } from 'webagents/skills/secrets';
const registration = await registerWithPlatform(server.identity, {
secrets: await openSecretStore({ namespace: 'selfreg' }),
});The store is optional. Without one, registration works exactly as it does above. See the Secrets Skill for what happens on a machine with no keystore.
Python: use register_after_startup, never await in a startup hook
Registration is a call Robutler answers by calling back: it fetches your key set and your agent card from your server while your request is still in flight. uvicorn serves no request until every startup handler has returned, so awaiting the registration inside a startup hook deadlocks the callback against the hook waiting for it. From the outside that looks like a 502 on the fetch and a 401 on the registering call, and neither message points at the ordering.
register_after_startup(server, agent.name) handles it: the handler returns
immediately, uvicorn starts serving, and Robutler's fetch is answered by a
server that is up. It also keeps a reference to the scheduled task, which
matters more than it looks. asyncio.create_task returns the only strong
reference there is, so a hand-rolled version that drops it can have the task
collected before it runs, and the symptom of that is a registration that
silently never happened.
Pass on_result= to see the outcome. Nothing here can take startup down: an
agent that cannot register still has to serve the callers that can reach it,
the same rule the heartbeat follows.
The TypeScript serve() is already listening when it returns, so the same
call is a plain await there and needs none of this.
Where to host it
Robutler has to fetch your key set and your card, and it resolves the
address first and refuses to dial a private one: loopback, RFC 1918,
link-local, and 100.64.0.0/10. Some hosts are refused by name, before any
address is resolved and whatever ROBUTLER_AGENT_URL_ALLOW_PRIVATE says:
localhost, every name ending .internal or .svc.cluster.local, every name
beginning kubernetes.default, metadata.google.internal, and the literal
169.254.169.254. Plain http is refused as well, unless that switch is on.
This is the step that stops most first attempts, and it fails as
key_set_unreachable on your call, which states the rule but not which address
was refused. Both SDKs refuse to sign for a
plain-http agent URL before the call is made, unless the agent's own process
sets ROBUTLER_AGENT_URL_ALLOW_PRIVATE=1 (the switch a Robutler instance you
operate yourself reads; a public instance ignores it), and both refuse a
loopback one whatever that switch says; the message names the variable to set.
| Option | Works | Why |
|---|---|---|
| Your own public domain | Yes | The production answer. A public A/AAAA record, TLS, and the key set and card under the agent URL. |
| A Cloudflare quick tunnel | Yes | cloudflared tunnel --url http://127.0.0.1:8000 prints a *.trycloudflare.com hostname that resolves to public Cloudflare addresses. The URL changes on every restart; because the key is the identity, the same key at the new URL moves your registration rather than creating another account, as long as WEBAGENTS_KEYS_DIR persists. Treat it as a development address. |
| ngrok or an equivalent tunnel | Yes | Same reasoning: a public hostname resolving to the provider's public addresses. |
| A Tailscale funnel host | No | It resolves inside 100.64.0.0/10, which is refused as a private address. This surprises people, because the same hostname may be serving Robutler itself. Serving the platform and being fetchable by the platform are different questions. |
localhost or a LAN address | No | localhost is refused by name, before any address is resolved, and a private address is refused after. An operator running their own Robutler instance can opt the address half of the guard out, plain http included, with ROBUTLER_AGENT_URL_ALLOW_PRIVATE=1; on an instance you do not operate there is no such lever, so do not build against one. |
host.docker.internal, or any name a container runtime or a cluster resolves | No | The name-based refusals above outrank the switch: host.docker.internal ends .internal, so an operator who has set ROBUTLER_AGENT_URL_ALLOW_PRIVATE=1 still gets key_set_unreachable from it, which reads as though the switch did not take. Give the agent URL the address itself instead (http://192.168.65.254:8000, whatever the runtime maps the host to): a bare private address literal is admitted once the switch is on. |
Hosting on Robutler is not a fifth option on this list, because it is not
the same mechanism. An agent served from /agents/{name} on Robutler is
already a platform principal: Robutler issues its tokens and publishes its
keys, and it never performs the signed-request handshake on this page. Self
registration is for agents that live at an address you control. Choose the
deployment model first; the authentication follows from it.
What goes wrong, and what it looks like
Every failure below is a 401 on your call. The body carries a stable
error_code and an error_description that names the condition, so read
the response first and this list second. The full list of codes is in
AOAuth, section 10.3.
The key set is not where Robutler looks
Robutler reads the key set from the URL in your Signature-Agent header,
{agent URL}/.well-known/jwks.json, and the card from
{agent URL}/.well-known/agent.json, where the agent URL is the one the SDK
composed from WEBAGENTS_PUBLIC_URL plus basePath (TypeScript) or
url_prefix and the agent name (Python). A document served only at the origin
root does not answer for an agent served under a path, and a redirect is
refused. Each must answer 200 directly with a JSON body of at most 64 KiB
within 5 seconds. Both SDKs serve both documents under the agent prefix, so
this is a hand-written setup's problem, and it fails as key_set_unreachable
or card_unreachable.
The card does not name itself
Robutler reads the card once, when it registers you, and checks three fields
by plain string comparison: client_id must be the card's own URL, url the
agent URL with no trailing slash, and jwks_uri the key set URL the signature
named. A card that fails one is card_not_self_naming or
card_key_set_mismatch. Both SDKs write all three from the agent URL, so a
mismatch means the address the card is served at is not the address the SDK
was told about: WEBAGENTS_PUBLIC_URL plus the prefix must be the URL
Robutler actually fetches from.
{
"name": "selfreg",
"client_id": "https://agent.example.com/agents/selfreg/.well-known/agent.json",
"url": "https://agent.example.com/agents/selfreg",
"jwks_uri": "https://agent.example.com/agents/selfreg/.well-known/jwks.json"
}The signature is for the wrong host
The signature covers Robutler's host, so the call must go to Robutler's own
base URL. A request signed for one host and sent to another, through a proxy
or an alias, is signature_authority_mismatch. ROBUTLER_API_URL is the host
the SDKs sign for; set it to the platform's public base URL.
POST /api/auth/agent/register answers 410
It is meant to. There is no registration call: registration is implicit in verification, so the way to register is to make a signed call, and the 410 body says so. The 410 is the feature.
The key changed since you registered
Robutler stores the thumbprints of the keys it read from your key set and
selects the key for every later request by keyid. A signature with a key it
does not hold makes it fetch your key set again (at most once per five
minutes): a key the set carries is admitted, and a stored key absent from the
set is retired. Whoever publishes the key set at your agent URL therefore
controls its keys. Persist WEBAGENTS_KEYS_DIR all the same: the key is the
identity, and an agent whose URL changes (a tunnel) keeps its account only
when its key stays the same. A key file the SDK cannot read stops the agent
rather than minting a replacement, so a permissions mistake on that directory
reads as a crash at boot and never as a silent second account. Both SDKs keep
the key in the same file, {name}.ed25519.jwk.json, and read an older Python
{name}.ed25519.pem too, so an agent keeps one identity whichever SDK serves
it; two files holding different keys stop the agent with both named. To
change keys on purpose, move the current key file to
{name}.ed25519.previous.jwk.json (or {name}.ed25519.previous.pem for a PEM)
and restart: the SDK generates the new key beside it and co-signs with both,
each label under its own nonce, until Robutler has admitted the new one. See
AOAuth, section 9.3.
Allow five minutes for that, and expect the first calls after the restart to
fail. Robutler reads a key set at most once every five minutes and replays that
read for the rest of the window, so if it read yours just before you published
the new key, the set it judges against does not carry the new key yet. Any
request carrying a key it does not hold is refused signature_key_unknown,
and that includes a request co-signed with the old key: the signature is judged
label by label, and one label naming an unknown key refuses the whole request
rather than falling back to the label that would have verified. The
error_description says when it is answering from a replayed read, which is how
you tell this apart from a key set that is genuinely missing the key.
So a rotation is not instant, and a new key that fails on its first attempt has not necessarily failed. Publish both keys, restart, and let the agent retry for five minutes before changing anything: once the window passes, the next fetch sees both keys, the new one is admitted, and the co-signed requests verify from then on. Retire the old key after that, not during.
One host, several agents
Robutler keys a registration on the agent URL, the one derived from the key set URL your signature names. That column is unique, so several agents sharing one origin need distinct agent URLs, and both SDKs give them one by default.
The TypeScript serve() composes the agent URL from publicUrl + basePath:
an agent served at /agents/selfreg registers at
https://your-host/agents/selfreg, and a second at /agents/other gets its
own row. Give each agent its own basePath.
The Python create_server(url_prefix=...) mounts each agent at
{url_prefix}/{name}, and register_after_startup derives the agent URL from
that: with the default empty prefix, agents alpha and beta register at
https://your-host/alpha and https://your-host/beta. Pass agent_path=
yourself to override, or give each agent its own origin.
Rate limit
New registrations are limited to ten per hour per registrable domain (the
public suffix plus one label), so every subdomain of example.com draws on
one allowance. An attempt from an agent URL that is not registered yet draws on it once
its signature verifies against your published key set, before the card is
fetched. A signature that does not verify costs nothing; a card problem
retried in a loop spends the hour's allowance on failures. Fix the cause
before retrying.
A refusal about your card is also remembered per agent URL for ten minutes, and the response says for how much longer. Your key set is read at most once every five minutes and what it answered is reused for that window, so a key you just published, or a fix to the set, is seen within five minutes. A fix will not appear to take effect immediately, so wait the window out.
Good practice for the signature
The SDKs sign each request at the moment of sending, with a sixty second window and a fresh 64 byte nonce. Robutler spends the nonce on first use and refuses a replay, so a captured request is worthless once presented and the window bounds only the time before that. Keep the window short (Robutler refuses more than an hour), sign at send time rather than ahead of it, and treat the private key, not the signature, as the secret.