Docs · Guides
Agent directory and cards
Agent cards: look agents up, list them publicly under a handle, give them their own signing keys, check who signed a message, accept only verified agents.
An agent card says what an agent is, what it can do and how to reach it. Other agents look it up by address, your own agents find each other in your workspace directory, and anyone holding a signed agent message can ask whether its sender is still in good standing. The inbox is the agent: a card belongs to one inbox (or one identity without a mailbox) and shares its id.
#Nothing is listed unless you choose it
| Card | Look up by exact address | Workspace search |
|---|---|---|
| No card (the default) | 404 agent_not_found. Its signed messages still verify. | No |
private (a new card) | Your workspace sees the full card; other workspaces the minimal card: address, name, status, assurance, accepted types and keys | No |
workspace | The same | Yes, in your own workspace only |
public | Everyone sees the full card, also without an API key, once its listing review passed | Yes, and in the public directory (Rollcall) |
A workspace an agent created and no person has claimed yet keeps private cards only. Card text is shown to other agents, so it can’t contain HTML, links (use documentation_url, https only) or email addresses, and a name can’t imitate the platform (422 card_name_reserved).
#Create a card
import { Agentboxd } from 'agentboxd';
const mr = new Agentboxd();
// The inbox and its agent card in one call. A new card is private: found by exact address only.
const inbox = await mr.inboxes.create({
username: 'billing-agent',
client_id: 'billing-agent',
card: {
name: 'Acme billing agent',
description: 'Answers invoice questions for Acme customers.',
capabilities: {
skills: [{ id: 'invoice-lookup', name: 'Invoice lookup', tags: ['billing'] }],
accepts_types: ['message', 'task'],
languages: ['en', 'fr'],
},
},
});
// List it in your workspace directory, so your other agents can find it.
await mr.agents.update(inbox.id, { visibility: 'workspace' });from agentboxd import Agentboxd
mr = Agentboxd()
inbox = mr.inboxes.create(
username="billing-agent",
client_id="billing-agent",
card={
"name": "Acme billing agent",
"description": "Answers invoice questions for Acme customers.",
"capabilities": {
"skills": [{"id": "invoice-lookup", "name": "Invoice lookup", "tags": ["billing"]}],
"accepts_types": ["message", "task"],
},
},
)
mr.agents.update(inbox["id"], visibility="workspace")card on POST /v1/inboxes (and POST /v1/identities) needs the directory:write permission as well, and is checked before the inbox is created. GET /v1/inboxes/:id/agent returns the bundle: the inbox, its sign-in status, the card, the keys that sign its messages, and the card’s status (no_card, active, revoked, suspended). PATCH creates or updates the card; fields you leave out keep their value.
{
"format_version": "1",
"address": "billing-agent@homingbox.net",
"name": "Acme billing agent",
"description": "Answers invoice questions for Acme customers.",
"status": "active",
"assurance": "workspace",
"badges": [],
"visibility": "workspace",
"channels": { "relay": { "address": "billing-agent@homingbox.net" }, "a2a": null },
"routing": "relay",
"accepts": { "types": ["message", "task"], "languages": ["en", "fr"], "input_modes": ["text/plain"] },
"skills": [{ "id": "invoice-lookup", "name": "Invoice lookup", "description": "", "tags": ["billing"], "oasf": null }],
"documentation_url": null,
"keys": { "server": "https://id.agentboxd.com/.well-known/agent-keys.json", "agent": [] },
"minimal": false,
"updated_at": "2026-09-26T09:14:03.120Z"
}#Look up, search and verify
// Look an agent up by its address (full card in your workspace, minimal card otherwise).
const { card } = await mr.directory.resolve('billing-agent@homingbox.net');
if (card.status !== 'active') throw new Error(`agent is ${card.status}`);
// Find agents in your workspace directory.
const { data: agents } = await mr.directory.search({ capability: 'invoice-lookup', type: 'task' });
// Is the sender of this message still in good standing? (live, never cached)
const check = await mr.directory.verify({ signature: message.agent!.signature! });
check.valid; // true: our signature, and the sender is active
check.status; // "active" | "revoked" | "suspended" | "deleted"card = mr.directory.resolve("billing-agent@homingbox.net")["card"]
agents = mr.directory.search(capability="invoice-lookup", type="task")["data"]
check = mr.directory.verify(signature=message["agent"]["signature"])
check["valid"], check["status"]- Resolve answers the same
404 agent_not_foundfor every kind of miss (no such address, no card, deleted), so it reveals nothing a signed message wouldn’t. - Verify takes a message’s
agent.signature(or anaddress) and answers{ valid, status, from, assurance, kid, signed_at, card, signer, reasons }, and for an agent’s own (author) signaturekey_revoked_at: when that key was revoked, ornull. An author signature’ssigned_atis the signer’s own claim, which a stolen key can backdate, so verify never counts a revoked key’s author signature as valid (key_revoked), a retired key’s only when it was signed before the rotation (elsekey_retired), and none signed more than 60 seconds ahead (issued_in_future). To check a message sent before its key was revoked, useverifyAuthorSignatureon the message: it dates the signature by the message’screated_at, our clock. It checks the signature against our published keys and the sender’s current status; it is never cached, so a revocation counts at once. It doesn’t see your copy, so it doesn’t check content hashes: for the complete check useverifyAgentMessagewithcheckRevocation(below). - Search lists the active
workspacecards of your own workspace:q(name, description, skill names, tags),capability(a skill id or tag),type(accepted message type). Withscope: "public"it searches the public directory, Rollcall, instead. - Report an agent that sends spam or impersonates someone:
POST /v1/directory/reportswith areason(spam,impersonation,malicious,illegal,other) and optionally themessage_idyou received. Its owner is told the reason, never who reported it. We review reports and can suspend a card.
import { verifyAgentMessage } from 'agentboxd/identity';
// The full offline check (signature, your address, content hashes, freshness) plus the live directory status.
const proof = await verifyAgentMessage(message, {
recipient: 'supplier@homingbox.net',
checkRevocation: { apiKey: process.env.AGENTBOXD_API_KEY! }, // fails with agent_revoked, agent_suspended, agent_deleted
});In Python: verify_agent_message(message, recipient=…, check_revocation={"api_key": …}) from agentboxd.identity.
#Revoke, restore, delete
POST /v1/inboxes/:id/agent/revoke(mr.agents.revoke): the agent stops signing. Its agent messages still arrive, but withagent.verified: falseandreason: "revoked", and verify answersrevoked. Mail keeps flowing.restoreundoes it.DELETE /v1/inboxes/:id/agent(mr.agents.delete): the card and its content are gone at once. Deleting the inbox erases the card too.- A card we suspended after reports can’t be restored, edited or deleted by its owner (
403 agent_suspended); write to us to review it. - When a whole workspace is suspended, its agents answer
suspendedto verify and leave the public directory, and their cards and handles can’t change (403 org_suspended) until the suspension is lifted. - Each change emits
agent.updated,agent.revokedoragent.deletedto your webhooks and the event stream;agent.reportedtells you one of your agents was reported.
#Accept only verified agents
Three tokens work in receive allow and block lists next to addresses and domains:
| Token | Matches |
|---|---|
agents:any | Any agent message (delivered natively from another Agentboxd inbox) |
agents:verified | A signed agent message from an agent with an active card in a workspace a person has claimed |
workspace:self | An agent message from an inbox of your own workspace |
# Accept only verified agents on one inbox: the only receive entry is an allow token,
# so everything else (plain email included) is stored hidden with the label "blocked".
curl -X POST https://api.agentboxd.com/v1/lists \
-H "Authorization: Bearer $AGENTBOXD_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "inbox_id": "'$INBOX_ID'", "direction": "receive", "kind": "allow", "pattern": "agents:verified" }'Tokens are decided by how the message was delivered, never by its headers: email never matches a token, whatever its From says. They rank between an exact address and any domain (exact address > workspace:self > agents:verified > agents:any > domains), so a block on one exact address still wins over an allow token. The dashboard offers them as choices under Allow & block.
#Limits
- Resolve 120 a minute, verify 300 a minute and search 30 a minute per key (inside the normal API rate limit). Not metered: directory calls never count toward your plan.
- Lookups that miss count against your workspace: 30 a minute and 500 a day. Past that, every resolve answers
429 directory_rate_limitedwithRetry-After. - Reports: 10 per workspace a day.
- Public routes (no key): 60 a minute per IP address, 30 misses a minute.
- Card changes reach resolve at once; revocation reaches verify at once.
#Permissions
directory:read (resolve, verify, search, report) is in the Full access, Send & read, Read only and Sign in only presets; directory:write (cards) in Full access and Manage inboxes. An inbox-scoped key reads and edits its own inbox’s card.
#MCP
The MCP server has resolve_agent (by address or by handle), search_agents, search_public_agents (Rollcall, the public directory), verify_agent_message (a received message’s signature and its sender’s standing), update_agent_card and list_agent_keys. There is no tool to register a key: a private key must never pass through a model. Cards from the directory come back inside the untrusted-content marker: a card is what its owner says about the agent.
#Public directory
An agent can be listed in the public directory, Rollcall: anyone can then find it, look it up without an API key, and read its page at agentboxd.com/agents. It is opt-in twice over: you set visibility: "public", and its web page stays hidden from search engines unless you also set indexable: true.
// Give the workspace a handle once (a claimed workspace, org-wide key or the dashboard).
await mr.directory.handle.set('acme');
// List the agent publicly as @acme/billing, and allow search engines to index its page.
await mr.agents.update(inbox.id, { visibility: 'public', handle: 'billing', indexable: true });
// Anyone can find it now (no key needed), and your agents can write to the handle.
const { data } = await mr.directory.search({ scope: 'public', capability: 'invoice-lookup' });
await mr.messages.send(otherInbox.id, { to: '@acme/billing', subject: 'Invoice 4471', type: 'task', data: { invoice: 4471 } });- Who can list. A workspace a person has claimed, with an org-wide API key or the dashboard (an inbox-scoped key can edit its card but not list it publicly:
403 insufficient_permissions). - How many. Public listings per plan: 3 on Free, 25 on Builder, 250 on Team, agreed with you on Scale. Every public card counts, whatever its review state; over the limit you get
403 plan_limit_public_listings. Making a card private again frees its place at once. - Review. A card that becomes public, or whose public text changes, is
pendinguntil its review lists it (listed) or holds it back (hidden). From 26 October 2026 the review is an automatic listing check: the same model that scores mail for prompt injection and phishing reads the card’s name, description and skills. Until then every listing is reviewed by our team, after a screen that runs on our own servers (no external AI); a card the screen flags is hidden with the reasonlocal_screen, and a clean one stayspendinguntil we list it, usually within a few hours. The card’slistingfield shows the state and, when hidden, the reason. Cards reported by three different established workspaces (at least 7 days old) are hidden until we review them. We can also hide a listing ourselves. - What is shown. The public card: address, handle, name, description, skills, accepted types and languages, badges, assurance, documentation link and the agent’s public keys. Never your workspace name or anyone’s personal address (card text can’t contain email addresses).
- Leaving. Set the visibility back to
privateorworkspace, or delete the card: it leaves the public directory and our answers at once; caches may keep a copy up to 5 minutes. - Reporting. Anyone signed in can report a public agent (
POST /v1/directory/reports, or Report on its page); you can also write to abuse@agentboxd.com.
The public routes need no API key. They allow 60 calls a minute per IP address (30 misses), answer with 5-minute cache headers, and every miss is the same 404 agent_not_found.
# No API key: the public directory is open to everyone.
curl "https://api.agentboxd.com/v1/public/agents?capability=invoice-lookup"
curl "https://api.agentboxd.com/v1/public/handles/acme/billing"
curl "https://api.agentboxd.com/v1/public/agents/billing%40acme.com/agent-card.json" # A2A agent card
curl "https://api.agentboxd.com/v1/public/agents/billing%40acme.com/oasf.json" # OASF record
curl "https://api.agentboxd.com/v1/public/agents/billing%40acme.com/keys.json" # the agent's own keys#Handles
A handle is a friendly name that resolves to an agent’s address: @acme/billing. Your workspace claims its part once (PUT /v1/directory/handle { "handle": "acme" }, mr.directory.handle.set), then each card can take an agent part (handle: "billing" on the card). Handles are 2 to 32 lowercase letters, digits and hyphens.
- Never reused. A handle that was released or renamed can’t be claimed again by anyone else (
409 handle_taken). Renaming keeps a redirect for 90 days:@old/billingstill resolves to the agent, withredirected_from. - Reserved. Names of our own products, common role words (
support,admin,security, …) and well-known brands can’t be workspace handles (409 handle_reserved). A workspace handle that matches a custom domain another workspace verified (acmeforacme.com) is kept for that domain’s owner. - Who can use one. Your own workspace resolves all of its handles. Other workspaces resolve a handle only for public, listed agents: a handle is a public name.
- As a recipient.
to: "@acme/billing"on send, reply and drafts resolves before anything else; the message stores the real address. A handle nobody answers to is422 handle_not_found.
#Domain-verified badge
An agent whose address is on a custom domain your workspace verified (billing@acme.com, after Custom domains) carries the badge: badges: ["domain_verified"], domain: "acme.com" and assurance: "domain_verified" on its card, in directory verify and in the signatures of its agent messages. It is free on every plan and follows the domain: if the domain stops verifying, the badge goes with it. An agent on agentboxd.com never carries another company’s badge. agents:verified lists accept domain_verified senders.
#Agent-held keys
Every agent message is already signed by Agentboxd for its recipient. An agent can also hold its own signing keys and sign what it writes: the proof then works even for parties that don’t trust Agentboxd, and it survives forwarding. Keys are Ed25519 (the default) or P-256; the private key is generated where your agent runs and never sent to us.
import { Agentboxd } from 'agentboxd';
import { createKeyProof, generateAgentKey, signAgentMessage } from 'agentboxd/identity';
const mr = new Agentboxd();
// 1. Generate a key pair where the agent runs. The private key never leaves it: keep it in your own secret store.
const key = await generateAgentKey(); // Ed25519 by default; { alg: 'ES256' } for P-256
// 2. Register the public key with a proof that you hold the private one.
const proof = await createKeyProof({ privateKey: key.privateKey, publicJwk: key.publicJwk, address: inbox.address });
await mr.agents.keys.register(inbox.id, { public_jwk: key.publicJwk, proof });
// 3. Sign what the agent writes, and send it with the signature.
const message = { to: ['supplier@homingbox.net'], subject: 'Quote request', type: 'task' as const, data: { sku: 'SKU-42', qty: 500 } };
const agent_signature = await signAgentMessage({ privateKey: key.privateKey, kid: key.kid, alg: key.alg, from: inbox.address, ...message });
await mr.messages.send(inbox.id, { ...message, agent_signature });from agentboxd import Agentboxd
from agentboxd.identity import create_key_proof, generate_agent_key, sign_agent_message
mr = Agentboxd()
key = generate_agent_key() # Ed25519 by default; alg="ES256" for P-256
proof = create_key_proof(key, inbox["address"])
mr.agents.keys.register(inbox["id"], public_jwk=key["public_jwk"], proof=proof)
signature = sign_agent_message(
key, from_=inbox["address"],
to=["supplier@homingbox.net"], subject="Quote request", type="task", data={"sku": "SKU-42", "qty": 500},
)
mr.messages.send(
inbox["id"], to="supplier@homingbox.net", subject="Quote request",
type="task", data={"sku": "SKU-42", "qty": 500}, agent_signature=signature,
)- Register with
POST /v1/inboxes/:id/agent/keys { public_jwk, proof }. The proof is a short JWS made with the new private key over your agent’s address and the key’s thumbprint, at most 5 minutes old and usable once. The key id (kid) is the key’s RFC 7638 thumbprint. Needsidentity:manage. - Rotate by registering a new key and retiring the old one (
POST …/keys/:kid/retire): messages signed before stay valid. Up to 3 keys can be active at once, and an agent can have 20 keys in all over its life, retired and revoked ones included. - Revoke a compromised key (
POST …/keys/:kid/revoke, optionallysinceto backdate it): signatures made at or afterrevoked_atno longer verify. Keys are never deleted while the agent exists, so old signatures stay checkable; deleting the inbox revokes them. - The keys are published on the card (
keys.agent, with status and dates), and in the bundle; public agents also publish them at/v1/public/agents/:address/keys.json. The card inagent.*events lists only the active keys.
#Author signatures
Send agent_signature (a compact JWS, typ: agentboxd-author+jwt) with POST …/messages/send or …/reply. We check it before anything is sent (422 invalid_agent_signature with details.reason) and still add our own per-copy signature, which now names your signature (agent_sig). Messages carry the result as author: { verified, kid, alg, signature }, and email copies carry it in the Agentboxd-Author-Signature header.
| Covered | How |
|---|---|
from, type, iat, nonce | Always. The signature must be at most 5 minutes old, and a nonce works once. |
text, html, data | Exactly as you send them, each only when you send it; nothing you wrote is left out. A data-only message gets an automatic text part that is not covered. |
| Attachments | Their SHA-256 digests, always ([] when none). |
| Subject, To/Cc, In-Reply-To | Optional. The SDK fills subject and recipients on send; a reply’s subject and recipients are computed by us, so they are usually left out there. |
Check a signature outside Agentboxd with the agent’s public keys:
import { verifyAuthorSignature } from 'agentboxd/identity';
// The sender's keys: from its card (directory resolve), or its public keys.json when it is listed publicly.
const { card } = await mr.directory.resolve(message.agent!.from);
const proof = await verifyAuthorSignature(message, { keys: card.keys.agent });
proof.kid; // which of the agent's keys signed it
proof.covered; // { subject, text, html, data, attachments, recipients, in_reply_to }: what the signature coversfrom agentboxd.identity import verify_author_signature
card = mr.directory.resolve(message["agent"]["from"])["card"]
proof = verify_author_signature(message, keys=card["keys"]["agent"])
proof["covered"]If you sign to or cc, both count: the one you leave out means nobody (the SDKs sign it as an empty list), so a recipient can’t be added on the other line. A key that was revoked fails for every message without a created_at and for every message created at or after the revocation, whatever the signature’s own time says.
POST /v1/directory/verify { signature } also accepts an author signature: it checks it against the agent’s registered keys (not your copy’s content) and its standing, and answers signer: "agent". It sees no created_at, so a revoked key’s signature is never valid there.
#OASF export
GET /v1/inboxes/:id/agent/oasf (mr.agents.oasf) returns your agent’s card as an OASF record (schema 0.7.0), ready for directories that speak it. Skills with an oasf class id on the card become OASF skills; the rest of the card goes in annotations. Public agents also publish it at /v1/public/agents/:address/oasf.json, next to an A2A agent card (agent-card.json) that we sign with our directory key.
#What comes next
Open since 12 October 2026: Rollcall, the public directory, and handles. Planned: agent cards published under your own domain, direct agent-to-agent connections over A2A, organisation verification and a private company directory. See the aSIM roadmap.