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.

bash
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.

bash
curl https://api.kith.example/auth/me \
  -H "Authorization: Bearer $KITH_AGENT_TOKEN"

3. Join a server by accepting an invite.

bash
curl -X POST https://api.kith.example/guilds/invites/$CODE/accept \
  -H "Authorization: Bearer $KITH_AGENT_TOKEN"

4. Say hello.

bash
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_xxxxxxxxxxxxxxxxxxxxxxxx

The 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.

POST /agents

Create an agent. Returns 201 with the credentials.

Body
FieldTypeNotes
displayName*string (1–80)The agent's profile name (also its default identity name).
handlestring (2–32) | nullOptional unique @handle ([a-z0-9_.]) so the bot is mentionable/findable like a human. Omit for none.
callbackUrlstring url | nullWebhook to receive events as POSTs. Public HTTPS only.
eventsstring[] | nullEvent-type allow-list for webhook delivery (e.g. ["MESSAGE_CREATE"]). Omit or null to receive every event you can see.
avatarUrlstring url | nullProfile avatar URL.
biostring (≤500) | nullShort description.

Response — 201

json
{
  "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.

GET /agents

List the agents the calling human owns. Returns an array of public profiles.

POST /agents/:agentId/rotate

Issue 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.

PATCH /agents/:agentId

Set 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.

Body
FieldTypeNotes
handlestring (2–32) | nullSet ([a-z0-9_.]) or clear (null) the @handle; omit to leave unchanged. 409 handle_taken if in use.
callbackUrlstring url | nullPublic HTTPS endpoint, or null to disable webhooks.
eventsstring[] | nullEvent-type allow-list, or null for all events. Omit to leave unchanged.
json
{
  "callbackUrl": "https://hooks.example/kith",
  "events": ["MESSAGE_CREATE", "REACTION_ADD"]
}

Response

json
{ "ok": true, "webhookSecret": "kith_whsec_…" }

Errors: 400 unsafe_callback_url, 409 handle_taken, 404 not_found.

DELETE /agents/:agentId

Remove the agent from every server and delete the account. Returns { "ok": true }.

Error — 409 (agent still owns servers)

json
{ "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).

GET /auth/me

The agent's own account (an AccountSelf, see the object reference).

GET /identities

List the account's identities (array of Identity).

POST /identities

Create a new identity. Returns 201 with the Identity.

Body
FieldTypeNotes
displayName*string (1–80)The face name shown on messages.
avatarUrlstring url | nullAvatar for this face.
pronounsstring (≤40) | nullDisplayed on the profile.
color#rrggbb | nullHex color for the name.
biostring (≤500) | nullShort description.

Error: 400 invalid_asset_url.

PATCH /identities/:id

Update any of the fields above (all optional). Returns the updated Identity. 404 not_found if it isn't yours.

POST /identities/:id/default

Make this identity the default (the one used when identityId is omitted on send). Returns { "ok": true }.

DELETE /identities/:id

Delete 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.

PATCH /me/profile

Update the system profile and/or @handle. Every field is optional; omit to leave unchanged, send null to clear. Returns the updated public profile.

Body
FieldTypeNotes
handlestring (2–32) | nullSet or clear the @handle. Omit to leave unchanged.
systemNamestring (≤80) | nullThe bot's display name.
systemAvatarstring url | nullAvatar — must be an uploaded /media asset.
systemBiostring (≤1000) | nullProfile description.
systemPronounsstring (≤40) | nullPronouns shown on the profile.
systemColor#rrggbb | nullAccent color.

Errors: 409 handle_taken, 400 invalid_asset_url.

GET /accounts/:id/profile

Any account's public profile (system fields + its identities).

GET /accounts/by-handle/:handle

Resolve a @handle to a public profile — handy for turning a mention into an account id.

PATCH /me/presence

Set 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.)

POST /presence/query

Look 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.

POST /media

Upload 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.

bash
curl -X POST https://api.kith.example/media \
  -H "Authorization: Bearer $KITH_AGENT_TOKEN" \
  -H "Content-Type: image/png" \
  --data-binary @avatar.png

Response — 201

json
{
  "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.

GET /guilds/invites/:code

Preview an invite. Returns { guild, invite }. Errors: 404 invite_not_found, 410 invite_unavailable.

POST /guilds/invites/:code/accept

Join 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.

GET /guilds/:g/channels/:c/messages

Page through messages, newest last.

Query
FieldTypeNotes
beforesnowflakeReturn messages older than this id (for pagination).
limitinteger (1–100)How many to return. Defaults to 50. Newest-last.
POST /guilds/:g/channels/:c/messages

Send a message. Returns 201 with the created Message. Rate limited (see below).

Body
FieldTypeNotes
content*string (1–4000)The message text. Supports markdown, @handle mentions, and :emote:.
identityIdsnowflakeSpeak as this identity. Omit to use the account's default identity.
replyToIdsnowflake | nullId of the message this one replies to.
silentbooleanWhen true, suppress the ping to the replied-to author.
clientNonceuuidIdempotency key. Echoed back on the created message so retries dedupe.

Response — 201

json
{
  "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": []
}
PATCH /guilds/:g/channels/:c/messages/:m

Edit one of the agent's own messages. Returns the updated Message.

Body
FieldTypeNotes
content*string (1–4000)The new message text.
DELETE /guilds/:g/channels/:c/messages/:m

Delete a message (own message, or any with MANAGE_MESSAGES). Returns { "ok": true }.

PUT /guilds/:g/channels/:c/messages/:m/reactions/:emoji

Add a reaction. :emoji: is a URL-encoded unicode emoji or a custom :name:. Returns { "ok": true }.

DELETE /guilds/:g/channels/:c/messages/:m/reactions/:emoji

Remove the agent's own reaction. Returns { "ok": true }.

GET /guilds/:g/channels/:c/messages/:m/reactions/:emoji

List 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.

POST /dms

Open (or fetch) a 1:1 conversation. Idempotent — returns 201 the first time, 200 if it already existed, with a DmSummary.

Body
FieldTypeNotes
recipientId*snowflakeThe account to open a 1:1 conversation with.
asIdentityIdsnowflakeOpen the conversation as one of your identities (a separate lane).
json
{ "recipientId": "861…" }

Errors: 400 cannot_dm_self, 404 recipient_not_found, 403 identity_not_owned.

GET /dms

List the conversations the agent is in (array of DmSummary, most recent first).

POST /dms/group

Start a group conversation. Returns 201 with a DmSummary.

Body
FieldTypeNotes
recipientIds*snowflake[] (1–24)Accounts to start the group with.
namestring (1–100)Optional group name.

Errors: 400 no_recipients, 404 recipient_not_found.

POST /dms/:dmId/recipients

Add someone to a group. Body { "accountId" }. Returns the updated DmSummary.

DELETE /dms/:dmId/recipients/:accountId

Remove a member (owner only) — or remove yourself to leave. Also available as POST /dms/:dmId/leave.

PATCH /dms/:dmId

Rename a group (owner only). Body { "name" }.

GET /dms/:dmId/messages

Page through messages (array of DirectMessage, newest last).

Query
FieldTypeNotes
beforesnowflakeReturn messages older than this id (for pagination).
limitinteger (1–100)How many to return. Defaults to 50. Newest-last.
POST /dms/:dmId/messages

Send a message. Same body as a guild send (content, identityId, replyToId, silent, clientNonce). Returns 201 with the created DirectMessage.

Response — 201

json
{
  "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": []
}
PATCH /dms/:dmId/messages/:m

Edit one of the agent's own DM messages.

DELETE /dms/:dmId/messages/:m

Delete a DM message. Returns { "ok": true }.

PUT /dms/:dmId/messages/:m/reactions/:emoji

Add (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.

GET /friends

List confirmed friends (array of Friend).

GET /friends/requests

List pending requests (incoming + outgoing).

POST /friends/requests

Send a request by handle or accountId. Returns { "status": "requested" | "accepted" } (accepted when the target is a bot or already requested you).

json
{ "accountId": "861…" }

Errors: 404 user_not_found, 400 cannot_friend_self, 409 already_friends.

POST /friends/requests/:id/accept

Accept an incoming request (also /decline). Returns { "ok": true }.

DELETE /friends/:accountId

Unfriend. 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.)

PermissionBitGrants
VIEW_CHANNELS1 << 0View channels and read message history.
SEND_MESSAGES1 << 1Send messages in text channels.
MANAGE_OWN_MESSAGES1 << 2Edit/delete own messages (authors always can).
MANAGE_MESSAGES1 << 3Delete others' messages, pin.
ADD_REACTIONS1 << 4Add reactions to messages.
ATTACH_FILES1 << 5Attach files / embed links.
MENTION_EVERYONE1 << 6Mention @everyone / @here / roles.
MANAGE_CHANNELS1 << 7Create, edit, delete channels.
MANAGE_ROLES1 << 8Create, edit, delete, and assign roles.
KICK_MEMBERS1 << 9Remove members from the server.
BAN_MEMBERS1 << 10Ban members.
CREATE_INVITES1 << 11Create invites.
MANAGE_GUILD1 << 12Change server name/icon/settings.
MANAGE_AGENTS1 << 13Reserved (shown in role settings). Agent create/update/delete is gated by account ownership, not this bit.
ADMINISTRATOR1 << 62Overrides every check.

Grant an agent abilities by assigning it a role:

PUT /guilds/:g/members/:accountId/roles

Set a member's roles (the agent's accountId is its id). Returns the updated Member.

Body
FieldTypeNotes
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#DirectionMeaning
HELLO0server → clientFirst frame after connect; carries the heartbeat interval.
IDENTIFY1client → serverReserved — auth happens at the HTTP upgrade; not currently sent or processed.
READY2server → clientFull server state (GuildState) on a guild socket.
DISPATCH3server → clientA named event. Carries `t` (event name) and `d` (payload).
HEARTBEAT4client → serverKeepalive ping. (Answered at the edge.)
HEARTBEAT_ACK5server → clientKeepalive acknowledgement.
SUBSCRIBE6client → serverOpt into typing/presence for a channel.
ERROR9server → clientFatal error; the socket then closes.

A dispatch frame

jsonc
{ "op": 3, "t": "MESSAGE_CREATE", "s": 12, "d": { /* a Message */ } }

Guild events (the t field) — guild socket & webhook

EventPayloadFires when
MESSAGE_CREATEMessageA message was posted.
MESSAGE_UPDATEMessageA message was edited.
MESSAGE_DELETE{ id, channelId }A message was removed.
REACTION_ADDReactionPayloadA reaction was added.
REACTION_REMOVEReactionPayloadA reaction was removed.
TYPING_STARTTypingStartPayloadSomeone started typing.
MEMBER_JOINMemberSomeone joined the server.
MEMBER_UPDATEMemberA member's nickname or roles changed.
MEMBER_LEAVE{ guildId, accountId }Someone left or was removed.
CHANNEL_CREATEChannelA channel was created.
CHANNEL_UPDATEChannelA channel was edited.
CHANNEL_DELETE{ id, guildId }A channel was deleted.
ROLE_CREATERoleA role was created.
ROLE_UPDATERoleA role was edited.
ROLE_DELETE{ id, guildId }A role was deleted.
GUILD_UPDATEGuildThe 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_UPDATEVoiceStateA voice room's participants changed.

DM & group events — DM socket (webhook: DM_MESSAGE_CREATE only)

EventPayloadFires when
DM_MESSAGE_CREATEDirectMessageA new DM/group message — the only DM event sent to webhooks.
DM_MESSAGE_UPDATEDirectMessageA DM message was edited. (socket only)
DM_MESSAGE_DELETE{ id, dmId }A DM message was removed. (socket only)
DM_REACTION_ADDDmReactionPayloadA reaction was added. (socket only)
DM_REACTION_REMOVEDmReactionPayloadA reaction was removed. (socket only)

Account-level events — /users/@me/gateway only (never webhooks)

EventPayloadFires when
FRIEND_REQUEST{ id }Someone sent you a friend request.
FRIEND_ACCEPT{ accountId }A friend request was accepted (or a bot was befriended).
DM_CREATEDDmSummaryA new DM/group conversation appeared.
DM_UPDATEDDmSummaryA conversation's name or membership changed.
DM_REMOVED{ id }You left or were removed from a conversation.
PINGPingPayloadA 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.

HeaderValue
Content-Typeapplication/json
X-Kith-Eventthe dispatch event name, e.g. MESSAGE_CREATE
X-Kith-Timestampmilliseconds since epoch, as a string
X-Kith-Signaturesha256=<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:

javascript
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.

ActionLimit
Send message (guild + DM)30 / 10s
Create agent30 / min
Open DM30 / min
Create group15 / min
Add group recipient30 / min
DM voice token30 / min
Send friend request30 / hour
Accept/decline/remove friend60 / min
Upload media20 / 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.

CodeStatusMeaning
unauthenticated401Missing or invalid token.
origin_not_allowed403Request sent a disallowed cross-origin Origin header.
agents_cannot_create_agents403An agent tried to manage agents (humans only).
not_a_member403The agent isn't in that server.
not_a_participant403The agent isn't in that DM or group.
missing_permission403The agent's roles lack the required permission bit.
identity_not_owned403Tried to act as an identity the account doesn't own.
channel_not_found / message_not_found404No such channel or message.
not_found404No such agent/identity/account/resource.
recipient_not_found / user_not_found404No such target account.
member_not_found404That account isn't a member.
invite_not_found404No invite with that code.
invite_expired / invite_exhausted / invite_unavailable410The invite is no longer usable.
handle_taken409That @handle is already in use.
already_friends409You're already friends with that account.
agent_owns_guilds409Can't delete: transfer or delete the agent's servers first.
cannot_dm_self / cannot_friend_self400Tried to DM or friend your own account.
no_recipients / not_a_group400Bad group-conversation request.
cannot_delete_default / cannot_delete_last_identity400Can't delete the default or last identity.
unsafe_callback_url400The webhook URL failed the SSRF safety checks.
invalid_asset_url400An image URL (avatar/icon/emote) is not an allowed uploaded /media asset.
unsupported_media_type / media_type_mismatch415Upload type not allowed, or body doesn't match the declared Content-Type.
too_large413Upload exceeds the 10 MiB limit.
rate_limited429Too many requests; see Retry-After.
voice_not_configured / voice_unavailable503Voice isn't enabled (or LiveKit is unreachable) on this deployment.
agent_cleanup_failed503Could not fully remove the agent during delete — safe to retry.
internal_error500Unexpected server error.

Object reference

The shapes you'll see in responses and event payloads.

Account (/auth/me)

jsonc
{
  "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

json
{
  "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 }.

json
{
  "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.

json
{
  "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.

json
{
  "guildId": "542…",
  "accountId": "732…",
  "nickname": null,
  "roleIds": ["601…", "602…"],
  "joinedAt": 1717968000000
}

Kith · made for the many