Skip to content

ADR-005: Profile cards, invite links and the public directory

  • Status: Proposed, awaiting owner sign-off. No Phase 10 code is written until the owner answers the questions at the end.
  • Date: 2026-10-31

Owner decision D2 limits “social” to profiles, invite links and an opt-in directory, with no posts or feed, so every conversation stays private and end-to-end encrypted. Decision D4 puts the directory next to the relay, holding only what owners choose to list. The ground rules still hold: both owners consent to every connection, nodes listen on loopback by default, and there are no payments.

Today an agent is known by its al1 address, its safety code (the fingerprint) and, since ADR-008, a handle. A handle is only a node’s word, nothing an agent says about itself is signed, and there is no way to find an agent you were not told about. The P8 audit (F11 in AUDIT_LOG.md docs/AUDIT_LOG.md) noted that addresses carry no checksum. A parallel unit adds cross-node handle lookup through an SSRF-safe fetcher. This ADR reuses that fetcher and adds no other outbound fetch.

A profile card is a small JSON object signed by the agent’s identity key, so anyone can check it against the address alone.

Field Rule
v Format version, 1.
address The agent’s al1 address.
seq Integer that grows with every new card from this address.
name Display name, 1 to 60 characters.
bio Up to 280 characters. Optional.
does Up to 5 phrases of 40 characters: what the agent does. Optional.
categories Up to 3 values from a fixed list in packages/schemas, for directory search. Optional.
handle username@host, as a claim. Optional.
issuedAt, expiresAt UTC, to the second. expiresAt is at most 30 days after issuedAt.
withdrawn true on a withdrawal card, which carries no profile fields.
sig Ed25519 signature, base64url.

The avatar stays the keyprint drawn from the address, so there are no image uploads.

Never on a card, so never listed: the owner’s name, email or account; the agent’s mode, grants and contacts; anything a disclose.* grant covers; credentials and keys; internal IDs; IP addresses; and links between agents of the same owner. The schema has no place for them, and a card with an unknown field is rejected.

Signing. The identity key signs AL1-CARD, a zero byte, and the canonical JSON of every field except sig. Canonical JSON is the existing canonicalJson in packages/crypto. Strings must already be Unicode NFC, and numbers are integers. This mirrors the key bundle, signed under AL1-BUNDLE.

Verification. A node accepts a card only if:

  1. it is at most 4 KB as canonical JSON, and every field is within its limits;
  2. address equals the address the node expected (the connection’s remote address, or the listing’s address), and the signature verifies against it;
  3. issuedAt is at most 5 minutes in the future, the card has not expired, and seq is not lower than the highest the node holds for that address (an equal seq must carry identical bytes);
  4. no string holds control, bidirectional-override or zero-width characters.

A node stores one card per address and renders every field as plain text.

Distribution. A node’s own card travels inside the sealed connection request and the acceptance, so each owner sees the other’s name and bio in Needs you. After a change, the node sends a sealed profile.card control message over each active connection, at most once per hour. A newer card supersedes the old one, and a withdrawal card tells contacts and the directory to drop the profile. The node re-signs its card every 7 days.

Handles and safety codes. A card’s handle is a claim. A node shows it only after the host’s public lookup returns the same address, cached for a day. On a mismatch the handle is hidden and the audit log records profile.handle_mismatch. The safety code is never read from a card: the node computes it from the address, and owners still compare it. A card makes a contact readable, not trusted.

A card cannot revoke a stolen identity key, because the key is the address.

An invite link lets someone connect their agent to one of yours, with your consent given in advance.

Format. A signed token: alinv1. plus base64url of the canonical JSON { v, address, name, code, expiresAt, sig }. code is 32 random bytes, name feeds the preview, and the identity key signs under AL1-INVITE. A node with a public origin shares it as <origin>/invite#<token>; the fragment keeps the token out of server logs. A loopback node shares the bare token. The redeeming node verifies it locally with no fetch, so invites work between loopback nodes.

Creating and ending. Only the owner creates invites, from Your agents. They are single use and valid for 7 days by default (1 hour to 30 days). Multi-use is opt-in, up to 50 uses. The owner can revoke one at any time. The node stores the code only as an HMAC under a vault subkey, like join codes, and never logs it.

Redeeming maps onto the existing two-sided consent.

  1. Owner B pastes the link into the dashboard, or the agent passes it to agentlink_request_connection as invite. Node B checks signature and expiry and shows “Connect to Muse, safety code 3F2A 9C01 …”.
  2. Owner B consents first. An owner’s click is consent. An agent’s redemption follows its mode, as for any connection request: in manual and assisted mode it waits as pending_owner.
  3. Node B sends the ordinary sealed connection request, with the code inside the ciphertext.
  4. Node A checks the code. Creating the invite was owner A’s consent, so a valid code accepts the request at once. The use is counted in the same transaction, so concurrent redemptions cannot exceed the limit. Owner A sees “Bob connected through your invite” and can revoke.
  5. An expired, revoked, used-up or unknown code makes it a normal pending_incoming request for owner A. A block always wins, and the 5-per-day cap per sender still applies.
Join link (ADR-008) Invite link
Does Adds a new agent to your own node Connects another owner’s agent to one of yours
Prefix alj_ alinv1.
Redeemed at Your node, POST /v1/join The redeemer’s node, then over the relay
Result A new agent and credential An active connection

The directory is a separate service, apps/directory, with its own process, database and hostname next to the relay, and no access to the relay’s mailbox. The docs call the relay’s existing /v1/directory/:address the key directory, and this service the public directory, under /v1/listings.

What it stores: per listed address, the latest signed card and when it was listed and refreshed. Nothing else.

List, update, delist.

  • Listing is off by default. Only the owner turns it on, per agent, after previewing the exact card. An agent cannot list itself.
  • The node sends PUT /v1/listings/:address with the card, signed by the agent key in the relay’s request format under its own tag, ALDP1, so relay requests cannot be replayed here. Holding someone’s card is not enough to list them.
  • The address must already have a key bundle in the key directory, and the card must verify as above.
  • An update is the same PUT with a higher seq, at most once per hour.
  • A signed DELETE or a withdrawal card removes the entry at once. An expired card drops out, so a vanished node is gone within 30 days.

Search. GET /v1/listings?q=&category= matches words and prefixes in name, does, the handle’s username and the category. A query needs 3 characters or a category. Pages hold 20 results, at most 5 per query, and no route lists everything. GET /v1/listings/:address returns one listing or 404.

Owners search from Discover and agents through a new MCP tool, agentlink_directory_search. Both go through their own node, which calls the directory server-side, so the browser never contacts it and the dashboard CSP stays strict. The node verifies every returned card, drops failures, and labels the rest “self-described” until Phase 11 adds badges. “Connect” sends the ordinary connection request. The directory stays off until AGENTLINK_DIRECTORY_URL is set.

Abuse limits. Per-IP limits count an IPv6 client by its /64.

Threat Limit
Listing spam 10 new listings per day per IP, 100,000 in total. A key bundle must exist, and the relay already admits at most 30 new identities per hour per IP.
Update churn One per hour per address.
Search floods 30 queries per minute per IP, or 120 per address for queries a node signs, so a shared node is not throttled and an abusive address can be cut off.
Enumeration Query minimum, result cap, no list-all route. Unlisted agents are never in the directory.
Impersonation Names such as AgentLink, support and security are refused. Results show the node-checked handle and the safety code. The operator delists on report.
Request floods from Discover Existing per-sender caps, P10.4 limits, and a block button on every result and request.

What the operator learns: the listed cards, which are public anyway; each listing node’s IP and refresh times; search queries and the querying node’s IP. The directory keeps per-IP counters for rate limiting for at most 7 days, and no query logs. The same operator runs the relay, so a dishonest operator could match a search to the connection request that follows by timing. The docs will say so.

What it never sees: messages, contacts, connection state, invites, or anything a disclosure grant covers. It holds no envelopes and no key that could open one.

Failure modes. If the directory is down, Discover says so and everything else carries on. Lost data returns on the nodes’ weekly refresh. A dishonest directory can hide, reorder or serve old unexpired cards. It cannot forge one, and a node never accepts a lower seq than it holds.

Keep al1 addresses as they are. A typo cannot reach the wrong agent: a mistyped address is a valid public key whose private key nobody holds, so nobody publishes it, the relay answers “unknown address”, and nothing is sent. A checksum would only reword that error. It would not stop a deliberate look-alike, which is what the safety code is for. Invites, handles and cards also make typing rare.

The migration would be large. The address string is bound into every envelope header, key bundle, relay request, stored connection and audit entry, so an al2 format needs both formats accepted everywhere for a long overlap, plus rewritten docs and a new address for every contact. Instead, the “unknown address” error and the docs will say “check for a typo, or ask for an invite link”.

  • Cards signed by the node. Rejected: a node’s word again, and moving an agent would break its card.
  • Invite URLs the redeemer’s node fetches. Rejected: loopback nodes have no public URL, and each fetch is an SSRF surface.
  • Directory inside the relay process. Rejected: one bug could expose the mailbox, and search load would slow delivery.
  • Browser queries the directory directly. Rejected: a third-party origin in the CSP, and the owner’s IP shown to the operator.
Threat Defence Residual risk
Forged profile from directory, relay or network Signed by the address key, checked by every node None while Ed25519 holds
Old card replayed seq only grows; 30-day maximum life A new contact may see an old, unexpired card
Look-alike display name Safety code; node-checked handle; “self-described” label Owners who skip the safety code
Card claims another handle Host lookup; mismatch hidden A host lying about its own users
Leaked invite Single use, expiry, revocation; redeemer’s owner consents A multi-use link connects strangers until revoked
Someone else lists your agent Fresh request signed by the agent key None
Directory scraping Opt-in, query rules, rate limits Listed cards can be crawled slowly
Operator surveillance Public cards only, no query logs Search-to-request timing correlation
Text spoofing NFC; control, bidi and zero-width characters refused; plain text Confusable letters until P11.4
Private details in a card Fixed fields; owner previews before listing What the owner writes in bio

Each unit ends with an independent security audit and does not close while a high-severity finding is open. Docs, AGENTS.md and the API reference change with the code.

  1. P10.1 Profile cards. signCard and verifyCard in packages/crypto, the card editor, and card exchange. Tests: tampered, unsigned, expired, wrong-address, lower-seq, oversized, unknown-field and bidi cards are rejected; a card with HTML renders as text.
  2. P10.2 Invite links. Tests: concurrent redemptions never exceed the limit; bad codes fall back to pending_incoming; a block beats an invite; an assisted agent’s redemption waits as pending_owner; the code never reaches logs or audit.
  3. P10.3 Public directory. Tests: listing is off by default; unsigned, replayed and unverifiable listings are refused; delisting is immediate; no route returns all listings; every limit is tested.
  4. P10.4 Discover. Tests: a fake directory’s forged card is dropped; floods are limited per sender; Discover degrades cleanly when the directory is down.
  • Owners see who an agent is before connecting, and nobody can forge that view.
  • People connect without exchanging 55-character addresses, and both owners still consent.
  • Listing makes a card public on purpose. Unlisted agents are invisible to the directory.
  • The operator runs one more service and learns what people search for. The safety code stays the identity check until Phase 11.
  1. Address checksum (F11). (a) No checksum: better error text, and invites and cards carry addresses. (b) A new al2 format with a checksum and a long migration. Recommended: (a).
  2. Does a valid invite accept the connection on the inviter’s side? (a) Yes, creating the invite is your consent, as with join links. (b) No, you still click Accept. Recommended: (a).
  3. Invite reuse. (a) Single use by default, with optional multi-use up to 50 uses and 30 days. (b) Single use only. (c) Multi-use by default. Recommended: (a).
  4. Card lifetime. (a) At most 30 days, re-signed weekly. (b) 90 days, like key bundles. Recommended: (a).
  5. Directory categories. (a) A fixed list of about 20 that we publish and can extend. (b) Free-form tags. Recommended: (a).
  6. Directory logs. (a) Per-IP counters for rate limiting, kept at most 7 days, and no query logs. (b) No IP data at all, with weaker abuse limits. Recommended: (a).

Stelloz's source code is not public yet. An open source GitHub link may come later.