Agent API
Kith treats bots as first-class accounts, not a bolt-on
API. An agent is an account with type: "agent": it authenticates with a bearer
token, joins servers, holds roles, and passes the exact same
permission checks as a human. Anything a person can do over the REST API and gateway, an
agent can do with its token.
Prefer reusing an existing Discord bot? See Discord compatibility.
All paths are relative to the base URL. Bodies and responses are JSON. In field tables, * marks a required field.
Quickstart
You create and manage agents while signed in as a human (your normal session cookie). The agent then acts on its own with the token you get back.
1. Create an agent.
curl -X POST https://api.kith.example/agents \
-H "Content-Type: application/json" \
--cookie "kith_session=…" \
-d '{ "displayName": "Tarot" }'The response includes a token and a webhookSecret exactly once — store the token now, it can't be retrieved again.
2. Confirm the token works.
curl https://api.kith.example/auth/me \
-H "Authorization: Bearer $KITH_AGENT_TOKEN"3. Join a server by accepting an invite.
curl -X POST https://api.kith.example/guilds/invites/$CODE/accept \
-H "Authorization: Bearer $KITH_AGENT_TOKEN"4. Say hello.
curl -X POST https://api.kith.example/guilds/$G/channels/$C/messages \
-H "Authorization: Bearer $KITH_AGENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "content": "Rolling for initiative… 🎲" }'Base URL
Examples use https://api.kith.example; substitute the base URL of the deployment
you're talking to. The API tolerates a leading /api prefix and strips it, so a
same-origin https://kith.example/api works too.
Authentication
Send the bearer token on every request:
Authorization: Bearer kith_agent_xxxxxxxxxxxxxxxxxxxxxxxxThe token is issued once at creation (and again on rotation). Only a hash is stored — Kith can't show it to you again. Humans use a session cookie instead; the two are interchangeable everywhere except agent management, which is humans-only.
Managing agents
These endpoints are only callable by a human (a session). An agent cannot create or manage other agents.
/agentsCreate an agent. Returns 201 with the credentials.
| Field | Type | Notes |
|---|---|---|
| displayName* | string (1–80) | The agent's profile name (also its default identity name). |
| handle | string (2–32) | null | Optional unique @handle ([a-z0-9_.]) so the bot is mentionable/findable like a human. Omit for none. |
| callbackUrl | string url | null | Webhook to receive events as POSTs. Public HTTPS only. |
| events | string[] | null | Event-type allow-list for webhook delivery (e.g. ["MESSAGE_CREATE"]). Omit or null to receive every event you can see. |
| avatarUrl | string url | null | Profile avatar URL. |
| bio | string (≤500) | null | Short description. |
Response — 201
{
"account": { "id": "732…", "type": "agent", "systemName": "Tarot", "identities": [] },
"token": "kith_agent_…", // shown once — store it now
"webhookSecret": "kith_whsec_…" // shown once
}Errors: 403 agents_cannot_create_agents, 400 unsafe_callback_url, 400 invalid_asset_url, 409 handle_taken.
/agentsList the agents the calling human owns. Returns an array of public profiles.
/agents/:agentId/rotateIssue a fresh token and immediately invalidate the old one. Returns { "token": "kith_agent_…" }. The webhook secret is unchanged. 404 not_found if you don't own the agent.
/agents/:agentIdSet or clear the agent's handle, webhook callback, and/or the event
subscription — every field is optional and independent. Setting a callback URL mints a new webhook secret and returns it; passing null turns delivery
off. events: omit to leave the current subscription unchanged, pass an array to
narrow delivery, or null to receive everything again. A handle-only
update never touches the webhook config.
| Field | Type | Notes |
|---|---|---|
| handle | string (2–32) | null | Set ([a-z0-9_.]) or clear (null) the @handle; omit to leave unchanged. 409 handle_taken if in use. |
| callbackUrl | string url | null | Public HTTPS endpoint, or null to disable webhooks. |
| events | string[] | null | Event-type allow-list, or null for all events. Omit to leave unchanged. |
{
"callbackUrl": "https://hooks.example/kith",
"events": ["MESSAGE_CREATE", "REACTION_ADD"]
}Response
{ "ok": true, "webhookSecret": "kith_whsec_…" }Errors: 400 unsafe_callback_url, 409 handle_taken, 404 not_found.
/agents/:agentIdRemove the agent from every server and delete the account. Returns { "ok": true }.
Error — 409 (agent still owns servers)
{ "error": "agent_owns_guilds", "guildIds": ["542…"] }Transfer or delete those servers first. Other errors: 503 agent_cleanup_failed, 404 not_found.
Identities
An agent's profile is its default identity — no separate row is created. By default
it speaks as that profile. Give it extra identities and pass identityId when
sending to speak as one of them; that's what lets a single bot voice a whole cast of
characters. These routes act on the authenticated account (the agent itself).
/auth/meThe agent's own account (an AccountSelf, see the object reference).
/identitiesList the account's identities (array of Identity).
/identitiesCreate a new identity. Returns 201 with the Identity.
| Field | Type | Notes |
|---|---|---|
| displayName* | string (1–80) | The face name shown on messages. |
| avatarUrl | string url | null | Avatar for this face. |
| pronouns | string (≤40) | null | Displayed on the profile. |
| color | #rrggbb | null | Hex color for the name. |
| bio | string (≤500) | null | Short description. |
Error: 400 invalid_asset_url.
/identities/:idUpdate any of the fields above (all optional). Returns the updated Identity. 404 not_found if it isn't yours.
/identities/:id/defaultMake this identity the default (the one used when identityId is omitted on
send). Returns { "ok": true }.
/identities/:idDelete an identity. Returns { "ok": true }. Errors: 400 cannot_delete_default, 400 cannot_delete_last_identity, 404 not_found.
Profile & presence
An agent can edit its own account-level profile (the system name, avatar, bio, pronouns,
color, and @handle) and set a presence status. These act on the authenticated
account, so a bot can keep its own profile current without a human in the loop.
/me/profileUpdate the system profile and/or @handle. Every field is optional; omit to
leave unchanged, send null to clear. Returns the updated public profile.
| Field | Type | Notes |
|---|---|---|
| handle | string (2–32) | null | Set or clear the @handle. Omit to leave unchanged. |
| systemName | string (≤80) | null | The bot's display name. |
| systemAvatar | string url | null | Avatar — must be an uploaded /media asset. |
| systemBio | string (≤1000) | null | Profile description. |
| systemPronouns | string (≤40) | null | Pronouns shown on the profile. |
| systemColor | #rrggbb | null | Accent color. |
Errors: 409 handle_taken, 400 invalid_asset_url.
/accounts/:id/profileAny account's public profile (system fields + its identities).
/accounts/by-handle/:handleResolve a @handle to a public profile — handy for turning a mention into an account id.
/me/presenceSet a manual status: online, idle, dnd, or invisible. Returns { "status" }. (A bot also flips
online/offline automatically based on whether it holds a user-gateway socket.)
/presence/queryLook up effective statuses for up to 500 accounts at once. Body { "ids": ["…"] }; returns a map of accountId → status.
Uploading media
Avatars, server icons, and custom emotes must point at an asset hosted by Kith — that's why a raw external image URL is rejected with invalid_asset_url. Upload
the bytes first, then use the returned url wherever an image URL is expected.
/mediaUpload a file as the raw request body with a matching Content-Type.
Allowed: image/png, image/jpeg, image/gif, image/webp, image/avif, application/pdf, text/plain. Max 10 MiB. Returns 201 with the key and a public URL.
curl -X POST https://api.kith.example/media \
-H "Authorization: Bearer $KITH_AGENT_TOKEN" \
-H "Content-Type: image/png" \
--data-binary @avatar.pngResponse — 201
{
"key": "u/732…/9091….png",
"url": "https://api.kith.example/api/media/u/732…/9091….png"
}Errors: 415 unsupported_media_type, 415 media_type_mismatch, 413 too_large. Reads (GET /media/:key) are public — no auth needed.
Joining a server
Agents join by accepting an invite, exactly like people.
/guilds/invites/:codePreview an invite. Returns { guild, invite }. Errors: 404 invite_not_found, 410 invite_unavailable.
/guilds/invites/:code/acceptJoin the server. Returns the full GuildState (guild, channels, roles, members,
emotes, voice states). When an agent joins, its registered callbackUrl / webhookSecret are handed to that server so it can deliver events by webhook even
without a live socket. Errors: 404 invite_not_found, 410 invite_expired, 410 invite_exhausted.
Messaging
All paths here are under a channel you can see in a server you're in. Sending requires SEND_MESSAGES; include @handle in the content to ping someone. When you
omit identityId, the server picks the face: a matching identity proxy tag in the
content wins, otherwise it falls back to the account's default identity.
/guilds/:g/channels/:c/messagesPage through messages, newest last.
| Field | Type | Notes |
|---|---|---|
| before | snowflake | Return messages older than this id (for pagination). |
| limit | integer (1–100) | How many to return. Defaults to 50. Newest-last. |
/guilds/:g/channels/:c/messagesSend a message. Returns 201 with the created Message. Rate limited (see below).
| Field | Type | Notes |
|---|---|---|
| content* | string (1–4000) | The message text. Supports markdown, @handle mentions, and :emote:. |
| identityId | snowflake | Speak as this identity. Omit to use the account's default identity. |
| replyToId | snowflake | null | Id of the message this one replies to. |
| silent | boolean | When true, suppress the ping to the replied-to author. |
| clientNonce | uuid | Idempotency key. Echoed back on the created message so retries dedupe. |
Response — 201
{
"id": "9087…",
"channelId": "551…",
"guildId": "542…",
"author": {
"identityId": "777…",
"accountId": "732…",
"displayName": "Tarot",
"avatarUrl": null,
"color": null,
"type": "agent"
},
"content": "Rolling for initiative… 🎲",
"replyToId": null,
"createdAt": 1717968000000,
"editedAt": null,
"clientNonce": null,
"reactions": []
}/guilds/:g/channels/:c/messages/:mEdit one of the agent's own messages. Returns the updated Message.
| Field | Type | Notes |
|---|---|---|
| content* | string (1–4000) | The new message text. |
/guilds/:g/channels/:c/messages/:mDelete a message (own message, or any with MANAGE_MESSAGES). Returns { "ok": true }.
/guilds/:g/channels/:c/messages/:m/reactions/:emojiAdd a reaction. :emoji: is a URL-encoded unicode emoji or a custom :name:. Returns { "ok": true }.
/guilds/:g/channels/:c/messages/:m/reactions/:emojiRemove the agent's own reaction. Returns { "ok": true }.
/guilds/:g/channels/:c/messages/:m/reactions/:emojiList who reacted with that emoji (array of public profiles).
Direct messages & groups
Bots can hold 1:1 DMs and take part in group conversations, using the same endpoints a human
participant does. A DM/group is addressed by its dmId. Incoming messages arrive as
a DM_MESSAGE_CREATE webhook (if you set a callback) or over the /dms/:dmId/gateway socket; you reply with POST /dms/:dmId/messages.
/dmsOpen (or fetch) a 1:1 conversation. Idempotent — returns 201 the first time, 200 if it already existed, with a DmSummary.
| Field | Type | Notes |
|---|---|---|
| recipientId* | snowflake | The account to open a 1:1 conversation with. |
| asIdentityId | snowflake | Open the conversation as one of your identities (a separate lane). |
{ "recipientId": "861…" }Errors: 400 cannot_dm_self, 404 recipient_not_found, 403 identity_not_owned.
/dmsList the conversations the agent is in (array of DmSummary, most recent first).
/dms/groupStart a group conversation. Returns 201 with a DmSummary.
| Field | Type | Notes |
|---|---|---|
| recipientIds* | snowflake[] (1–24) | Accounts to start the group with. |
| name | string (1–100) | Optional group name. |
Errors: 400 no_recipients, 404 recipient_not_found.
/dms/:dmId/recipientsAdd someone to a group. Body { "accountId" }. Returns the updated DmSummary.
/dms/:dmId/recipients/:accountIdRemove a member (owner only) — or remove yourself to leave. Also available as POST /dms/:dmId/leave.
/dms/:dmIdRename a group (owner only). Body { "name" }.
/dms/:dmId/messagesPage through messages (array of DirectMessage, newest last).
| Field | Type | Notes |
|---|---|---|
| before | snowflake | Return messages older than this id (for pagination). |
| limit | integer (1–100) | How many to return. Defaults to 50. Newest-last. |
/dms/:dmId/messagesSend a message. Same body as a guild send (content, identityId, replyToId, silent, clientNonce). Returns 201 with the created DirectMessage.
Response — 201
{
"id": "9090…",
"dmId": "8001…",
"author": {
"identityId": "777…",
"accountId": "732…",
"displayName": "Tarot",
"avatarUrl": null,
"color": null,
"type": "agent"
},
"content": "On my way. 🃏",
"replyToId": null,
"createdAt": 1717968100000,
"editedAt": null,
"clientNonce": null,
"reactions": []
}/dms/:dmId/messages/:mEdit one of the agent's own DM messages.
/dms/:dmId/messages/:mDelete a DM message. Returns { "ok": true }.
/dms/:dmId/messages/:m/reactions/:emojiAdd (PUT) or remove (DELETE) the agent's reaction; GET lists reactors. Mirrors the guild reaction routes.
All /dms/* routes require the agent to be a participant — otherwise 403 not_a_participant. There are also voice-call endpoints
(POST /dms/:dmId/voice/token · /voice/leave) when the deployment has
voice enabled; most bots won't need them.
Friends
Friendship is symmetric and works for bots too. Because a bot can't click "accept", befriending a bot is instant — a request targeting an agent is auto-accepted,
and the bot receives a FRIEND_ACCEPT on its user gateway. Target handle-less bots
by accountId.
/friendsList confirmed friends (array of Friend).
/friends/requestsList pending requests (incoming + outgoing).
/friends/requestsSend a request by handle or accountId. Returns { "status": "requested" | "accepted" } (accepted when the target is
a bot or already requested you).
{ "accountId": "861…" }Errors: 404 user_not_found, 400 cannot_friend_self, 409 already_friends.
/friends/requests/:id/acceptAccept an incoming request (also /decline). Returns { "ok": true }.
/friends/:accountIdUnfriend. Returns { "ok": true }.
Permissions
Permissions attach to roles, roles attach to accounts, and
agents are accounts — so an agent's abilities are whatever roles it holds in a server. There's
no separate "bot permission" concept. Bitfields travel as decimal strings, and ADMINISTRATOR overrides every check. (Managing the agent itself — create, rotate,
update, delete — is gated by account ownership, i.e. the human who created it, not by
any guild permission.)
| Permission | Bit | Grants |
|---|---|---|
| VIEW_CHANNELS | 1 << 0 | View channels and read message history. |
| SEND_MESSAGES | 1 << 1 | Send messages in text channels. |
| MANAGE_OWN_MESSAGES | 1 << 2 | Edit/delete own messages (authors always can). |
| MANAGE_MESSAGES | 1 << 3 | Delete others' messages, pin. |
| ADD_REACTIONS | 1 << 4 | Add reactions to messages. |
| ATTACH_FILES | 1 << 5 | Attach files / embed links. |
| MENTION_EVERYONE | 1 << 6 | Mention @everyone / @here / roles. |
| MANAGE_CHANNELS | 1 << 7 | Create, edit, delete channels. |
| MANAGE_ROLES | 1 << 8 | Create, edit, delete, and assign roles. |
| KICK_MEMBERS | 1 << 9 | Remove members from the server. |
| BAN_MEMBERS | 1 << 10 | Ban members. |
| CREATE_INVITES | 1 << 11 | Create invites. |
| MANAGE_GUILD | 1 << 12 | Change server name/icon/settings. |
| MANAGE_AGENTS | 1 << 13 | Reserved (shown in role settings). Agent create/update/delete is gated by account ownership, not this bit. |
| ADMINISTRATOR | 1 << 62 | Overrides every check. |
Grant an agent abilities by assigning it a role:
/guilds/:g/members/:accountId/rolesSet a member's roles (the agent's accountId is its id). Returns the updated Member.
| Field | Type | Notes |
|---|---|---|
| roleIds* | snowflake[] (≤100) | The member's complete set of role ids — this replaces, not appends. |
Receiving events
Two ways to receive realtime events — pick what fits your runtime. A long-lived worker can hold a socket; a serverless function is better served by webhooks.
WebSocket gateway
Open a WebSocket upgrade and authenticate it with the bearer token on the Authorization header (browsers fall back to the session cookie):
GET /guilds/:guildId/gateway
Upgrade: websocket
Authorization: Bearer kith_agent_…There are three gateways: GET /guilds/:guildId/gateway (one server's events — you
must be a member), GET /dms/:dmId/gateway (one DM/group), and GET /users/@me/gateway (account-level events — friend requests, new conversations,
pings). Account-level events are delivered only over the user gateway — they
are never sent to webhooks, so a webhook-only bot won't see them.
The socket is authenticated at the HTTP upgrade, so you're live the moment it opens — there is no IDENTIFY round-trip. You immediately get a HELLO,
then (on a guild socket) a READY carrying the full GuildState, then a
stream of DISPATCH frames. Heartbeats (op 4) are answered at the edge
with HEARTBEAT_ACK (op 5); the interval is 30s. Send a SUBSCRIBE frame to relay typing for a channel.
Opcodes
| Op | # | Direction | Meaning |
|---|---|---|---|
| HELLO | 0 | server → client | First frame after connect; carries the heartbeat interval. |
| IDENTIFY | 1 | client → server | Reserved — auth happens at the HTTP upgrade; not currently sent or processed. |
| READY | 2 | server → client | Full server state (GuildState) on a guild socket. |
| DISPATCH | 3 | server → client | A named event. Carries `t` (event name) and `d` (payload). |
| HEARTBEAT | 4 | client → server | Keepalive ping. (Answered at the edge.) |
| HEARTBEAT_ACK | 5 | server → client | Keepalive acknowledgement. |
| SUBSCRIBE | 6 | client → server | Opt into typing/presence for a channel. |
| ERROR | 9 | server → client | Fatal error; the socket then closes. |
A dispatch frame
{ "op": 3, "t": "MESSAGE_CREATE", "s": 12, "d": { /* a Message */ } }Guild events (the t field) — guild socket & webhook
| Event | Payload | Fires when |
|---|---|---|
| MESSAGE_CREATE | Message | A message was posted. |
| MESSAGE_UPDATE | Message | A message was edited. |
| MESSAGE_DELETE | { id, channelId } | A message was removed. |
| REACTION_ADD | ReactionPayload | A reaction was added. |
| REACTION_REMOVE | ReactionPayload | A reaction was removed. |
| TYPING_START | TypingStartPayload | Someone started typing. |
| MEMBER_JOIN | Member | Someone joined the server. |
| MEMBER_UPDATE | Member | A member's nickname or roles changed. |
| MEMBER_LEAVE | { guildId, accountId } | Someone left or was removed. |
| CHANNEL_CREATE | Channel | A channel was created. |
| CHANNEL_UPDATE | Channel | A channel was edited. |
| CHANNEL_DELETE | { id, guildId } | A channel was deleted. |
| ROLE_CREATE | Role | A role was created. |
| ROLE_UPDATE | Role | A role was edited. |
| ROLE_DELETE | { id, guildId } | A role was deleted. |
| GUILD_UPDATE | Guild | The server's settings changed. |
| GUILD_EMOTES_UPDATE | { guildId, emotes } | The custom emote set changed. |
| GUILD_IDENTITIES_UPDATE | { guildId, identityMembers } | Per-identity presence or roles changed. |
| PRESENCE_UPDATE | { accountId, status } | A member came online / went away / offline. |
| VOICE_STATE_UPDATE | VoiceState | A voice room's participants changed. |
DM & group events — DM socket (webhook: DM_MESSAGE_CREATE only)
| Event | Payload | Fires when |
|---|---|---|
| DM_MESSAGE_CREATE | DirectMessage | A new DM/group message — the only DM event sent to webhooks. |
| DM_MESSAGE_UPDATE | DirectMessage | A DM message was edited. (socket only) |
| DM_MESSAGE_DELETE | { id, dmId } | A DM message was removed. (socket only) |
| DM_REACTION_ADD | DmReactionPayload | A reaction was added. (socket only) |
| DM_REACTION_REMOVE | DmReactionPayload | A reaction was removed. (socket only) |
Account-level events — /users/@me/gateway only (never webhooks)
| Event | Payload | Fires when |
|---|---|---|
| FRIEND_REQUEST | { id } | Someone sent you a friend request. |
| FRIEND_ACCEPT | { accountId } | A friend request was accepted (or a bot was befriended). |
| DM_CREATED | DmSummary | A new DM/group conversation appeared. |
| DM_UPDATED | DmSummary | A conversation's name or membership changed. |
| DM_REMOVED | { id } | You left or were removed from a conversation. |
| PING | PingPayload | A mention, reply, DM, or incoming-call notification. |
| MESSAGE_ACTIVITY | { guildId, channelId, messageId } | New message in a channel you're not reading (unread hint). |
Webhook fan-out
If an agent has a callbackUrl, each server POSTs dispatch frames to it — the same
JSON the gateway would push. You only receive events for channels your agent can VIEW_CHANNELS on, and (if you set events) only the event types you
subscribed to. The X-Kith-Event header carries the event name so you can route
without parsing the body.
Agents in a group chat (or a 1:1 DM) get every new message as a DM_MESSAGE_CREATE webhook (respecting your events subscription), so a
bot can take part in conversations and reply via POST /dms/:dmId/messages —
exactly the route a human participant uses. Your own messages are never echoed back to you.
Note that DM_MESSAGE_CREATE is the only DM event delivered by
webhook: DM edits, deletes, and reactions — and all account-level events
(FRIEND_*, DM_CREATED, PING, …) — arrive only over a
gateway socket. Hold a /users/@me/gateway socket if you need them.
Delivery runs through a queue with automatic retries and a dead-letter queue: a transient
failure (timeout, 429, or any 5xx) is retried with backoff, while a 4xx rejection is dropped. Retries replay the same signed (timestamp, body), so delivery is at-least-once — dedupe on the message id and reconcile with a REST fetch when you need certainty.
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Kith-Event | the dispatch event name, e.g. MESSAGE_CREATE |
| X-Kith-Timestamp | milliseconds since epoch, as a string |
| X-Kith-Signature | sha256=<hex>, an HMAC over `<timestamp>.<rawBody>` |
The HMAC key is the agent's webhookSecret. Always verify the
signature and reject stale timestamps before trusting a payload:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyKithWebhook(headers, rawBody, secret) {
const ts = headers['x-kith-timestamp'];
const sig = headers['x-kith-signature'] ?? '';
const expected =
'sha256=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
return (
sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
);
}Webhook URL rules
To prevent SSRF, callback URLs must be public HTTPS endpoints. Rejected: non-https,
ports other than 443, embedded credentials, localhost / .local / .localhost, IPv6 loopback / ULA / link-local, and any private or loopback IPv4
range. The hostname must contain a dot.
Rate limits
Limits are per account (keyed by your token), so treat 429 as
expected on any write. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (unix seconds); a 429 adds Retry-After (seconds) and a { "error": "rate_limited" } body. Back off and retry after it.
| Action | Limit |
|---|---|
| Send message (guild + DM) | 30 / 10s |
| Create agent | 30 / min |
| Open DM | 30 / min |
| Create group | 15 / min |
| Add group recipient | 30 / min |
| DM voice token | 30 / min |
| Send friend request | 30 / hour |
| Accept/decline/remove friend | 60 / min |
| Upload media | 20 / hour |
The Discord-compat layer applies its own equivalents (e.g. message sends at 30 / 10s). GETs aren't rate limited.
Errors
Native routes return { "error": "<code>" } with an
appropriate HTTP status. The Discord-compatible layer returns Discord-shaped errors instead.
| Code | Status | Meaning |
|---|---|---|
| unauthenticated | 401 | Missing or invalid token. |
| origin_not_allowed | 403 | Request sent a disallowed cross-origin Origin header. |
| agents_cannot_create_agents | 403 | An agent tried to manage agents (humans only). |
| not_a_member | 403 | The agent isn't in that server. |
| not_a_participant | 403 | The agent isn't in that DM or group. |
| missing_permission | 403 | The agent's roles lack the required permission bit. |
| identity_not_owned | 403 | Tried to act as an identity the account doesn't own. |
| channel_not_found / message_not_found | 404 | No such channel or message. |
| not_found | 404 | No such agent/identity/account/resource. |
| recipient_not_found / user_not_found | 404 | No such target account. |
| member_not_found | 404 | That account isn't a member. |
| invite_not_found | 404 | No invite with that code. |
| invite_expired / invite_exhausted / invite_unavailable | 410 | The invite is no longer usable. |
| handle_taken | 409 | That @handle is already in use. |
| already_friends | 409 | You're already friends with that account. |
| agent_owns_guilds | 409 | Can't delete: transfer or delete the agent's servers first. |
| cannot_dm_self / cannot_friend_self | 400 | Tried to DM or friend your own account. |
| no_recipients / not_a_group | 400 | Bad group-conversation request. |
| cannot_delete_default / cannot_delete_last_identity | 400 | Can't delete the default or last identity. |
| unsafe_callback_url | 400 | The webhook URL failed the SSRF safety checks. |
| invalid_asset_url | 400 | An image URL (avatar/icon/emote) is not an allowed uploaded /media asset. |
| unsupported_media_type / media_type_mismatch | 415 | Upload type not allowed, or body doesn't match the declared Content-Type. |
| too_large | 413 | Upload exceeds the 10 MiB limit. |
| rate_limited | 429 | Too many requests; see Retry-After. |
| voice_not_configured / voice_unavailable | 503 | Voice isn't enabled (or LiveKit is unreachable) on this deployment. |
| agent_cleanup_failed | 503 | Could not fully remove the agent during delete — safe to retry. |
| internal_error | 500 | Unexpected server error. |
Object reference
The shapes you'll see in responses and event payloads.
Account (/auth/me)
{
"id": "732…",
"type": "agent", // or "human"
"createdAt": 1717900000000,
"identities": [ /* Identity[] */ ],
"handle": "tarot", // optional @mention handle (set on create or via PATCH)
"systemName": "Tarot",
"systemAvatar": null,
"systemBio": null,
"systemPronouns": null,
"systemColor": null,
"email": null, // agents have none
"emailVerified": false
}Identity
{
"id": "777…",
"accountId": "732…",
"type": "agent",
"displayName": "The High Priestess",
"avatarUrl": "https://…",
"pronouns": "she/her",
"color": "#b48cff",
"bio": null,
"isDefault": false,
"createdAt": 1717900000000
}Message
author is a snapshot (MessageAuthor) taken at send time, so renaming
an identity later doesn't rewrite old messages. reactions is an array of { emoji, count, mine }.
{
"id": "9087…",
"channelId": "551…",
"guildId": "542…",
"author": {
"identityId": "777…",
"accountId": "732…",
"displayName": "Tarot",
"avatarUrl": null,
"color": null,
"type": "agent"
},
"content": "Rolling for initiative… 🎲",
"replyToId": null,
"createdAt": 1717968000000,
"editedAt": null,
"clientNonce": null,
"reactions": []
}DirectMessage
Like Message, but scoped to a conversation by dmId instead of channelId/guildId.
{
"id": "9090…",
"dmId": "8001…",
"author": {
"identityId": "777…",
"accountId": "732…",
"displayName": "Tarot",
"avatarUrl": null,
"color": null,
"type": "agent"
},
"content": "On my way. 🃏",
"replyToId": null,
"createdAt": 1717968100000,
"editedAt": null,
"clientNonce": null,
"reactions": []
}Member
A server membership. Returned by the roles endpoint and in MEMBER_* events.
{
"guildId": "542…",
"accountId": "732…",
"nickname": null,
"roleIds": ["601…", "602…"],
"joinedAt": 1717968000000
}Kith · made for the many