Skip to main content
You’re going to make an AI agent sign itself up to a service that supports AFAuth. The agent generates its own Ed25519 keypair, derives a did:key identifier from it, signs an HTTP request per RFC 9421, and the service treats it as a first-class principal — with no human approving the signup.

Install

pnpm add @afauthhq/agent     # client / agent
pnpm add @afauthhq/server    # service handlers + Verifier
pnpm add @afauthhq/worker    # Cloudflare Workers bindings
pnpm add @afauthhq/core      # primitives shared by the above

Make your first signed request

import { Agent } from "@afauthhq/agent";

const agent = await Agent.generate();
console.log(agent.did); // "did:key:z6Mk..."

// Build a signed GET against the service's /afauth/v1/accounts/me endpoint.
const signed = await agent.buildAccountIntrospection({
  baseUrl: "https://api.example.com",
});

const res = await fetch(signed.url, {
  method: signed.method,
  headers: signed.headers,
});

const account = await res.json();
console.log(account);
// { account_id: "acct_...", agent_did: "did:key:z6Mk...", state: "UNCLAIMED", ... }
A few things just happened:
  • Agent.generate() made a fresh Ed25519 keypair and computed a did:key for the public half.
  • buildAccountIntrospection produced a fully signed request: covered components (@method, @target-uri), signature parameters (created, expires, nonce, keyid, alg), and the Ed25519 signature itself. See Signing requests.
  • The first signed request from an unrecognised DID triggers implicit signup on the service (§6.3). The account exists immediately in UNCLAIMED state.
The UNCLAIMED result above assumes a service in unclaimed_mode: "free". Services created with defineService default to attested_only (§9.2) — including the one from the service quickstart — and reject an un-attested request with 401 attestation_required, creating no account. The next step makes your agent acceptable to them.
Persist agent somewhere durable — the private key is the sole credential for pre-claim operations.
const privateKey = agent.exportPrivateKey();
// Store privateKey securely. See concepts/identity-and-keys.
Services created with defineService default to unclaimed_mode: "attested_only" (§9.2) — the service from the service quickstart is one of them. They reject an un-attested signed request with 401 attestation_required and create no account. To be accepted, the agent presents a short-lived JWT from the canonical trust attestor at trust.afauth.org proving a verified human stands behind it — carrying no PII. You do this once per human/agent pair; the binding persists as long as the agent keeps minting (it lapses only after ~90 days of inactivity), and each per-service JWT is minted fresh.
import { Agent, TrustClient } from "@afauthhq/agent";

const trust = new TrustClient({
  agentDid:        agent.did,
  agentPublicKey:  agent.publicKey,
  agentPrivateKey: agent.exportPrivateKey(),
});

// 1. Start a link request and surface the URL to a human.
const start = await trust.linkStart({ label: "Atlas (research agent)" });
console.log("Ask your human to confirm:", start.link_url);

// 2. Poll until the human signs in at trust.afauth.org and confirms.
let binding;
while (!(binding = await trust.linkPoll(start.req_id))) {
  await new Promise((r) => setTimeout(r, 2_000));
}
// `binding` is { binding_id, binding_token_expires_at } — no secret here.
// The agent's keypair is the only credential; mints are signed with it.

// 3. Mint a per-service attestation and attach it to the signed request.
const { jwt } = await trust.token("did:web:api.example.com");
const signed = await agent.buildAccountIntrospection({ baseUrl: "https://api.example.com" });
const res = await fetch(signed.url, {
  method: signed.method,
  headers: { ...signed.headers, "AFAuth-Attestation": jwt },
});
// Now the account exists: { state: "UNCLAIMED", ... }
The service verifies the JWT offline against trust.afauth.org/.well-known/jwks.json — no enrollment, no API key. If the target service advertises unclaimed_mode: "free", skip this step; the signed request alone suffices. Full walkthrough, error handling, and the afauth trust CLI shortcut: Link your agent to a human.

Invite a human to claim the account

When you want a human to own the account, the agent stages an invitation. The binding only commits after the human authenticates from the invited address — see The ceremony for why. This is a different human binding from the trust link above. Linking proves a human stands behind the agent so an attested_only service accepts its requests — ownership stays with the agent. Claiming hands ownership to a human and is always optional. The two are independent and can happen in either order.
const signed = await agent.buildOwnerInvitation({
  baseUrl: "https://api.example.com",
  recipient: { type: "email", value: "alice@example.com" },
});

await fetch(signed.url, {
  method: signed.method,
  headers: signed.headers,
  body: signed.body,
});
The service sends a magic link to alice@example.com. After Alice clicks and authenticates, the account transitions to CLAIMED and the agent continues to operate it — but ownership-changing operations now require Alice’s session.

Next steps