Skip to main content
You run a service, and you ship a CLI (or a language SDK, or a desktop app) that your users — and increasingly their coding agents — run locally. This page is how that client provisions an identity on its own, with a cryptographic keypair, instead of gating first use behind an email + OTP + browser session. It’s the client half. Your backend accepts AFAuth-signed requests already — if not, start at Accept AFAuth — and here you give the CLI you distribute an AFAuth identity so it can provision against that backend with no shared password and no portal to automate.
Which front door is this? Build an agent is for a roaming agent product. Accept AFAuth is your backend. Run a coding agent is an end user equipping Claude Code. This page is where two roles fuse: a service that distributes a client is both agent and service — your CLI consumes the agent SDK, your server consumes the server SDK.

The shape

Your CLI plays the agent role against your own backend. The user types your product’s verbs — acme deploy, acme login — and AFAuth stays invisible plumbing: the client holds a did:key and signs each request. One call provisions it, and @afauthhq/agent’s signup() adapts to whatever attestation mode your service advertises — so the same client code works whether or not you gate signup.
npm i @afauthhq/agent

Provision in one call

signup() fetches your discovery doc, links to a human only if your service is attested_only (and only the first time on a machine), then sends the signed implicit-signup request and returns the binding to persist.
1

Load or create the identity

import { loadOrCreateAgent, loadBinding, saveBinding } from "@afauthhq/agent/node";

const { agent } = await loadOrCreateAgent(); // reads the shared ~/.afauth/key.json
loadOrCreateAgent() reads the shared agent home, so if the user already ran afauth init (or any other AFAuth-aware tool), you reuse that identity — and any human link it already has. The user links at most once, ever, across every AFAuth service.
2

Sign up

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

const existing = (await loadBinding({ agentDid: agent.did })) ?? undefined;

const result = await signup({
  agent,
  baseUrl: "https://api.acme.dev",
  binding: existing,
  label: "acme-cli",
  onLink: (url) => {
    // Fires ONLY when your service is attested_only and this machine isn't
    // linked yet — the one-time human approval. Under `optional`, never called.
    console.error("Approve this agent once — you, in a browser:\n  " + url);
  },
});

// Persist a freshly-established link so the human is never prompted again.
if (result.binding && result.binding !== existing) {
  await saveBinding({ agentDid: agent.did, binding: result.binding });
}
The first signed request provisions the account on your backend — there’s no separate create call. signup() is mode-agnostic: against an optional service onLink never fires; against attested_only it fires once, then the saved binding is reused.
3

Hand ownership to a human, later (optional)

const signed = await agent.buildOwnerInvitation({
  baseUrl: "https://api.acme.dev",
  recipient: { type: "email", value: "you@example.com" },
});
await fetch(signed.url, { method: signed.method, headers: signed.headers, body: signed.body });
The account runs unclaimed until a human claims it; the agent’s signature alone can never bind ownership.
A full, runnable version is examples/cli; artidrop’s CLI is a live, deployed example.

Pick your attestation mode

This is the one decision that shapes the user experience — and it’s yours to make by economics. Both modes are first-class:
  • attested_only — a human links once at the trust attestor before the agent gets anything. Choose it when your free tier is expensive (compute, storage, inference — anything costly you give away per user): you want a real person on the hook before granting it, so a flood of throwaway keys can’t drain you. The CLI’s first signup then includes the one-time onLink approval (reused across every AFAuth service thereafter).
  • optional — the agent signs up with just its key: no attestor, no browser. Choose it when free resources are cheap and content moderation + graded rate limits are enough anti-abuse. You can grade trust — give unclaimed keys a lower quota/rate than claimed, human-owned accounts.
signup() handles both, so your CLI code is identical either way — only whether onLink fires changes. The mode is a server-side switch; see Accept AFAuth and Attestation.

The login ladder — and no merge

A distributed client usually wants three tiers, and the cleanest mental model is a ladder the user climbs only when they want more:
TierHowWhat it is
Throwawayacme publish --anonymouskeyless, unlinkable; can’t be managed
Guest / agentdefault (optional) or after the one-time link (attested_only)a real identity that owns and manages its own work
Accountacme logina human-owned account: dashboard, recovery, cross-device
Make login the single account verb, and have it fork:
  • Claim — turn the current identity into an account in place (your existing owner-invitation/claim ceremony). Keeps everything.
  • Sign in to an existing account — switch this machine to it. The identity’s prior work stays with the key — it is not merged.
Don’t build account-merge. Every system that tried regrets it — Firebase Anonymous Auth throws credential-already-in-use and refuses; consumer apps warn “guest progress may not transfer.” Your claim ceremony already enforces this: claiming into an email that already has an account returns a 409 — that is the guard. Make the user choose at the fork; don’t reconcile two pools of data.

Logout: go dormant, don’t delete

The ladder has a step down that most clients get wrong. What should acme logout do to a self-provisioned identity? The wrong answer is rm ~/.afauth/key.json. That key is shared across every AFAuth-aware tool on the machine, and it owns the guest’s work — delete it and you orphan their artifacts and sign their other tools out. Logging out is about no longer using an identity, not destroying it. Possession ≠ use. So make logout write a dormant marker, not erase anything:
  • Drop any stored bearer key and persist a signed_out flag. The client now resolves to anonymous — it signs nothing — until an explicit login.
  • Leave ~/.afauth/ untouched. acme login --afauth lifts the marker and the same did:key is active again, intact, still owning its artifacts.
Resolution becomes a short, ordered ladder:
OrderResolves toTrigger
1anonymous--anonymous (per-call escape hatch)
2bearerexplicit key arg → $ACME_API_KEY → stored key
3anonymoussticky logout marker
4afauthdefault — sign as the did:key
Bearer sits above the logout marker on purpose: an env key in CI must still authenticate even on a box where someone ran logout. For the same reason, when logout runs with $ACME_API_KEY set, say so — you can’t unset their environment, and that key keeps forcing bearer auth until they do.

Don’t rebrand the plumbing

The user knows your product, not AFAuth:
  • The account verb is acme login (or fold it into your flow) — never afauth init. Sending a user to a second, unfamiliar tool is friction at the worst moment.
  • Don’t mirror AFAuth’s subcommands (acme trust link, …). Fold the human step into login/signup; the user’s vocabulary stays at your verbs.
  • Name “AFAuth” at most once, as reassurance. The one unavoidable AFAuth-branded moment is the trust attestor’s approval page — and only for attested_only services. Pass a clear label so it shows what’s being linked.
Power users who know AFAuth are served for free: because you reuse the shared ~/.afauth/, afauth trust status / forget operate on your CLI’s identity with no extra surface from you.

Notes & gotchas

  • Pin @afauthhq/agent@^0.6.1. (0.6.0 fails to load on a clean install — it re-exported a helper from a @afauthhq/core that hadn’t shipped it.)
  • Signing multipart uploads: fetch serializes a FormData with a boundary you can’t see up front, so a pre-computed Content-Digest won’t match. Serialize it yourself first, then sign over the bytes:
    const probe = new Request(url, { method, body: formData });
    const bytes = new Uint8Array(await probe.arrayBuffer());
    const headers = await agent.signRequest({ method, url, body: bytes });
    await fetch(url, { method, headers, body: bytes }); // + probe.headers.get("content-type")
    
    Signing JSON bodies needs none of this — pass the JSON string straight to signRequest.
  • Auth resolution in your request layer: an explicit key → bearer; else a stored/env key → bearer; else sign with the did:key; --anonymous → no auth.
  • Tests: point $AFAUTH_HOME at a temp dir so signing never touches a developer’s real ~/.afauth.

Next steps

Accept AFAuth on your service

The backend half — verify signed requests, host the claim page, pick your attestation mode.

@afauthhq/agent

signup(), the /node persistence helpers, and the signing surface.

Attestation

attested_only vs optional + graded trust — and when each fits.

Invite and claim

The deferred human-ownership ceremony your login claim path drives.