# Build with AI Source: https://docs.relayapp.im/ai Give coding agents a compact, machine-readable Relay contract. Relay publishes every page as Markdown, the full site as one file, and the API contract as OpenAPI. ## Machine-readable docs | Artifact | URL | Use it for | | -------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | | Docs index | [`/llms.txt`](https://docs.relayapp.im/llms.txt) | Discover every page before exploring | | Full docs | [`/llms-full.txt`](https://docs.relayapp.im/llms-full.txt) | Load the site into RAG or a long-context model | | Any page as Markdown | append `.md` to any page URL | Fetch exactly the page you need, no HTML | | OpenAPI spec | [`/api-reference/openapi.yaml`](https://docs.relayapp.im/api-reference/openapi.yaml) | The canonical machine-readable endpoint contract | | MCP server | `https://docs.relayapp.im/mcp` | Live docs search from Claude, Cursor, VS Code, or any MCP client | Use the text files when feeding Relay docs into a build pipeline, RAG system, or system prompt: fetch `llms-full.txt` at build time or runtime. Use the MCP server when an assistant should search the docs interactively. The page menu on every doc can also copy the page as Markdown or open it in ChatGPT, Claude, or Cursor. ## Install the Relay skill One command teaches Claude Code, Cursor, Codex, or any skills-aware agent the complete Relay integration, offline: ```bash theme={null} npx skills add https://github.com/relaymessenger/skills --skill relay ``` The skill covers the quickstart loop, Agent Tokens, signed webhooks, typed message parts, streaming, interactive components, groups, receipts, limits, and errors. Its topic files are generated from these docs, so the two never disagree. ## Connect the MCP server The server at `https://docs.relayapp.im/mcp` speaks streamable HTTP and exposes docs search and retrieval tools. No authentication is required. ```bash Claude Code theme={null} claude mcp add --transport http relay-docs https://docs.relayapp.im/mcp ``` ```json Cursor (.cursor/mcp.json) theme={null} { "mcpServers": { "relay-docs": { "url": "https://docs.relayapp.im/mcp" } } } ``` ```json VS Code (.vscode/mcp.json) theme={null} { "servers": { "relay-docs": { "type": "http", "url": "https://docs.relayapp.im/mcp" } } } ``` Once connected, ask the assistant to search Relay docs before writing integration code; the server returns current page content with source links. ## The integration brief Paste this into a coding agent or save it beside an existing integration. It covers the current v0 HTTPS contract. ```markdown theme={null} # Relay agent integration brief Relay is a native messenger for AI agents. The integration uses HTTPS at https://api.relayapp.im and one public HTTPS webhook; no SDK is required. ## Authentication Read `RELAY_AGENT_TOKEN` from the environment and send it as `Authorization: Bearer `. Verify with GET /v1/agents/me. Update the credential before retrying a 401. ## Receive (signed webhook) Register with POST /v1/webhooks { "url": "https://..." } using the Agent Token. Store the returned signing_secret; it is shown only on create or rotation. - Relay POSTs the event as raw JSON with webhook-id, webhook-timestamp, and webhook-signature headers. Verify HMAC-SHA256 over `..` using the base64-decoded bytes after whsec_. - Reject timestamps outside a 5-minute window and compare signatures in constant time. - Return 2xx only after durable acceptance. Delivery is at-least-once; deduplicate on event_id. - Envelope: { event_id, event_type, agent_id, created_at, data }. - Event types today: message.received (data.message = full message), message.edited (data.message + revision_count), message.unsent (data.message_id + conversation_id + sequence), message.delivered / message.read (receipt watermarks: data.through_sequence), reaction.added / reaction.removed (data.reaction), conversation.added / conversation.updated / conversation.removed (group lifecycle), group.invite.joined / group.invite.declined (one person's answer, joined carries conversation_id and user_id), group.invite.completed / group.invite.expired (terminal state). Ignore unknown types. - Relay retries timeouts, connection errors, 408, 429, and 5xx with exponential backoff for up to 10 attempts. Any other non-2xx response is a permanent failure and is dead-lettered immediately. - A successful message.received delivery automatically marks it Delivered. ## Send POST /v1/messages with the required Idempotency-Key header (8-255 chars; derive it from the inbound event_id so retries are safe; reuse the same key when you retry). Body: { "conversation_id": "cnv_…", "parts": [...], "reply_to"?: { "message_id", "part_index"? } } Part types (1-32 parts, order = presentation order): - { "type": "text", "text": "…" } (≤ 8 KB per part) - { "type": "link_preview", "url": "https://…" } - { "type": "data", "data": { any JSON } } (≤ 16 KB) - { "type": "media", "url" | "attachment_id" } (exactly one of the two) - { "type": "voice_memo", "url": "https://…", "duration_ms": 18400 } - { "type": "voice_memo", "attachment_id": "att_…", "duration_ms": 18400 } Response 202 { message_id, message }; parts come back with dense part_index. ## Stream an existing agent reply POST /v1/messages?stream=true&conversation_id=cnv_… with: - Content-Type: text/event-stream - x-vercel-ai-ui-message-stream: v1 - the required Idempotency-Key - the SSE body returned by Vercel AI SDK toUIMessageStreamResponse() Relay consumes the stream and commits one canonical message only after the UIMessageStream `finish` event. `[DONE]` closes the transport but is not semantic completion. Abort, error, malformed ordering, or early EOF commits nothing. Relay presents tool state but never executes tools; the external agent continues to own its model and tool loop. ## Messaging state - Typing: POST /v1/conversations/{id}/typing { "started": true, "label"?: "Searching…" } (204; send {started:false} if you end up not replying; label ≤ 80 chars) - Read receipts: POST /v1/conversations/{id}/read { "message_id" }; read implies delivered; watermarks are monotonic and idempotent. - Reactions: POST /v1/messages/{id}/reactions { "operation": "add"|"remove", "type": "love|like|dislike|laugh|emphasize|question|emoji", "emoji"? (iff type=emoji), "part_index"? } - Edit: PATCH /v1/messages/{id} { "parts": [...] } within 15 minutes, at most five revisions. Only the original user session or Agent Token can edit. Replacement and stored content must be text-bearing with no attachments. - Unsend: DELETE /v1/messages/{id} within two minutes. Only the original sender can unsend. History keeps a bare status=deleted tombstone. - History: GET /v1/conversations/{id}/messages?limit=50&before_sequence=N, newest first. Visible messages include receipt-projected status, reactions, edited_at, and revisions. Unsent messages project as bare tombstones. - Media: POST /v1/attachments (raw body ≤ 100 MB, Content-Type + X-Relay-Filename headers) → { attachment: { id: "att_…", url } }; send the id in a media/voice_memo part. Attachments are private to their uploader. ## Errors Envelope: { "error": { "code", "message" } }. Codes: unauthorized(401), forbidden(403), not_found(404), invalid_request(422), idempotency_conflict(409), internal_error(500). Retry only 429/5xx/network, with backoff, reusing the same Idempotency-Key. ## Coming later Treat these surfaces as unavailable in the current preview: Agent-initiated group management, socket mode, and calls. Full matrix: https://docs.relayapp.im/roadmap.md ``` The [quickstart](/quickstart) is intentionally curl-first, and the [streaming guide](/guides/streaming) shows the direct AI SDK handoff without introducing a Relay SDK or a second agent runtime. ## Next steps * [Quickstart, the curl-first receive-and-reply loop](/quickstart) * [Streaming replies, the direct AI SDK handoff](/guides/streaming) * [Text your coding agent](/guides/coding-agents) * [API availability](/roadmap) # Compare your options Source: https://docs.relayapp.im/alternatives What Relay gives an agent, and when another channel is the better fit. Relay is a first-party messenger for independently operated AI agents. This page covers what that gives you, and when a different channel fits better. ## Platform comparison What an agent can express on each channel. | Platform | Voice | Images | Files | Threads | Reactions | Typing | Streaming | | ----------- | :---: | :----: | :---: | :-----: | :-------: | :----: | :-------: | | **Relay** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | | Discord | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Slack | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Matrix | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Feishu/Lark | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Telegram | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | | Mattermost | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | | Weixin | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | | WeCom | ✅ | ✅ | ✅ | — | — | — | — | | DingTalk | — | ✅ | ✅ | — | ✅ | — | ✅ | | WhatsApp | — | ✅ | ✅ | — | — | ✅ | ✅ | | Signal | — | ✅ | ✅ | — | — | ✅ | ✅ | | Google Chat | — | ✅ | ✅ | ✅ | — | ✅ | — | | Email | — | ✅ | ✅ | ✅ | — | — | — | | SMS | — | — | — | — | — | — | — | In the Streaming column, — is deliberate: Relay accepts a [streamed reply](https://docs.relayapp.im/guides/streaming) from your backend and commits it as one finished message, the way iMessage and WhatsApp deliver finished messages instead of mutating bubbles. Relay adds two things the matrix cannot show: delivery and read receipts your backend can act on, and interactive [message components](/components) that return a typed result instead of parsed text. ## What else Relay gives an agent | ✅ | 🔵 | ⏳ | | :-------: | :---------------: | :----------: | | Available | Developer preview | Coming later | | Status | Capability | | :----: | ----------------------------------------------------------------------------- | | ✅ | A dedicated inbox the user shares with every agent they have added | | ✅ | Your own identity, handle, avatar, and named operator on the profile | | ✅ | Explicit install and remove, instead of silent inbox access | | ✅ | Durable conversation history that survives reinstall | | ✅ | Typing indicators, reactions, delivery and read receipts | | ✅ | Streaming replies that commit one canonical message | | ✅ | Typed message parts: text, media, voice memos, link previews, data | | ✅ | Interactive [message components](/components): buttons, select, confirm, card | | ✅ | Signed webhooks, or long polling when you have no public inbound | | ✅ | Idempotent sends, so a redelivered event never doubles a reply | | ✅ | Your own model, prompts, tools, and hosting, with no Relay runtime | | ✅ | Group conversations, with explicit invocation instead of ambient access | | ✅ | Attachments up to 100 MB | | ✅ | Public, unlisted, and private visibility, with reviewed Store listing | | ✅ | Edit and unsend, with sender-only windows and revision history | | ⏳ | Socket mode, calls, agent-initiated group membership management | No business verification, sender registration, template approval, or per-message carrier fee stands between you and a user who has added your agent. Full detail in [developer preview availability](/roadmap). ## What Relay asks for Relay is a new app. That means a user has to install it and choose your agent, and it means Relay carries no existing installed network. If reach into an audience that already exists matters more than a dedicated agent relationship, one of the channels below is the better answer. ## Other channels Relay complements these. A backend can keep serving any of them and add Relay as one more channel. | Channel | What it is | Choose it when | | --------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------ | | **Standalone app** | Your own iOS or Android app | The agent has enough demand and unique surface area to justify a complete product | | **Telegram** | Official Bot API, Mini Apps, topics, payments | Its installed network or Mini Apps matter more than a dedicated agent relationship | | **Discord** | Bot API plus Gateway, servers, channels, DMs | The agent belongs to a community built on channels and roles | | **Slack** | Official app APIs, scopes, events, commands | The agent belongs in a workplace with workspace permissions | | **WhatsApp Business** | Business Platform, templates, session windows | Reach and proactive business messaging matter most | | **SMS and RCS** | Carrier messaging through a provider | You need to reach a phone number with no app at all | | **Apple Messages for Business** | Apple's approved business channel | The relationship belongs in the native iOS inbox and you can clear business approval | | **Managed provider or multi-channel runtime** | Linq, Photon, OpenClaw, Hermes, and similar | You want one integration that reaches users inside several existing networks | Each of these defines the account, identity, invocation rules, history, permissions, rich output, discovery, and enforcement surface for you. That is the trade: less to build, less that is yours. ## A note on personal-account automation Official bot and business APIs are supported paths, and their constraints are part of the platform contract. Automating an ordinary *personal* account is a different mechanism. Discord [prohibits automating normal user accounts](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots) outside its bot API, WhatsApp's consumer terms reserve automated messaging for the Business Platform, and Apple's developer agreement restricts private APIs and unauthorized access to Apple services. If an integration depends on private interfaces, automated personal-account access, or a signed-in personal device, that dependency sits outside your control. Platform enforcement can affect the connected account or your service access. Relay's delivery path is fully first-party: users sign in to Relay itself, never by attaching a personal account on another platform, so the integration stays inside contracts you and Relay control. ## See also * [Quickstart](/quickstart) * [Why Relay](/why-relay) * [Developer preview availability](/roadmap) # Get a user Source: https://docs.relayapp.im/api-reference/agent/get-a-user /api-reference/openapi.yaml get /v1/users/{user_id} Resolve a human participant your agent is in conversation with. Every `message.received` event carries `data.message.sender.id` (a `usr_…` id); pass it here to read that sender's name and messaging phone number — enough to greet them or match them to an existing account. Scope: the user must currently share an active conversation (direct or group) with your agent. Any other id — unknown, or a user you have no active conversation with — returns `404` so the endpoint can neither enumerate accounts nor confirm who owns a phone number. # Verify the agent token Source: https://docs.relayapp.im/api-reference/agent/verify-the-agent-token /api-reference/openapi.yaml get /v1/agents/me Verify an Agent Token and read the agent profile it controls. # Get attachment metadata Source: https://docs.relayapp.im/api-reference/attachments/get-attachment-metadata /api-reference/openapi.yaml get /v1/attachments/{attachment_id} Uploader-only. Others receive 404. # Upload an attachment Source: https://docs.relayapp.im/api-reference/attachments/upload-an-attachment /api-reference/openapi.yaml post /v1/attachments Streams one raw request body directly to Relay storage, up to 100 MB. `Content-Length` is required and Relay verifies it against the stored object size. `Content-Type` sets the stored MIME type; `X-Relay-Filename` names downloads. Agent tokens and user sessions both work; the attachment is private to its uploader. # Advance the read watermark Source: https://docs.relayapp.im/api-reference/conversations/advance-the-read-watermark /api-reference/openapi.yaml post /v1/conversations/{conversation_id}/read Marks everything through `message_id` read for the caller. Read implies delivered; older or repeated targets are idempotent no-ops. # Create or reuse a human direct conversation Source: https://docs.relayapp.im/api-reference/conversations/create-or-reuse-a-human-direct-conversation /api-reference/openapi.yaml post /v1/conversations/direct Creates the single direct conversation for the authenticated Relay user and a verified target user, or reuses the existing conversation for that normalized pair. # Get a conversation Source: https://docs.relayapp.im/api-reference/conversations/get-a-conversation /api-reference/openapi.yaml get /v1/conversations/{conversation_id} Read one conversation where the authenticated agent is an active participant. Unknown conversations and conversations outside the agent's active membership both return 404. # List conversations Source: https://docs.relayapp.im/api-reference/conversations/list-conversations /api-reference/openapi.yaml get /v1/conversations List conversations where the authenticated agent is an active participant. Results are ordered by `last_message_at` descending, with conversations that have no messages last. Pass the opaque `next_cursor` unchanged to read the next page. # Start or stop the typing indicator Source: https://docs.relayapp.im/api-reference/conversations/start-or-stop-the-typing-indicator /api-reference/openapi.yaml post /v1/conversations/{conversation_id}/typing Ephemeral — pushed to the user's live devices, never enters the event log. `label` (≤ 80 chars) renders a short status line while started. # Delete a webhook Source: https://docs.relayapp.im/api-reference/events/delete-a-webhook /api-reference/openapi.yaml delete /v1/webhooks/{webhook_id} Permanently deletes the endpoint and stops new deliveries to it. # List webhooks Source: https://docs.relayapp.im/api-reference/events/list-webhooks /api-reference/openapi.yaml get /v1/webhooks List the authenticated agent's webhook endpoints. Signing secrets are never returned. # Long-poll for events Source: https://docs.relayapp.im/api-reference/events/long-poll-for-events /api-reference/openapi.yaml get /v1/events Drain the agent's durable event log past `cursor` (Telegram getUpdates model). When no newer events exist the request holds open up to `timeout` seconds and returns as soon as one lands. Passing a cursor durably acknowledges every event at or below it. Before making the next request, atomically persist this page's events to the engine queue together with `next_cursor`; never advance the cursor before the events are durable. While any webhook endpoint is enabled this endpoint returns `409 conflict`; the check runs per request and on every wake, so around a registration or disable both paths may briefly observe the same events — deduplicate by `event_id`. One consumer per agent: starting a newer poll terminates an older held-open request with `409 terminated_by_other_consumer`. # Receive an agent event Source: https://docs.relayapp.im/api-reference/events/receive-an-agent-event /api-reference/openapi.yaml webhook agentEvent Relay sends this request to each matching enabled endpoint. Verify the Standard Webhooks signature against the exact raw body, deduplicate by event_id, durably accept the event, and return a 2xx within 10 seconds. # Register a webhook Source: https://docs.relayapp.im/api-reference/events/register-a-webhook /api-reference/openapi.yaml post /v1/webhooks Register a public HTTPS receiver. The signing secret is returned only in this response. Matching events from the preceding 24 hours are queued on first registration, capped at 1,000. An agent may have up to five enabled endpoints. # Rotate a webhook secret Source: https://docs.relayapp.im/api-reference/events/rotate-a-webhook-secret /api-reference/openapi.yaml post /v1/webhooks/{webhook_id}/rotate-secret Returns a new signing secret once. For 24 hours Relay includes signatures from both the new and previous secret for zero-downtime rotation. # Update a webhook Source: https://docs.relayapp.im/api-reference/events/update-a-webhook /api-reference/openapi.yaml patch /v1/webhooks/{webhook_id} Change the URL, event filters, or enabled state. # Accept a group invite Source: https://docs.relayapp.im/api-reference/groups/accept-a-group-invite /api-reference/openapi.yaml post /v1/groups/invites/{invite_id}/accept Accept as one invited Relay user, with immediate effect. The first accept atomically creates the group, or adds the caller to the existing destination group, and puts the caller in it beside the proposing agent. Every later accept joins that same conversation. Repeating an accept returns the live conversation and adds nobody twice. # Decline a group invite Source: https://docs.relayapp.im/api-reference/groups/decline-a-group-invite /api-reference/openapi.yaml post /v1/groups/invites/{invite_id}/decline Decline as one invited Relay user. Declining answers for the caller alone: it removes nobody from a live conversation and never blocks another target from joining. Once every target has answered, the invite reaches its terminal state. # Get a group invite Source: https://docs.relayapp.im/api-reference/groups/get-a-group-invite /api-reference/openapi.yaml get /v1/groups/invites/{invite_id} Read the current member acceptance states for an invite created by the authenticated agent. # Propose a group Source: https://docs.relayapp.im/api-reference/groups/propose-a-group /api-reference/openapi.yaml post /v1/groups/invites Ask verified users who have installed the authenticated agent to join a new group or an existing group that already contains the agent. Relay commits one consent card into each target's direct conversation with the agent. The agent never changes membership directly. The first accept makes the conversation live, each later accept joins that same conversation, and the invite stays open for whoever has not answered until everyone answers or it expires. # Add or remove a reaction Source: https://docs.relayapp.im/api-reference/messages/add-or-remove-a-reaction /api-reference/openapi.yaml post /v1/messages/{message_id}/reactions Add or remove a tapback or emoji on a message or a specific part. Recipients receive `reaction.added` / `reaction.removed` events. # Edit a message Source: https://docs.relayapp.im/api-reference/messages/edit-a-message /api-reference/openapi.yaml patch /v1/messages/{message_id} Replace the authenticated sender's message parts without changing its conversation sequence. User-sent messages require that user's session. Agent-sent messages require that agent's token. Messages can be edited for 15 minutes after creation and at most five times. Both the stored message and replacement must be text-bearing and cannot contain attachment parts. Relay does not send a push notification for edits. # Read conversation history Source: https://docs.relayapp.im/api-reference/messages/read-conversation-history /api-reference/openapi.yaml get /v1/conversations/{conversation_id}/messages Read a conversation's messages, newest first. The authenticated agent must be a participant. Page backwards with `before_sequence`. # Send a message Source: https://docs.relayapp.im/api-reference/messages/send-a-message /api-reference/openapi.yaml post /v1/messages Send a message as the authenticated agent. Requires an `Idempotency-Key` header of 8–255 characters; reusing the same key with the same request returns the original message. To pipe native agent output, set `stream=true` and pipe a Vercel AI SDK UIMessageStream v1 SSE body into this same request. Relay commits one canonical message after the stream's semantic `finish` event; recipients receive that final committed message. # Unsend a message Source: https://docs.relayapp.im/api-reference/messages/unsend-a-message /api-reference/openapi.yaml delete /v1/messages/{message_id} Unsend the authenticated sender's message within two minutes of creation. Relay keeps its id and conversation sequence as a deleted tombstone, removes its stored parts, and does not send a push notification. User-sent messages require that user's session. Agent-sent messages require that agent's token. # API overview Source: https://docs.relayapp.im/api-reference/overview Base URL, authentication, errors, streaming, reactions, and public profiles. Relay uses versioned HTTPS endpoints. The current developer preview is `/v1`. ## Conventions | | Value | | -------------- | -------------------------------------------------------------- | | Base URL | `https://api.relayapp.im` | | Staging | `https://api.staging.relayapp.im` | | Authentication | `Authorization: Bearer $RELAY_AGENT_TOKEN` | | Content type | `application/json`, or `text/event-stream` for a native stream | | Version | `/v1` | Every endpoint requires authentication unless it is marked public. Every error uses one shape: ```json theme={null} { "error": { "code": "invalid_request", "message": "conversation_id is required" } } ``` See [Errors](/reference/errors) for the full status and code table. ## Incoming events Register a public HTTPS endpoint with `POST /v1/webhooks`. Relay signs the exact request body using the Standard Webhooks headers `webhook-id`, `webhook-timestamp`, and `webhook-signature`. Return `2xx` only after durable acceptance, and deduplicate retries on `event_id`. ## Native streaming Set `stream=true` and pipe the Vercel AI SDK's existing `UIMessageStream v1` body. Relay consumes the stream and commits one canonical message at semantic completion. ```bash theme={null} # reply.sse is the body from toUIMessageStreamResponse() curl -sS -X POST \ "$RELAY_API_URL/v1/messages?stream=true&conversation_id=cnv_01JZC7K4RQ" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Idempotency-Key: reply-evt_01JZE9M2XW" \ -H "Content-Type: text/event-stream" \ -H "x-vercel-ai-ui-message-stream: v1" \ --data-binary @reply.sse ``` The response is the normal `202 { message_id, message }`. `abort`, `error`, malformed event ordering, or a disconnect before `finish` writes no message at all. Retry the complete stream with the same idempotency key. ## Reactions Add or remove a tapback or emoji on a message, or on one specific part. ```bash theme={null} curl -sS -X POST "$RELAY_API_URL/v1/messages/msg_01JZM3T8AH/reactions" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"operation":"add","type":"emoji","emoji":"🔥","part_index":0}' ``` | Field | Accepts | | ------------ | -------------------------------------------------------------------- | | `operation` | `add`, `remove` | | `type` | `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`, `emoji` | | `emoji` | Required if and only if `type` is `emoji` | | `part_index` | Optional. Targets one part instead of the whole message | Matching agent webhooks receive `reaction.added` and `reaction.removed`. The app receives durable `reaction.changed`. ## Share links Public and unlisted agents have a share URL at `relayapp.im/handle`. Relay shows the username as `@handle` in the app and on the profile. `GET /v1/contacts/{handle}/profile` returns display name, tagline, avatar, and visibility without authentication. | Visibility | `GET /v1/agents` | Profile route | | -------------------------------------- | :--------------: | :-----------------------------: | | Public, listing approved and published | ✅ | ✅ | | Public, listing pending | ❌ | ✅ | | Unlisted | ❌ | ✅ by exact handle or share link | | Private | ❌ | ❌ `404` | Message editing and unsending, socket mode, calls, and the future event families listed in [API availability](/roadmap) are not implemented in the v0 developer API. ## See also * [Quickstart](/quickstart) * [Authentication](/authentication) * [Webhooks](/guides/webhooks) for verification, filters, retries, and rotation * [Streaming replies](/guides/streaming) for the exact stream contract * [Create and connect an agent](/guides/your-agent) * [Developer data access and retention](/reference/data-and-permissions) # Claim a pairing Source: https://docs.relayapp.im/api-reference/pairing/claim-a-pairing /api-reference/openapi.yaml post /v1/pairings/{code}/claim App-internal authenticated claim. Creates the private agent and its conversation but never returns the Agent Token. Repeating the same unexpired claim from the same user returns the existing agent. # Create a pairing Source: https://docs.relayapp.im/api-reference/pairing/create-a-pairing /api-reference/openapi.yaml post /v1/pairings Start device-code pairing from a terminal. No authentication; the source IP is rate-limited. Show the returned `code` (and a QR of `url`) to the user, then long-poll the pairing with `poll_token` until the Relay app claims it. Codes are single-use and expire after `expires_in` seconds. If a JSON body is present it must be an object containing only the documented fields. # Poll a pairing Source: https://docs.relayapp.im/api-reference/pairing/poll-a-pairing /api-reference/openapi.yaml get /v1/pairings/{pairing_id} Poll pairing state with the pairing's `poll_token` as the bearer credential. With `wait=true` the request holds open (≤30 s) and returns as soon as the pairing is claimed. After a claim, the computer receives the minted Agent Token. A lost response is recoverable: the same poll credential receives the same token until its first successful Agent API use or ten minutes after first delivery. Then Relay scrubs the recovery copy and returns `410`. An expired unclaimed pairing returns `404`. A newer concurrent poll terminates the older one with `409 terminated_by_other_consumer`. # Preview a pairing claim Source: https://docs.relayapp.im/api-reference/pairing/preview-a-pairing-claim /api-reference/openapi.yaml get /v1/pairings/code/{code} App-internal authenticated preview used before the user confirms Add. Claimed and pending codes are visible only until their advertised ten-minute expiry; lookup attempts are rate-limited independently from final claims. # List Store agents Source: https://docs.relayapp.im/api-reference/public/list-store-agents /api-reference/openapi.yaml get /v1/agents Returns active public agents whose Store listing is approved and published. Public visibility alone does not put an agent in this catalog. This read never installs an agent or creates a conversation. Consumer projections never expose an owner, prompt, provider, model, runtime, credential, or backend configuration. # Public agent profile Source: https://docs.relayapp.im/api-reference/public/public-agent-profile /api-reference/openapi.yaml get /v1/contacts/{handle}/profile The profile behind a public or unlisted share link (`relayapp.im/handle`). Private handles return 404. Exposes only displayable identity, never tokens, owner, or configuration. # Authentication Source: https://docs.relayapp.im/authentication Authenticate requests, verify identity, and rotate credentials. Set `RELAY_AGENT_TOKEN` in your server environment. Requests authenticate as the Relay agent that issued the token. ```bash theme={null} curl -sS "https://api.relayapp.im/v1/agents/me" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" ``` `GET /v1/agents/me` verifies the credential and returns the agent's `id`, `handle`, and profile. Relay shows the `rly_live_…` token once when the agent is created. ## Store and rotate * Keep it in a secret manager or environment variable. Relay stores only a hash. * Keep it out of source code, logs, and URLs. * A `401 unauthorized` response means the credential needs attention. Update it before retrying. * If a token is exposed, rotate it from the agent profile and update the deployment. Rotation revokes the previous token immediately. An Agent Token authenticates external code as one agent, and only that: user accounts keep their own sessions, and the backend runs on the developer's own infrastructure. See [Your agent](/guides/your-agent) for the identity, installation, and conversation boundaries behind the credential. ## Next steps * [Create and connect an agent](/guides/your-agent) * [Quickstart](/quickstart) * [Errors, including `401 unauthorized`](/reference/errors) # Agent permission request component Source: https://docs.relayapp.im/components/agent_permission_request Render relayapp permission choices with their original tap origins intact. ## Abstract `agent_permission_request` is the permission component already emitted by Relay's coding-agent integrations. Relay validates the shared component fields, preserves integration-owned top-level fields, and never rewrites the option origins. ## Preview TODO: add the real iOS lane capture from `/tmp/relay-component-agent-permission-request.png`. This slot intentionally has no fabricated screenshot. ## Wire example ```json theme={null} { "send": { "conversation_id": "cnv_01JZC7K4RQ", "parts": [{ "type": "data", "data": { "kind": "agent_permission_request", "request_id": "perm_01JZ8A4M", "tool_name": "Bash", "options": [ { "id": "allow", "label": "Allow", "style": "primary", "origin": { "kind": "agent_permission", "request_id": "perm_01JZ8A4M" } }, { "id": "deny", "label": "Deny", "origin": { "kind": "agent_permission", "request_id": "perm_01JZ8A4M" } } ] } }] }, "stored_part": { "part_index": 0, "type": "data", "data": { "kind": "agent_permission_request", "request_id": "perm_01JZ8A4M", "tool_name": "Bash", "options": [ { "id": "allow", "label": "Allow", "style": "primary", "origin": { "kind": "agent_permission", "request_id": "perm_01JZ8A4M" } }, { "id": "deny", "label": "Deny", "origin": { "kind": "agent_permission", "request_id": "perm_01JZ8A4M" } } ], "fallback": "1. Allow\n2. Deny" } }, "tap_result": { "parts": [{ "type": "data", "data": { "origin": { "kind": "agent_permission", "request_id": "perm_01JZ8A4M" }, "option_id": "allow", "label": "Allow" } }], "fallback_text": "Allow" } } ``` ## Schema table | Field | Type | Required | Notes | | ---------------------- | ---------------------------- | -------- | ---------------------------------------- | | `kind` | `"agent_permission_request"` | Yes | Selects the permission renderer | | `request_id` | string | Yes | Non-empty integration request identity | | `prompt` | string | No | Up to 1,024 characters | | `options` | permission option\[] | Yes | Non-empty; every option carries `origin` | | `fallback` | string | No | Synthesized when omitted | | Other top-level fields | JSON | No | Preserved for relayapp compatibility | Each option uses `id`, `label`, optional `style`, and a required `origin` object. Permission options cannot combine an origin with a link-out URL. ## Examples per state | State | Repository artifact | | ------------------------ | -------------------------------------------------------------- | | Send request | `examples/components/agent_permission_request-send.json` | | Canonical stored message | `examples/components/agent_permission_request-stored.json` | | Tap-result user message | `examples/components/agent_permission_request-tap-result.json` | ## Constraints | Violation | Result | | ----------------------------------------- | --------------------- | | Missing or empty `request_id` | `422 invalid_request` | | Empty option list | `422 invalid_request` | | Option without `origin` | `422 invalid_request` | | Origin combined with a link-out URL | `422 invalid_request` | | Invalid common option ID, label, or style | `422 invalid_request` | | Message targets a group conversation | `422 invalid_request` | ## iOS rendering notes Render with `ComponentConfirm` machinery while preserving the integration's allow and deny labels. On tap, echo the chosen option's `origin` object verbatim instead of constructing `data_action`. ## See also * [Message components](/components) * [Confirm](/components/confirm) * [Coding agents](/guides/coding-agents) # Buttons component Source: https://docs.relayapp.im/components/buttons Attach a vertical stack of up to five options to an agent message. ## Abstract Use `buttons` when a short set of distinct actions should remain attached to the agent message that requested them. An option without `url` sends an origin-tagged user data message; an option with `url` opens its HTTPS destination. ## Preview TODO: add the real iOS lane capture from `/tmp/relay-component-buttons.png`. This slot intentionally has no fabricated screenshot. ## Wire example ```json theme={null} { "send": { "conversation_id": "cnv_01JZC7K4RQ", "parts": [{ "type": "data", "data": { "kind": "buttons", "prompt": "Want me to book it?", "options": [ { "id": "book", "label": "Book it", "style": "primary" }, { "id": "later", "label": "Remind me later" } ] } }] }, "stored_part": { "part_index": 0, "type": "data", "data": { "kind": "buttons", "prompt": "Want me to book it?", "options": [ { "id": "book", "label": "Book it", "style": "primary" }, { "id": "later", "label": "Remind me later" } ], "fallback": "Want me to book it?\n1. Book it\n2. Remind me later" } }, "tap_result": { "parts": [{ "type": "data", "data": { "origin": { "kind": "data_action", "message_id": "msg_01JZM4Q9VN", "part_index": 0, "option_id": "book", "source_kind": "buttons" }, "option_id": "book", "label": "Book it" } }], "fallback_text": "Book it" } } ``` ## Schema table | Field | Type | Required | Notes | | ---------- | ----------- | -------- | ------------------------ | | `kind` | `"buttons"` | Yes | Selects this renderer | | `prompt` | string | No | Up to 1,024 characters | | `options` | option\[] | Yes | 1–5 options | | `fallback` | string | No | Synthesized when omitted | Button options use the [common option fields](/components#common-option-fields), except `description` is not accepted. ## Examples per state | State | Repository artifact | | ------------------------ | --------------------------------------------- | | Send request | `examples/components/buttons-send.json` | | Canonical stored message | `examples/components/buttons-stored.json` | | Tap-result user message | `examples/components/buttons-tap-result.json` | ## Constraints | Violation | Result | | ------------------------------------ | --------------------- | | No options or more than 5 | `422 invalid_request` | | Duplicate or invalid option `id` | `422 invalid_request` | | Option label over 24 characters | `422 invalid_request` | | More than one `primary` option | `422 invalid_request` | | `description` on a button option | `422 invalid_request` | | Non-HTTPS option URL | `422 invalid_request` | | Message targets a group conversation | `422 invalid_request` | ## iOS rendering notes Render `ComponentButtons` as a vertical stack under the owning agent bubble at message width. A selected option becomes selected, siblings disable, and an in-flight tap shows a spinner. Style HTTPS link-out options as quieter navigation rows rather than reply choices. A failed send re-enables the options with a brief error. ## See also * [Message components](/components) * [Confirm](/components/confirm) * [Sending messages](/guides/sending-messages) # Card component Source: https://docs.relayapp.im/components/card Send a slot-based media card with a title, captions, link, and up to two actions. ## Abstract Use `card` for one rich object whose image, title, metadata, destination, and actions belong together. Its slots follow the compact message-card grammar rather than a dashboard layout. ## Preview TODO: add the real iOS lane capture from `/tmp/relay-component-card.png`. This slot intentionally has no fabricated screenshot. ## Wire example ```json theme={null} { "send": { "conversation_id": "cnv_01JZC7K4RQ", "parts": [{ "type": "data", "data": { "kind": "card", "media": { "url": "https://example.com/osteria.jpg" }, "title": "Osteria Mozza", "subtitle": "Italian · $$$", "caption": "Tue 2:00pm", "trailing_caption": "2 seats", "url": "https://example.com/restaurants/osteria-mozza", "options": [{ "id": "reserve", "label": "Reserve", "style": "primary" }] } }] }, "stored_part": { "part_index": 0, "type": "data", "data": { "kind": "card", "media": { "url": "https://example.com/osteria.jpg" }, "title": "Osteria Mozza", "subtitle": "Italian · $$$", "caption": "Tue 2:00pm", "trailing_caption": "2 seats", "url": "https://example.com/restaurants/osteria-mozza", "options": [{ "id": "reserve", "label": "Reserve", "style": "primary" }], "fallback": "Osteria Mozza\n1. Reserve" } }, "tap_result": { "parts": [{ "type": "data", "data": { "origin": { "kind": "data_action", "message_id": "msg_01JZM6Q9VN", "part_index": 0, "option_id": "reserve", "source_kind": "card" }, "option_id": "reserve", "label": "Reserve" } }], "fallback_text": "Reserve" } } ``` ## Schema table | Field | Type | Required | Notes | | ------------------ | --------- | -------- | ---------------------------------------------------------- | | `kind` | `"card"` | Yes | Selects this renderer | | `media` | object | No | Exactly one `attachment_id` or credential-free HTTPS `url` | | `title` | string | Yes | 1–60 characters | | `subtitle` | string | No | Up to 60 characters | | `caption` | string | No | Up to 60 characters | | `trailing_caption` | string | No | Up to 60 characters | | `url` | string | No | Credential-free HTTPS card destination | | `options` | option\[] | No | Up to 2 actions | | `fallback` | string | No | Synthesized when omitted | Card options use all [common option fields](/components#common-option-fields), including the optional 72-character description. ## Examples per state | State | Repository artifact | | ------------------------ | ------------------------------------------ | | Send request | `examples/components/card-send.json` | | Canonical stored message | `examples/components/card-stored.json` | | Tap-result user message | `examples/components/card-tap-result.json` | ## Constraints | Violation | Result | | ------------------------------------------- | --------------------- | | Missing, empty, or over-60-character title | `422 invalid_request` | | Subtitle or caption over 60 characters | `422 invalid_request` | | More than 2 options | `422 invalid_request` | | Both or neither media source inside `media` | `422 invalid_request` | | Non-HTTPS media or card URL | `422 invalid_request` | | Message targets a group conversation | `422 invalid_request` | ## iOS rendering notes Render `ComponentCard` at normal message width with slots for media, title, subtitle, captions, and actions. * Reserve the media slot while an image resolves, then collapse it if metadata has no usable URL or the image fails to load. * Keep the card as transcript content, without dashboard chrome. * Use the existing attachment path for `attachment_id` media. * Style link-out options as navigation rather than postback choices. ## See also * [Message components](/components) * [Attachments](/guides/attachments) * [Buttons](/components/buttons) # Confirm component Source: https://docs.relayapp.im/components/confirm Ask for one explicit confirm-or-deny decision in the transcript. ## Abstract Use `confirm` for a consequential binary decision. The two named roles keep confirmation and denial semantics explicit even when the labels are task-specific. ## Preview TODO: add the real iOS lane capture from `/tmp/relay-component-confirm.png`. This slot intentionally has no fabricated screenshot. ## Wire example ```json theme={null} { "send": { "conversation_id": "cnv_01JZC7K4RQ", "parts": [{ "type": "data", "data": { "kind": "confirm", "prompt": "Cancel Tuesday's reservation?", "confirm": { "id": "yes", "label": "Cancel", "style": "danger" }, "deny": { "id": "keep", "label": "Keep it" } } }] }, "stored_part": { "part_index": 0, "type": "data", "data": { "kind": "confirm", "prompt": "Cancel Tuesday's reservation?", "confirm": { "id": "yes", "label": "Cancel", "style": "danger" }, "deny": { "id": "keep", "label": "Keep it" }, "fallback": "Cancel Tuesday's reservation?\n1. Cancel\n2. Keep it" } }, "tap_result": { "parts": [{ "type": "data", "data": { "origin": { "kind": "data_action", "message_id": "msg_01JZM7Q9VN", "part_index": 0, "option_id": "yes", "source_kind": "confirm" }, "option_id": "yes", "label": "Cancel" } }], "fallback_text": "Cancel" } } ``` ## Schema table | Field | Type | Required | Notes | | ---------- | ----------- | -------- | ------------------------ | | `kind` | `"confirm"` | Yes | Selects this renderer | | `prompt` | string | No | Up to 1,024 characters | | `confirm` | option | Yes | Confirmation role | | `deny` | option | Yes | Denial role | | `fallback` | string | No | Synthesized when omitted | Confirm and deny use the [common option fields](/components#common-option-fields), except `description` is not accepted. Their IDs must differ. ## Examples per state | State | Repository artifact | | ------------------------ | --------------------------------------------- | | Send request | `examples/components/confirm-send.json` | | Canonical stored message | `examples/components/confirm-stored.json` | | Tap-result user message | `examples/components/confirm-tap-result.json` | ## Constraints | Violation | Result | | ------------------------------------ | --------------------- | | Missing `confirm` or `deny` | `422 invalid_request` | | Duplicate role IDs | `422 invalid_request` | | Prompt over 1,024 characters | `422 invalid_request` | | Invalid option label, style, or URL | `422 invalid_request` | | Message targets a group conversation | `422 invalid_request` | ## iOS rendering notes Render `ComponentConfirm` as a role-styled pair at message width. Keep short labels side by side, then stack the options for accessibility Dynamic Type or longer labels. Use Relay's danger red for destructive confirmation, keep the denial neutral, and apply the shared selected, disabled, in-flight, and retry states. ## See also * [Message components](/components) * [Agent permission request](/components/agent_permission_request) * [Buttons](/components/buttons) # Message components Source: https://docs.relayapp.im/components/index Send native buttons, selects, cards, confirmations, and agent permission requests as data parts. Message components are recognized `data` parts inside an ordinary Relay message. They stay in the canonical transcript, preserve part order, and use the same idempotent send path as text and media. Components are conversational decisions, not app surfaces: no pagination, toggles, dashboards, or silent interactions. Kinds requiring interactions without a visible transcript artifact are rejected. A vertical stack with up to five options. A collapsed row that opens a native single-select sheet. A slot-based media card with up to two actions. A two-role confirm and deny decision. The permission component already emitted by relayapp integrations. ## One message path An agent sends a component as `{ "type": "data", "data": { "kind": "…" } }`. Relay validates recognized v1 kinds and adds `data.fallback` when it is absent. A tap sends a normal user message whose data part identifies the source option: ```json theme={null} { "parts": [ { "type": "data", "data": { "origin": { "kind": "data_action", "message_id": "msg_01JZM4Q9VN", "part_index": 0, "option_id": "book", "source_kind": "buttons" }, "option_id": "book", "label": "Book it" } } ], "fallback_text": "Book it" } ``` If an option already carries `origin`, as permission options do, the client echoes that object verbatim. `option_ids` (array) is reserved as the future multi-select tap-result shape. Version 1 is single-select; do not emit or interpret `option_ids` yet. ## Common option fields | Field | Required | Contract | | ------------- | -------- | ------------------------------------------------------------------------------------------------------- | | `id` | Yes | 1–200 bytes, unique within the part, `[A-Za-z0-9_.:-]`; this ASCII charset makes bytes equal characters | | `label` | Yes | 1–24 characters | | `description` | No | Up to 72 characters; select rows and card actions only | | `style` | No | `default`, `primary`, or `danger`; at most one `primary` per part | | `url` | No | Credential-free HTTPS link-out; an option with a URL does not send a postback tap | | `disabled` | No | `true` renders the option non-tappable and excludes it from synthesized fallback numbering | Every recognized component prompt is limited to 1,024 characters. A message may contain at most four recognized component data parts, and every data part remains subject to the 16 KB limit. ## Fallback and compatibility Relay synthesizes a missing component fallback from the prompt or title followed by numbered option labels. Clients use that string when they do not support the kind or when `features.postback_interactions` is `false`. The feature flag controls client rendering only. It does not block component sending, validation, or storage. Unknown `data.kind` values pass through unchanged so clients can render `data.fallback` or the message-level `fallback_text`. Consumers must ignore unknown fields inside known kinds. Component parts are valid only in 1:1 threads in v1. The server returns `422 invalid_request` when a message with component parts targets a group conversation. Group tap semantics is an open question for the groups component release. [Quick-reply suggestions](/guides/sending-messages#quick-replies) are a separate feature. Suggestions are transient chips attached to the newest message and send their visible text. Components are durable transcript parts and return an origin-tagged data message. The machine-readable registry is `docs/components/catalog.json`. Each catalog entry points to its standalone schema under `schemas/parts/` and its send, stored, and tap-result examples under `examples/components/`. ## See also * [Sending messages](/guides/sending-messages) * [Buttons component](/components/buttons) * [Select component](/components/select) * [Confirm component](/components/confirm) * [Card component](/components/card) # Select component Source: https://docs.relayapp.im/components/select Open a native single-select sheet from a collapsed transcript row. ## Abstract Use `select` when the user should choose one item from a longer, optionally sectioned list. The transcript shows `button_label`; tapping it opens the native picker sheet. ## Preview TODO: add the real iOS lane capture from `/tmp/relay-component-select.png`. This slot intentionally has no fabricated screenshot. ## Wire example ```json theme={null} { "send": { "conversation_id": "cnv_01JZC7K4RQ", "parts": [{ "type": "data", "data": { "kind": "select", "prompt": "Pick a slot", "button_label": "View slots", "sections": [{ "title": "Tuesday", "options": [{ "id": "tue-2pm", "label": "2:00pm", "description": "45 min" }] }] } }] }, "stored_part": { "part_index": 0, "type": "data", "data": { "kind": "select", "prompt": "Pick a slot", "button_label": "View slots", "sections": [{ "title": "Tuesday", "options": [{ "id": "tue-2pm", "label": "2:00pm", "description": "45 min" }] }], "fallback": "Pick a slot\n1. 2:00pm" } }, "tap_result": { "parts": [{ "type": "data", "data": { "origin": { "kind": "data_action", "message_id": "msg_01JZM5Q9VN", "part_index": 0, "option_id": "tue-2pm", "source_kind": "select" }, "option_id": "tue-2pm", "label": "2:00pm" } }], "fallback_text": "2:00pm" } } ``` ## Schema table | Field | Type | Required | Notes | | -------------------- | ---------- | -------- | ------------------------------ | | `kind` | `"select"` | Yes | Selects this renderer | | `prompt` | string | No | Up to 1,024 characters | | `button_label` | string | Yes | 1–20 characters | | `sections` | section\[] | Yes | 1–5 sections, 12 options total | | `sections[].title` | string | No | Up to 24 characters | | `sections[].options` | option\[] | Yes | Non-empty within each section | | `fallback` | string | No | Synthesized when omitted | Select options use all [common option fields](/components#common-option-fields), including the optional 72-character description. ## Examples per state | State | Repository artifact | | ------------------------ | -------------------------------------------- | | Send request | `examples/components/select-send.json` | | Canonical stored message | `examples/components/select-stored.json` | | Tap-result user message | `examples/components/select-tap-result.json` | ## Constraints | Violation | Result | | ---------------------------------------- | --------------------- | | No sections or more than 5 | `422 invalid_request` | | Empty section option list | `422 invalid_request` | | More than 12 options across all sections | `422 invalid_request` | | `button_label` over 20 characters | `422 invalid_request` | | Section title over 24 characters | `422 invalid_request` | | Description over 72 characters | `422 invalid_request` | | Message targets a group conversation | `422 invalid_request` | ## iOS rendering notes Render `ComponentSelect` as one collapsed row at message width. Present a native single-select `.sheet` on tap. After a successful choice, show the selected label, disable other choices, and preserve the selection in the transcript. ## See also * [Message components](/components) * [Buttons](/components/buttons) * [Sending messages](/guides/sending-messages) # Core concepts Source: https://docs.relayapp.im/concepts Relay agents, conversations, messages, parts, and delivery semantics. ## Agents and users A **user** is a person using Relay. An **agent** is the identity they message; its external backend supplies the model, tools, and memory. See [Your agent](/guides/your-agent) for creation, identity, distribution, and installation. ## Conversations and participants A conversation has a stable `conversation_id` and a set of participants. A direct conversation normally contains one user and one agent. The backend can send only while its agent is an active participant and installed for that user. Use the `conversation_id` from `message.received` for replies and history reads. See [Conversation lifecycle](/guides/conversation-lifecycle) for how direct threads begin, remain authorized, and recover. ## Messages and ordered parts A message carries an ordered `parts[]` array. Relay assigns each stored part a zero-based `part_index`. Keep the array order: it is presentation order and provides a stable target for replies and future interactions. | Type | Shape | Meaning | | -------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | `text` | `{ "type": "text", "text": "Hello" }` | Text content. | | `link_preview` | `{ "type": "link_preview", "url": "https://example.com" }` | An HTTPS URL rendered as a native preview card. | | `data` | `{ "type": "data", "data": { ... } }` | Structured JSON content. | | `media` | `{ "type": "media", "url": "https://…" }` or `{ "type": "media", "attachment_id": "att_..." }` | Media by public URL or attachment reference. Exactly one of the two. | | `voice_memo` | `{ "type": "voice_memo", "url": "https://…" }` or `{ "type": "voice_memo", "attachment_id": "att_..." }` | Recorded audio rendered in the inline player. Exactly one source. | The send route accepts 1–32 parts. Text is limited to 8 KB per part and data parts to 16 KB. Upload files up to 100 MB to create an `attachment_id`, or send media and voice memos by public HTTPS `url`. Stored messages include: ```json theme={null} { "id": "msg_01JZM3T8AH", "conversation_id": "cnv_01JZC7K4RQ", "sequence": 7, "sender": { "kind": "agent", "id": "agt_01JZRELAY" }, "parts": [ { "part_index": 0, "type": "text", "text": "Here is the link:" }, { "part_index": 1, "type": "link_preview", "url": "https://example.com" } ], "reply_to": null, "fallback_text": "Here is the link:", "status": "sent", "created_at": "2026-07-12T01:21:08.000Z" } ``` `fallback_text` is the plain-language representation used by notifications, search, and clients that cannot render a part. Relay always derives it: from the first text part, then the first link preview, then the first data part's `fallback`, then `Voice memo` or `[attachment]`. ## Edit and unsend Only a message's original sender can mutate it. Use `PATCH /v1/messages/{message_id}` within 15 minutes to replace all parts, up to five times. Edits must remain text-bearing and cannot involve attachment parts. Each successful edit preserves the prior canonical parts and fallback text in `revisions`, sets `edited_at`, and keeps the original conversation sequence. Use `DELETE /v1/messages/{message_id}` within two minutes to unsend. Relay removes the stored parts but keeps a bare `status: "deleted"` tombstone with the original id, sequence, sender, and creation time. Neither operation sends a push notification. Agent-sent messages use the sending agent's token. User-sent messages use the sending user's Relay session. Every other actor receives `404`. ## Delivery and recovery `message.sequence` orders messages inside one conversation. A webhook `event_id` identifies one durable change for deduplication. They are different values. Relay delivers webhook events at least once and accepts message writes idempotently. See [Delivery model](/guides/delivery-model) for webhook acknowledgement, deduplication, receipt, and recovery rules. Socket mode and calls are specified for Relay and ship after the v0 developer API. See [API availability](/roadmap) for the full matrix. ## Next steps * [Quickstart](/quickstart) * [Delivery model](/guides/delivery-model) * [Sending messages](/guides/sending-messages) * [Event types](/reference/events) # Current status Source: https://docs.relayapp.im/current-status What Relay can prove today, what the developer preview exposes, and what must work before beta. Relay has a substantial backend, native iOS client, public API contract, and developer documentation. **The remaining work before beta is deploying and production-proving the multi-user messenger.** Route existence, local implementation, and a real user outcome are three different levels of proof. Every status claim in these docs uses one of the labels below. ## Status labels | | Label | Meaning | | :-: | ------------------------------ | ------------------------------------------------------------------------------------- | | ✅ | Production-proved | The complete user and backend flow has passed in the named production environment. | | 🔵 | Available in developer preview | The public route and schema exist. Not a claim about the consumer flow in production. | | ⚠️ | Proved locally | Implementation and local or simulated checks pass. Not shipped. | | ⏳ | Coming later | Specified or intended, unavailable in the current public product. | ## Built and available | | Area | | :-: | ---------------------------------------------------------------------------------------------------------------------------------------- | | ✅ | Postgres-backed canonical messages with ordered typed parts | | ✅ | Transactionally assigned message ordering and idempotent writes | | ✅ | Durable user and agent event logs | | ✅ | Consumer sync and WebSocket routes | | ✅ | Native iOS inbox, conversation, onboarding, profile, composer, and notification paths | | ✅ | Live text and model responses | | ✅ | Agent creation with a one-time Agent Token, plus rotation | | ✅ | Versioned REST API, OpenAPI contract, and public documentation | | ✅ | Canonical production API and documentation domains | | ✅ | TestFlight distribution and its release automation | | ✅ | Public quickstart proved end to end against production: fresh agent, signed webhook, receive-and-reply, idempotent retry (July 26, 2026) | The [developer preview availability matrix](/roadmap) lists the current API surface in detail. ## Open proof gaps | | Gap | | :-: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ⏳ | Production phone OTP, blocked until Twilio is fully activated | | ⚠️ | Local SQLite persistence and cursor recovery, pending physical-device recovery proof | | ⚠️ | Attachments, voice memos, link previews, reactions, discovery, installation, removal, blocking, reporting, and account deletion: implemented, pending deployment and production proof | | ⏳ | Production push, monitoring, backups, and the full core-path release proof | | ⏳ | A new production user may still see an empty public agent roster | ## The first release gate The first credible release is an invite-only beta for roughly 20–50 people. It requires all of: Working phone authentication and account deletion. A few dependable curated agents with explicit installation and removal. Reliable text threads, canonical streaming finalization, local persistence, cursor recovery, and visible send states. Production push, blocking, reporting, privacy, support, monitoring, backups, automated core-path checks, and TestFlight distribution. The user gate is simple to state and hard to prove: > A new user can install Relay, verify their phone, add and message a useful > agent, receive replies and notifications, go offline, relaunch or reinstall, > and recover the exact same conversation without assistance. The developer gate is equally concrete: > A developer can create an agent, copy its one-time token, receive a message > event, return a finalized reply or supported native stream, and see the user > receive one canonical message by following the public docs exactly. ## Deliberately later Proving the reliable text relationship comes first. These are intentionally out of scope until it holds: an open marketplace, payouts and subscriptions, automatic routing, production voice and video calls, payments, location, general interactive cards, and Android. ## See also * [Developer preview availability](/roadmap) * [Why Relay](/why-relay) * [Compare your options](/alternatives) # Relay for developers Source: https://docs.relayapp.im/developers/index Add Relay as a consumer messaging channel to an agent backend you already run. Relay gives independently hosted AI agents a native place to message users. Relay handles agent profiles, conversations, delivery, sync, the native app, installation, and safety controls; your backend handles the model, tools, external memory, behavior, hosting, and operations. The developer opportunity follows from the consumer product: people get one trusted place for distinct agents, while each operator can reach that relationship without shipping another complete mobile client. **Building with an AI agent?** Feed these docs straight to your LLM or coding agent: [`llms-full.txt`](https://docs.relayapp.im/llms-full.txt) is the complete documentation in one file, and [`llms.txt`](https://docs.relayapp.im/llms.txt) is a concise index. More in [Build with AI](/ai). The API is in developer preview. The published quickstart is proved end to end against production, and the consumer messenger is still being production-proved for multi-user use. [See the current status](/current-status). ## Add Relay as another channel Keep the agent backend and channels you already run. Register one Relay webhook and send the same handler's output back through plain HTTPS. Register a signed webhook, receive one message, and reply with one request. Receive signed HTTPS events with durable retries. `event_id` makes redelivery safe. Send ordered text, media, voice memos, rich link previews, and structured data. Reply to a message or target one part of it. Pipe a supported UI message stream in one request. Relay stores one final message. Machine-readable docs, the MCP server, and a paste-ready integration brief for your coding agent. Bridge Claude Code, Codex, or Hermes to your phone through a Relay agent. Verify signed webhooks and stream `streamText` output as one canonical message. Start by [receiving and replying to one message](/quickstart), then connect the same handler your other messaging channels already use. Read the [data model](/concepts) when you need richer message parts. **Building with a coding agent?** Give it [`llms-full.txt`](https://docs.relayapp.im/llms-full.txt), the [integration brief](/ai), or Relay's docs MCP server. ## Current preview boundary The current developer preview is webhooks plus the REST API. Socket mode, agent-initiated group management, and calls ship later. See [developer preview availability](/roadmap) for the full matrix. ## Next steps * [Quickstart](/quickstart) * [Create and connect an agent](/guides/your-agent) * [Authentication](/authentication) * [API overview](/api-reference/overview) # Attachments Source: https://docs.relayapp.im/guides/attachments Upload a file and reference it from a media or voice-memo part. Upload a file, then reference its `attachment_id` in a `media` or `voice_memo` part. ## Upload Send the raw bytes as the request body (up to **100 MB**): ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/attachments" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: image/png" \ -H "X-Relay-Filename: chart.png" \ --data-binary @chart.png ``` `201 Created`: ```json theme={null} { "attachment": { "id": "att_01KXGWNZFRF5BH4959T6JYD9SM", "url": "https://api.relayapp.im/v1/attachment-content/eeQPcHI4…", "content_type": "image/png", "size_bytes": 48213 } } ``` `Content-Type` sets the stored MIME type (defaults to `application/octet-stream`); `X-Relay-Filename` names the file for downloads. Uploads accept up to 100 MB; a presigned two-step flow is on the [roadmap](/roadmap). ## Send it Reference the attachment in a part. Relay stores its download URL on the part so clients and history reads can render it directly: ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/messages" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: media-evt_01JZE9M2XW" \ -d '{ "conversation_id": "cnv_01JZC7K4RQ", "parts": [ { "type": "text", "text": "Here is the chart:" }, { "type": "media", "attachment_id": "att_01KXGWNZFRF5BH4959T6JYD9SM" } ] }' ``` Use `"type": "voice_memo"` for native inline playback. The uploaded attachment needs an `audio/*` content type. To send an already-hosted file, pass its public HTTPS `url` instead of `attachment_id`. ## Ownership Attachments belong to their uploader. Referencing another agent's `attachment_id` returns `422 invalid_request`; reading its metadata returns `404 not_found`. ## Check an upload ```bash theme={null} curl -sS "https://api.relayapp.im/v1/attachments/att_01KXGWNZFRF5BH4959T6JYD9SM" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" ``` The response includes `state` (`pending`, `available`, or `failed`), `content_type`, `size_bytes`, and the download `url`. Only `available` attachments can be sent. ## Receiving media Incoming `media` and `voice_memo` parts carry a capability `url` that can be downloaded directly. ## Next steps * [Voice memos](/guides/voice-memos) * [Sending messages](/guides/sending-messages) * [Capability URLs and retention](/reference/data-and-permissions) # Text your coding agent Source: https://docs.relayapp.im/guides/coding-agents Connect Claude Code, Codex, Hermes Agent, or OpenClaw to Relay and drive it from your phone. `relayapp` connects an agent running on your computer to a Relay conversation. Relay carries messages, approval cards, and final replies; Claude Code, Codex, Hermes Agent, or OpenClaw keeps running locally with its own credentials, tools, files, and session state. ## The 60-second path ```bash theme={null} npm install -g relayapp@0.2.0 relayapp pair ``` Scan the terminal QR code or enter its short code in Relay. Pairing creates a normal Relay agent, returns its Agent Token only to this computer, and pins your Relay user id as the only default sender allowed to drive it. ```bash theme={null} cd ~/code/my-project relayapp start --engine claude # or: --engine codex # or: --engine hermes ``` Claude and Codex use adapters bundled at exact versions in `relayapp`. Hermes uses the `hermes acp` command already installed on your machine; check it first with `hermes acp --check`. A message, or a quick burst of messages, becomes one turn. Relay shows the agent typing while it works, posts one final reply, and presents supported tool permission requests as owner-only Allow/Deny cards. ## Four supported integrations | Integration | Relay path | Best when | | ------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Claude Code | `relayapp start --engine claude` over ACP, or the native Claude channel plugin | You want a headless bridge or messages pushed into an already-open Claude session | | Codex | `relayapp start --engine codex` over ACP, plus optional notify/MCP/permission hooks | You want a headless conversation or project-scoped Relay tools in normal Codex runs | | Hermes Agent | `relayapp start --engine hermes` over `hermes acp` | You already run Hermes locally and want its persisted sessions and permission flow in Relay | | OpenClaw | Native OpenClaw channel installed by `relayapp install-openclaw` | OpenClaw owns the agent runtime and Relay should be one of its messaging channels | OpenClaw is intentionally not an ACP engine preset. Its channel plugin runs inside OpenClaw's gateway lifecycle and uses OpenClaw's routing, session, and durable outbound APIs. ## Claude Code ### Headless ACP bridge ```bash theme={null} relayapp start --engine claude --dir ~/code/my-project ``` `relayapp` launches its exact bundled `@agentclientprotocol/claude-agent-acp` entrypoint directly with Node, without a shell or a mutable `npx latest` lookup. Conversation-to-session bindings survive bridge restarts when the adapter supports session loading. Claude must already be authenticated on the computer. Interactive terminal authentication and form elicitation are not proxied through Relay. ### Native channel plugin ```bash theme={null} relayapp install-claude claude --dangerously-load-development-channels plugin:relay@relayapp-bundled ``` The installer validates the bundled marketplace, copies it into the paired account's private runtime directory, installs `relay@relayapp-bundled`, and writes the token, API origin, and owner pin to an owner-only channel `.env`. It refuses to replace a different configured identity. Claude Code channels are a research preview. Custom channels require the development-channel flag unless an organization explicitly allowlists the plugin; Team and Enterprise organizations must also enable channels. Channel events arrive only while that Claude session remains open. The plugin follows Claude's MCP channel contract: it emits `notifications/claude/channel`, exposes retry-safe `reply` and `acknowledge` tools, and relays permission notifications. Claude currently supplies only a bounded permission input preview. Relay offers remote Allow only when that preview is complete JSON below Claude's truncation boundary; otherwise the phone can deny and approval must happen locally. ## Codex ### Headless ACP bridge ```bash theme={null} relayapp start --engine codex --dir ~/code/my-project ``` The exact bundled `@agentclientprotocol/codex-acp` adapter drives Codex's app-server protocol. Relay preserves streamed text, per-conversation session bindings, cancellation, and permission options. For file-change approvals, the bridge combines Codex's earlier tool-call diff with the later permission request so the phone sees the exact old and new text. If the operation cannot be represented in full, Relay denies instead of offering a blind Allow. ### Project-scoped Codex integration ```bash theme={null} cd ~/code/my-project relayapp install-codex ``` This merges without clobbering user configuration: * `[mcp_servers.relay]` and a completion `notify` command in `~/.codex/config.toml`; * a `PermissionRequest` hook in `~/.codex/hooks.json`; * a local opt-in for this project root in `~/.relayapp/codex-notify.json`. Other projects remain suppressed until you opt them in separately. Codex may ask you to trust a newly installed hook handler. MCP sends require a caller-stable `send_id`, so an unknown-outcome retry can reuse the same Relay idempotency key without posting twice. ## Hermes Agent Install and authenticate Hermes using its official setup, then verify its ACP surface before starting Relay: ```bash theme={null} hermes acp --check relayapp start --engine hermes --dir ~/code/my-project ``` Relay launches `hermes acp` shell-free and leaves Hermes configuration, provider credentials, and `~/.hermes/state.db` under Hermes ownership. The current Hermes ACP server supports persisted session load and resume, streamed assistant and reasoning content, tool activity, diffs, cancellation, plan updates, and permission choices. Hermes waits 60 seconds for an ACP permission decision, so Relay uses a 55-second phone window and denies on timeout or transport failure. The reference implementation audited for this release is Hermes Agent 0.18.2. `relayapp doctor` reports the installed version and runs `hermes acp --check`; the readiness check, rather than an unverified version-string cutoff, decides whether the local installation can start. Installing and upgrading Hermes stays entirely in your hands. ## OpenClaw ```bash theme={null} npm install -g relayapp@0.2.0 relayapp pair relayapp install-openclaw ``` The installer persists the bundled OpenClaw plugin archive, runs OpenClaw's plugin installer, adds only the Relay plugin/channel fields to `~/.openclaw/openclaw.json`, and stores the token in an owner-only file. Unrelated OpenClaw configuration is preserved and a different existing Relay identity is never overwritten. The plugin follows OpenClaw's current channel SDK: setup discovery stays network-free, installed packages load built JavaScript runtime entrypoints, inbound events use stable ingress and envelope helpers, and replies use the durable outbound path. Its present product scope is direct conversations and text. Media is represented by a placeholder until Relay exposes agent attachment downloads; reactions and receipts do not start agent turns. ## Pairing and credential boundary Pairing is a device-code flow: 1. The CLI creates a short-lived pairing and long-polls it with a poll token. 2. Claiming creates a Relay agent owned by your account. 3. Relay returns that agent's Agent Token only to the waiting CLI. 4. The CLI stores the token with owner-only permissions and records the owner user id returned by `GET /v1/agents/me`. Relay never receives your engine provider keys, SSH keys, or full parent process environment. ACP subprocesses receive only platform basics, engine/provider variables for the three supported engines, and variables you explicitly add through `RELAYAPP_ENGINE_ENV`. ## Approval and delivery safety * Only the pinned Relay owner can create prompts or answer approval cards by default. A non-owner message is dropped before its content is interpreted. * Each approval is a durable create-once file. The exact operation must fit in the security preview; incomplete or oversized operations are denied. * The event cursor advances atomically with the durable pending queue. A crash cannot acknowledge an event that was never recorded. * Agent/tool turns are at-most-once. A durable attempt marker prevents a crash from silently repeating a deploy, deletion, command, or external send. * Completed replies use a durable outbox and stable idempotency key, so delivery can retry without rerunning the agent turn. * Event long-polling is exclusive per Agent Token and mutually exclusive with webhooks. Give every active consumer its own Relay agent/token. ## Diagnose ```bash theme={null} relayapp doctor ``` `doctor` verifies Node, pairing and owner pinning, token permissions, API reachability, durable state, exact Claude/Codex adapter entrypoints, and the installed Hermes binary/readiness check. A `401` is terminal and requires re-pairing; it is never retried forever. ## Local files ```text theme={null} ~/.relayapp/config.json paired token, origin, owner (0600) ~/.relayapp/codex-notify.json project roots opted into Codex notify ~/.relayapp/accounts//state.json durable cursor, queue, outbox ~/.relayapp/accounts//approvals/ one file per approval ~/.relayapp/accounts//sessions.json conversation to ACP sessions ~/.relayapp/accounts//mcp-sends/ durable Codex MCP logical sends ~/.relayapp/accounts//installed-plugins/ stable plugin copies ``` ## Next steps * [Delivery model, including long polling](/guides/delivery-model) * [Streaming replies](/guides/streaming) * [Authentication](/authentication) # Conversation history Source: https://docs.relayapp.im/guides/conversation-history Read a conversation's messages for context with cursor pagination. Read conversation history after a restart, on a cold start, or when rebuilding a prompt window. Relay creates or reuses the direct thread when a user installs the agent. See [Conversation lifecycle](/guides/conversation-lifecycle) for membership, removal, and reinstall behavior. ```bash theme={null} curl -sS "https://api.relayapp.im/v1/conversations/cnv_01JZC7K4RQ/messages?limit=50" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" ``` Messages return **newest first** with ordered `parts[]`, `sequence`, `reply_to`, reactions, revision history, and a receipt-projected `status` (`sent`, `delivered`, or `read`): ```json theme={null} { "messages": [ { "id": "msg_01JZM4Q9VN", "conversation_id": "cnv_01JZC7K4RQ", "sequence": 2, "sender": { "kind": "agent", "id": "agt_01JZRELAY" }, "parts": [{ "part_index": 0, "type": "text", "text": "Tomorrow at 2:00 PM works." }], "reply_to": { "message_id": "msg_01JZM3T8AH", "part_index": 0 }, "reactions": [], "fallback_text": "Tomorrow at 2:00 PM works.", "status": "read", "edited_at": "2026-07-12T01:21:12.000Z", "revisions": [ { "parts": [{ "part_index": 0, "type": "text", "text": "Tomorrow at 1:00 PM works." }], "fallback_text": "Tomorrow at 1:00 PM works.", "replaced_at": "2026-07-12T01:21:12.000Z" } ], "created_at": "2026-07-12T01:21:08.000Z" } ] } ``` `revisions` is ordered from oldest to newest and is empty for a message that has never been edited. Each entry preserves the prior canonical parts and fallback text. `edited_at` is present after the first edit. An unsent message keeps its place in sequence order but projects as a bare tombstone: ```json theme={null} { "id": "msg_01K1M9UNSENT", "conversation_id": "cnv_01JZC7K4RQ", "sequence": 3, "sender": { "kind": "user", "id": "usr_01JZU1F0BD" }, "status": "deleted", "created_at": "2026-07-12T01:21:10.000Z" } ``` Tombstones do not expose parts, fallback text, reactions, or revisions. ## Pagination `limit` accepts 1–100 and defaults to 50. To page backward, pass your lowest `sequence` as `before_sequence`. ```bash theme={null} curl -sS ".../messages?before_sequence=2&limit=50" ... ``` An empty `messages` array means you have reached the start of the conversation. ## Notes Message `status` includes the delivery and read watermark projected for the message's recipients. See [Read receipts](/guides/read-receipts) for the lifecycle. * History is available while the agent participates in the conversation (`403 forbidden` otherwise). * `sequence` orders messages *within one conversation*. It is unrelated to a webhook `event_id`. * History is the recovery path after an event gap or uncertain delivery. ## Next steps * [Read receipts](/guides/read-receipts) * [Delivery model](/guides/delivery-model) * [Conversation lifecycle](/guides/conversation-lifecycle) # Conversation lifecycle Source: https://docs.relayapp.im/guides/conversation-lifecycle Understand how a direct conversation begins, remains active, and is recovered. A conversation is the durable thread between a Relay user and an agent. The current developer preview supports direct conversations initiated inside Relay. ## How a conversation begins Relay creates or reuses the direct conversation when a user installs the agent. When that user sends a message, the agent receives `message.received` with the stable `conversation_id`: ```json theme={null} { "event_type": "message.received", "data": { "message": { "id": "msg_01JZM3T8AH", "conversation_id": "cnv_01JZC7K4RQ", "sequence": 1 } } } ``` Store the ID and pass it back unchanged when sending, typing, marking read, or reading history. ## Active conversation A direct conversation contains one user and one agent. Relay serializes its messages with a dense, increasing `sequence`. Relay checks both conversation membership and the user's active installation before accepting a send. A `403 forbidden` response here usually means the agent is no longer a participant or installed. Keep each send and reply target within the relationship that produced the conversation ID. ## Remove and return | Action | Effect on the conversation | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Remove an ordinary agent | The installation ends; the backend can no longer add messages | | Add the agent again | Relay reuses the existing direct conversation, never a second one | | Block any agent | Removes the active installation, including for a required built-in agent, and suppresses required-distribution reconciliation while the block exists | | Built-in **Relay** agent | Has no ordinary Remove action | ## Recover the thread Two identifiers do different jobs. Keep them separate when storing and recovering state. | Identifier | Answers | | ---------- | ---------------------------------------------------- | | `event_id` | Have I already processed this durable change? | | `sequence` | Where does this message sit inside one conversation? | After a restart or an uncertain webhook attempt, rebuild from [conversation history](/guides/conversation-history). Your registered endpoint keeps receiving new events automatically. Conversation listing, backend-created conversations, and agent-initiated group management are not available in the current developer preview. Groups themselves are live: people create them in the app, and your backend receives invocations and lifecycle events. See [API availability](/roadmap). ## Next steps * [Conversation history](/guides/conversation-history) to rebuild a thread * [Delivery model](/guides/delivery-model) for ordering and recovery rules * [Create and connect an agent](/guides/your-agent) for installation behavior # Delivery model Source: https://docs.relayapp.im/guides/delivery-model How Relay stores messages, delivers events, prevents duplicates, and recovers after failure. Relay separates canonical conversation state from live presentation. Knowing which is which tells you what you can rely on after a failure. | State | Durable | Examples | | --------- | :-----: | ------------------------------------------------------------------------------ | | Canonical | ✅ | Final messages, ordered history, webhook events, reactions, receipt watermarks | | Live | ❌ | Typing indicators, in-flight stream deltas | ## Incoming messages When a user sends a message, Relay commits it to the conversation before adding `message.received` to the agent's durable event log. An agent consumes that log through exactly one transport. | Transport | Use it when | | ----------------------------------- | -------------------------------------------------------- | | [Signed webhooks](/guides/webhooks) | Your backend can accept public inbound HTTPS | | Long polling | Your backend cannot, such as a local coding-agent bridge | The two transports are mutually exclusive. Polling while a webhook is enabled returns `409 conflict`. ### Signed webhooks The event and one outbox row per matching active endpoint are written in the same transaction. The exact JSON body is signed and sent to your registered HTTPS URL. Verify the signature before parsing, then deduplicate on `event_id`. Enqueue the event and return `2xx` quickly. Delivery is at least once, so an event may arrive again after a timeout or an ambiguous response. A successful `message.received` webhook advances the agent's delivered watermark; mark it read separately once your backend has consumed it. ### Long polling `GET /v1/events` returns events strictly after the supplied cursor, plus a `next_cursor`. ```bash theme={null} curl -sS "$RELAY_API_URL/v1/events?cursor=1042&timeout=30" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" ``` | Parameter | Behavior | | --------- | ------------------------------------------------------------------------ | | `cursor` | Returns events strictly after this sequence | | `timeout` | 0 to 30 seconds. `timeout=0` returns immediately with any pending events | Persist the complete returned page and its `next_cursor` atomically before the next request. Supplying that cursor on the next request acknowledges everything through it and governs redelivery. The sender-visible delivered receipt advances as soon as Relay hands the page to the consumer. The durable acknowledgement remains the redelivery watermark. Cursors are scoped to the agent, not the token. Rotating an Agent Token never resets the ledger. #### Long-poll errors | Status | Code | What happened | What to do | | ------ | ------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `422` | `invalid_request` | Your cursor is ahead of Relay's delivered ledger | Resume from `error.details.highest_delivered_cursor` after reconciling history over REST | | `409` | `terminated_by_other_consumer` | A newer poll took over the token | Run exactly one consumer per Agent Token | | `409` | `conflict` | A webhook is enabled | Disable the webhook or use it instead | | `410` | `cursor_expired` | The cursor is behind the seven-day retention ceiling | Stop and reconcile from history. Do not reset to zero | ## Outgoing messages `POST /v1/messages` commits the message and returns `202 Accepted`. That means Relay accepted the canonical write, not that the user has received or read it. Every send requires an `Idempotency-Key`. | Reuse | Result | | --------------------------- | -------------------------------- | | Same key, same request | The original message is returned | | Same key, different request | `409 idempotency_conflict` | Derive the key from the incoming `event_id`. A redelivered event then produces the same write instead of a duplicate reply. ## Delivery and read state Receipts are monotonic watermarks through a conversation sequence: ```text theme={null} sent → delivered → read ``` Relay emits `message.delivered` and `message.read` only when the corresponding watermark advances. Read implies delivered. Conversation history projects those watermarks onto each message. ## Live state Typing indicators are pushed to active devices and never enter the durable event log. A native UI message stream writes no partial transcript rows; its semantic finish commits the authoritative message and sends one notification. If generation aborts, errors, or disconnects before completion, Relay commits nothing. Retry the complete stream with the same idempotency key. ## Recovery rule Events tell your backend what changed. Conversation history is the source of truth for what the thread contains. After any uncertainty, reconcile from history rather than reconstructing state from delivery attempts or retry responses. ## Next steps * [Webhooks](/guides/webhooks) for registration, verification, and rotation * [Event types](/reference/events) for every payload shape * [Read receipts](/guides/read-receipts) for the action and lifecycle * [Conversation history](/guides/conversation-history) for reconciliation * [Errors](/reference/errors) for the full status and code table # Group conversations Source: https://docs.relayapp.im/guides/group-conversations Receive explicit invocations in a group, reply to them, and follow membership changes. People create groups in the Relay app and add agents to them. Your backend receives an event only when a human explicitly invokes your agent, and replies to that invocation with the ID Relay supplied. ## The invocation boundary Group membership is not transcript authority. This is the single rule that shapes everything else on this page. | Your backend receives | Your backend does not receive | | ------------------------------------------ | ------------------------------------ | | Messages that explicitly invoke your agent | Ambient group conversation | | Your agent's own replies, in history | Messages between other participants | | Membership and metadata lifecycle events | Any message from before it was added | An agent added to a group yesterday cannot read what the group said this morning. Only invocations reach you. ## Receive an invocation A group `message.received` carries an extra `data.invocation_id` alongside the usual message envelope. ```json theme={null} { "event_type": "message.received", "agent_id": "agt_01JZRELAY", "data": { "invocation_id": "inv_01JZC7INVOKE", "message": { "id": "msg_01JZM3T8AH", "conversation_id": "cnv_01JZC7K4RQ", "sequence": 12, "sender": { "kind": "user", "id": "usr_01JZU1F0BD" }, "parts": [{ "part_index": 0, "type": "text", "text": "@scheduler when are we free?" }], "status": "sent", "created_at": "2026-07-17T12:00:00.000Z" } } } ``` Treat `invocation_id` as required state for the reply. Store it with the `event_id` you are already deduplicating on. ## Reply to an invocation Reply exactly as you would in a direct conversation, and pass the `invocation_id` through. ```bash theme={null} curl -sS -X POST "$RELAY_API_URL/v1/messages" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reply-evt_01JZE9M2XW" \ -d '{ "conversation_id": "cnv_01JZC7K4RQ", "invocation_id": "inv_01JZC7INVOKE", "parts": [{ "type": "text", "text": "Thursday after 4pm works for everyone." }] }' ``` A streaming reply carries it as a **query parameter**, not a body field: ```bash theme={null} curl -sS -X POST \ "$RELAY_API_URL/v1/messages?stream=true&conversation_id=cnv_01JZC7K4RQ&invocation_id=inv_01JZC7INVOKE" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Idempotency-Key: reply-evt_01JZE9M2XW" \ -H "Content-Type: text/event-stream" \ -H "x-vercel-ai-ui-message-stream: v1" \ --data-binary @reply.sse ``` ### Invocation errors | Status | Message | Cause | | --------------------- | --------------------------------------------------------------- | ---------------------------------------------------- | | `403 forbidden` | `group agent replies require invocation_id` | A group reply omitted it | | `403 forbidden` | `group agent streams require invocation_id` | A group stream omitted the query parameter | | `403 forbidden` | `invocation is invalid, completed, or belongs to another agent` | The invocation was already consumed, or is not yours | | `403 forbidden` | `invocation belongs to an ended membership period` | Your agent was removed and re-added since | | `403 forbidden` | `message is outside this agent's invocation scope` | The reply targets content you were never invoked on | | `422 invalid_request` | `invocation_id must be a Relay invocation id` | Malformed ID | An invocation is consumed once. Reuse the same `Idempotency-Key` to retry a reply safely; do not reuse the `invocation_id` for a second, different message. ## Membership and metadata events Relay emits lifecycle events to every active group agent. | Event | Emitted when | | ---------------------- | ----------------------------------------------- | | `conversation.added` | A human adds your agent to a group | | `conversation.updated` | A human renames the group or changes its avatar | | `conversation.removed` | A human removes your agent | Each payload is typed and self-contained: `conversation_id`, the human `actor`, the affected participant when the mutation targets one, the current `membership_version`, a structured `system_mutation` with old and new values, and the canonical system `message`. Full payloads are in [event types](/reference/events). A lifecycle event grants no transcript access. `conversation.added` tells you that you are a member, not what the group has been saying. ## Propose a group `POST /v1/groups/invites` commits one consent card into each target's direct conversation with your agent. Every target must have your agent added, and each person answers for themselves. ```bash theme={null} curl -sS -X POST "$RELAY_API_URL/v1/groups/invites" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: invite-founders-dinner" \ -d '{ "title": "Founders dinner", "user_ids": ["usr_01K1M8ALICE", "usr_01K1M8BOB"], "reason": "You both asked for an introduction" }' ``` The first accept creates the conversation and puts that person in it beside your agent. Every later accept joins the same conversation right away, and the invite stays open for whoever has not answered yet. | Invite `state` | Meaning | Conversation | | -------------- | ------------------------------------------------------- | -------------------------- | | `pending` | Open, nobody has joined yet | Not created | | `active` | Open, at least one person joined | Live, at `conversation_id` | | `completed` | Closed because everyone answered or the deadline passed | Live, at `conversation_id` | | `expired` | Closed with nobody joined | Never created | | Member `state` | Meaning | | -------------- | ---------------------------------------------- | | `invited` | Has not answered yet | | `accepted` | Joined the live conversation | | `declined` | Answered no, and stays out of the conversation | Read one member's `state` to know whether that person is in the conversation. A `conversation_id` on a `pending` invite names the destination group your agent proposed into, so it tells you where the invite leads rather than who consented. | Event | Emitted when | | ------------------------ | ----------------------------------------------------------- | | `group.invite.joined` | One person accepts. Carries `conversation_id` and `user_id` | | `group.invite.declined` | One person declines. Carries `user_id` | | `group.invite.completed` | The invite closes with a live conversation | | `group.invite.expired` | The invite closes with nobody joined | A decline answers for that person alone. It removes nobody from a live conversation and leaves the invite open for everyone still deciding. ## Limits | Limit | Value | | ------------------------------- | ----------------------------------- | | Participants per group | 25, including every human and agent | | Historical participants tracked | 100 | | Group title | 1 to 100 characters | | Agents required per group | At least 1 | | Invocation stream claim | 5 minutes | ## What people do, and what backends cannot Group creation and membership are first-party app actions, authenticated with a person's Relay session. There is no Agent Token route for them. | Action | Who | | ------------------------------------- | --------------------------------------------------------------------------- | | Create a group, set its title | A person, in the app | | Add or remove a participant | A person, in the app | | Rename the group or change its avatar | A person, in the app | | Leave the group | A person, in the app | | Invoke an agent | A person, in the app | | Reply to an invocation | Your backend | | Send a group invite card | Your backend, via `POST /v1/groups/invites`; the person consents in the app | | Accept or decline an invite | A person, in the app | Direct membership writes stay human. A backend can propose with an invite card, and the `group.invite.*` events report each answer and the terminal state, but only a person's consent changes who is in a group. Fuller agent-initiated management is on the [roadmap](/roadmap). ## Next steps * [Event types](/reference/events) for the exact lifecycle payloads * [Sending messages](/guides/sending-messages) for every part type * [Streaming replies](/guides/streaming) * [Developer data access and retention](/reference/data-and-permissions) # Identifying users Source: https://docs.relayapp.im/guides/identifying-users Turn a message's sender.id into the human's name and phone number. Every inbound event already tells you *which* user it came from. A `message.received` event carries the sender under `data.message.sender`: ```json theme={null} "sender": { "kind": "user", "id": "usr_01JZU1F0BD" } ``` That `usr_…` id is stable: the same user always has the same id, in direct chats and in groups. Use it to key your own records. ## Resolve the name and phone To turn the id into something you can greet or match on, call `GET /v1/users/{user_id}` (see the **API Reference** tab): ```bash theme={null} curl https://api.relayapp.im/v1/users/usr_01JZU1F0BD \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" ``` ```json theme={null} { "user": { "id": "usr_01JZU1F0BD", "name": "Rushil Kagithala", "first_name": "Rushil", "last_name": "Kagithala", "phone_number": "+13135550123", "avatar_url": null, "created_at": "2026-07-12T01:20:00.000Z" } } ``` `name` is the canonical stored value. `first_name` and `last_name` are a convenience split of `name` on the first space, so a two-word given name puts its tail in `last_name`. Greet with them, but store `name` if you need the exact value. In a group, read `sender.id` from each `message.received` to tell participants apart: three users in a thread are three distinct `usr_…` ids. ## Scope You can only resolve a user you **currently share an active conversation with** (direct or group). Any other id, one you have no active conversation with or one that does not exist, returns `404`: ```json theme={null} { "error": { "code": "not_found", "message": "user not found" } } ``` The two cases are deliberately indistinguishable, so the endpoint can't be used to enumerate accounts or confirm who owns a phone number. If a user leaves the conversation or removes your agent, the lookup stops resolving them. ## Next steps * [Developer data access and retention](/reference/data-and-permissions) * [Conversation lifecycle](/guides/conversation-lifecycle) * [Trust and data](/trust-and-data) # Reactions Source: https://docs.relayapp.im/guides/reactions Add and remove tapback-style reactions, target a specific part, and receive reaction events. A reaction targets a whole message or one part of it. Relay emits an event when a user adds or removes a reaction from the agent's message. ## Add or remove a reaction Use `operation` to add or remove the reaction: ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/messages/msg_01JZM3T8AH/reactions" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "operation": "add", "type": "love" }' ``` | Field | Values | | ------------ | -------------------------------------------------------------------------------- | | `operation` | `add` \| `remove` | | `type` | `love` \| `like` \| `dislike` \| `laugh` \| `emphasize` \| `question` \| `emoji` | | `emoji` | required if and only if `type` is `"emoji"`. Any single emoji, e.g. `"🔥"` | | `part_index` | optional; omit to react to the whole message | ```bash theme={null} # Arbitrary emoji on part 1 of a multi-part message curl -sS -X POST "https://api.relayapp.im/v1/messages/msg_01JZM3T8AH/reactions" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "operation": "add", "type": "emoji", "emoji": "🔥", "part_index": 1 }' ``` The response returns the stored reaction. Removing a missing reaction or adding an existing one is an idempotent no-op. A reaction outside the agent's conversations returns `403 forbidden`. ## Receiving reaction events When a user reacts to the agent's message, each matching registered webhook receives `reaction.added` or `reaction.removed`: ```json theme={null} { "event_id": "evt_01JZR3ACT10N", "event_type": "reaction.added", "agent_id": "agt_01JZRELAY", "created_at": "2026-07-13T20:02:11.000Z", "data": { "reaction": { "message_id": "msg_01JZM4Q9VN", "part_index": null, "type": "love", "actor": { "kind": "user", "id": "usr_01JZU1F0BD" }, "operation": "add" } } } ``` Use a reaction when acknowledgment is enough, for example, `like` a “thanks” instead of sending another message. ## Next steps * [Event types](/reference/events) * [Sending messages](/guides/sending-messages) * [Message components](/components) # Read receipts Source: https://docs.relayapp.im/guides/read-receipts Mark inbound messages read and follow delivery and read state on your replies. Mark an inbound message read after your backend has consumed it: ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/conversations/cnv_01JZC7K4RQ/read" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "message_id": "msg_01JZM3T8AH" }' ``` ## Receipt watermarks Receipts are monotonic conversation watermarks, not independent flags on each message. | Rule | Behavior | | ---------------------------------------------------- | -------------------------------------------------- | | Marking one message read | Covers every earlier message in the conversation | | Read state | Implies delivered | | Repeating the request, or targeting an older message | Idempotent no-op | | Target | Applies only to a message from another participant | ## Track your replies Relay emits two events as your reply moves through the conversation. | Event | Emitted when | | ------------------- | -------------------------------------------- | | `message.delivered` | A user's runtime accepts the agent's message | | `message.read` | The user reads through it | Both carry `through_sequence`: every message through that sequence has reached the same state for that participant. Conversation history projects the watermark onto each outbound message as `sent`, `delivered`, or `read`. | Use | When | | ---------------------------------------------------- | --------------------------------------- | | Events | Your backend needs to react immediately | | [Conversation history](/guides/conversation-history) | Rebuilding state after a restart | ## Next steps * [Event types](/reference/events) for the canonical receipt payloads * [Webhooks](/guides/webhooks) for signature and redelivery behavior * [Delivery model](/guides/delivery-model) for the full watermark lifecycle # Rich link previews Source: https://docs.relayapp.im/guides/rich-link-previews Render a URL as a native card with page metadata. A `link_preview` part asks Relay to render a URL as a native preview card. The conversation shows page metadata when it can be loaded and retains a tappable URL fallback when it cannot. ## Send a preview ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/messages" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: link-evt_01JZE9M2XW" \ -d '{ "conversation_id": "cnv_01JZC7K4RQ", "parts": [{ "type": "link_preview", "url": "https://example.com/article" }] }' ``` | Constraint | Value | | -------------- | ---------------- | | Scheme | HTTPS | | Maximum length | 2,048 characters | ## Add context before the card Relay preserves ordered parts, so a preview can follow text in the same message: ```json theme={null} { "parts": [ { "type": "text", "text": "This is the source I used:" }, { "type": "link_preview", "url": "https://example.com/article" } ] } ``` Each stored part receives a stable `part_index`, so replies and reactions can target the text or card separately. ## Preview metadata and fallback Relay's iOS app loads page metadata through the native Link Presentation framework. What the reader sees depends on the destination. | Destination | Card | | -------------------------------------------------------------------------------- | ---------------------------------------- | | Public page with a title and image | ✅ Full preview card | | Page that blocks metadata requests, sits behind auth, or is on a private network | ⚠️ Simpler card showing the host and URL | The server never rewrites or shortens the destination. ## Choosing the part type | Part | Use when | | -------------- | -------------------------------- | | `text` | The URL is ordinary message text | | `link_preview` | The card itself is intentional | Rich previews use the normal message response, history, events, idempotency, replies, reactions, and delivery and read receipts. ## Next steps * [Sending messages](/guides/sending-messages) for every part type * [Message components](/components) for interactive alternatives * [Reactions](/guides/reactions) for targeting one part # Sending messages Source: https://docs.relayapp.im/guides/sending-messages Send ordered parts with POST /v1/messages, reply to a message or a part, and make retries safe. Send a message with `conversation_id`, an ordered `parts[]` array, and an `Idempotency-Key`: ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/messages" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reply-evt_01JZE9M2XW" \ -d '{ "conversation_id": "cnv_01JZC7K4RQ", "parts": [ { "type": "text", "text": "Found three options. The best one:" }, { "type": "link_preview", "url": "https://example.com/listing/42" } ] }' ``` Relay returns `202 Accepted` with `message_id` and the stored message. Each stored part includes its assigned `part_index`. ## Part types A message carries 1–32 parts. Order is presentation order. | Type | Shape | Limits | | -------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | | `text` | `{ "type": "text", "text": "Hello" }` | 8 KB per part | | `link_preview` | `{ "type": "link_preview", "url": "https://example.com" }` | public HTTPS URL, up to 2,048 characters | | `data` | `{ "type": "data", "data": { ... } }` | any JSON, 16 KB; recognized component kinds get additional validation | | `media` | `{ "type": "media", "url": "https://…" }` or `{ "attachment_id": "att_…" }` | exactly one of `url` \| `attachment_id` | | `voice_memo` | `{ "type": "voice_memo", "url": "https://…" }` or `{ "type": "voice_memo", "attachment_id": "att_…" }` | exactly one source; `duration_ms` is optional | Upload a file to get an `attachment_id`, or pass a public `url`. See [Attachments](/guides/attachments). `data` parts carry integration-defined JSON such as tool results and artifacts. Relay also recognizes the v1 [message component](/components) kinds: `buttons`, `select`, `card`, `confirm`, and `agent_permission_request`. | Case | Behavior | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | Missing component `data.fallback` | Synthesized before storage | | Unknown kind | Passes through unchanged, so older clients render the fallback instead of dropping the part | | `group_invite` kind | Reserved for cards committed by `POST /v1/groups/invites`; sending it through the ordinary message endpoint returns `422 invalid_request` | Clients that cannot render a part use its component fallback when present, then the message's `fallback_text`, derived from the first text part, first link preview, data fallback, `Voice memo`, or `[attachment]`. Use [Voice memos](/guides/voice-memos) when audio should appear in the inline voice player, and [Rich link previews](/guides/rich-link-previews) when a URL should render as a preview card. ## Quick replies Attach up to 8 `suggestions` to a message when a short list of answers covers what you need next: ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/messages" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reply-evt_01JZE9M2XW" \ -d '{ "conversation_id": "cnv_01JZC7K4RQ", "parts": [ { "type": "text", "text": "Who are you trying to date?" } ], "suggestions": [ { "text": "Women" }, { "text": "Men" }, { "text": "Nonbinary" }, { "text": "Everyone" } ] }' ``` Each suggestion is `{ "text": "…" }` with 1 to 96 characters. | Behavior | Detail | | ------------- | ------------------------------------------------------------------------------------ | | Placement | Chips render above the composer while your message is the newest in the conversation | | Lifetime | They disappear once anything newer arrives | | On tap | The `text` is sent back as an ordinary user text message | | Your handling | A normal [`message.received`](/guides/webhooks) event, no extra code | The user can always type a free-form answer instead. Relay lays the options out for you: a short set renders as inline rows in the transcript, and a longer set collapses into a single card that opens a full-height picker sheet. You don't choose the presentation. Quick-reply suggestions are separate from message components. Suggestions are transient presentation on the newest message and send a text part. Components are durable `data` parts in the transcript and return an origin-tagged data message. ## Message components Use a component data part when the choices must remain attached to their source message or the tap must carry a stable option identity: ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/messages" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: booking-evt_01JZE9M2XW" \ -d '{ "conversation_id": "cnv_01JZC7K4RQ", "parts": [ { "type": "data", "data": { "kind": "buttons", "prompt": "Pick your booking slot", "options": [ { "id": "slot_fri_7", "label": "Friday 7pm", "style": "primary" }, { "id": "slot_sat_8", "label": "Saturday 8pm" } ] } } ] }' ``` Tapping `slot_sat_8` sends a normal user message through the existing message pipeline: ```json theme={null} { "parts": [{ "type": "data", "data": { "origin": { "kind": "data_action", "message_id": "msg_01JZM4Q9VN", "part_index": 0, "option_id": "slot_sat_8", "source_kind": "buttons" }, "option_id": "slot_sat_8", "label": "Saturday 8pm" } }], "fallback_text": "Saturday 8pm" } ``` The agent receives this as the next ordinary `message.received` event. Normal message idempotency covers retries. The client marks the successful choice selected and disables its siblings; your agent should still treat `option_id` as idempotent because stale taps are possible. See the catalog and per-kind wire contracts in [Message components](/components), [Buttons](/components/buttons), [Select](/components/select), [Card](/components/card), [Confirm](/components/confirm), and [Agent permission request](/components/agent_permission_request). ## Replying to a message or a part In a group, Relay invokes an agent only when a human explicitly selects it or replies to one of its messages. The resulting `message.received` event includes an `invocation_id`; echo that value as `invocation_id` in the finalized JSON request, or as the query parameter for a streamed reply. Ambient group context is not delivered to agent backends and does not appear in agent history. `reply_to` targets a whole message or a single part: ```json theme={null} { "reply_to": { "message_id": "msg_01JZM3T8AH" } } { "reply_to": { "message_id": "msg_01JZM3T8AH", "part_index": 1 } } ``` Use the `part_index` from the stored message in `message.received`. Part indexes are dense, zero-based, and stable. ## Idempotency The `Idempotency-Key` header is **required** and accepts 8–255 characters. Derive it from the inbound `event_id`: * Same key + same request → Relay returns the original message instead of sending twice. * Same key + different request → `409 idempotency_conflict`. Generate the key once per logical send and reuse it across retries. ## Long replies Split content longer than the per-part limit across `text` parts or messages. Each part renders as its own bubble. Next: pipe your agent's existing output with [Streaming](/guides/streaming), or react to a user's message with [Reactions](/guides/reactions). ## Next steps * [Streaming replies](/guides/streaming) * [Attachments](/guides/attachments) * [Message components](/components) * [Delivery model](/guides/delivery-model) # Streaming replies Source: https://docs.relayapp.im/guides/streaming Pipe an existing Vercel AI SDK UI message stream into Relay in one request. Relay accepts the output your agent already produces. Send one request with `stream=true` and pipe a Vercel AI SDK `UIMessageStream v1` into its body. Relay consumes the whole stream and commits one canonical message when it finishes. There is no Relay draft to open, append, edit, or clean up. ## Pipe an AI SDK response Use the `conversation_id` from `message.received` and derive the idempotency key from that event's `event_id`. ```ts theme={null} const result = streamText({ model, messages }); const uiResponse = result.toUIMessageStreamResponse(); const relayResponse = await fetch( `${process.env.RELAY_API_URL}/v1/messages?stream=true&conversation_id=${event.data.message.conversation_id}`, { method: "POST", headers: { Authorization: `Bearer ${process.env.RELAY_AGENT_TOKEN}`, "Idempotency-Key": `reply-${event.event_id}`, "Content-Type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1", }, body: uiResponse.body, duplex: "half", }, ); if (!relayResponse.ok) throw new Error(await relayResponse.text()); ``` This is a direct HTTPS integration, not a Relay SDK. Your backend still owns the model, tool loop, prompts, memory, and hosting. ## Wire request The same contract can be exercised without a framework: ```bash theme={null} curl -sS -X POST \ "$RELAY_API_URL/v1/messages?stream=true&conversation_id=cnv_01JZC7K4RQ" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Idempotency-Key: reply-evt_01JZE9M2XW" \ -H "Content-Type: text/event-stream" \ -H "x-vercel-ai-ui-message-stream: v1" \ --data-binary @reply.sse ``` `reply.sse` uses the AI SDK's existing framing: ```text theme={null} data: {"type":"start","messageId":"ui-message-42"} data: {"type":"text-start","id":"answer"} data: {"type":"text-delta","id":"answer","delta":"Tomorrow at "} data: {"type":"text-delta","id":"answer","delta":"2:00 PM works."} data: {"type":"text-end","id":"answer"} data: {"type":"finish","finishReason":"stop"} data: [DONE] ``` Relay returns the normal `202` message response after the canonical commit. ## What Relay preserves | AI SDK part | Relay presentation | | ----------------------------------- | ----------------------------------------------------------- | | Text parts | Canonical `text` parts | | Tool input and output | A transcript `tool_call` data part; Relay never executes it | | URL sources | `link_preview` parts | | Files and document sources | Transcript artifact parts when they have usable metadata | | Reasoning and transient custom data | Remain private to the agent backend | The external agent owns the full loop. Relay is the messaging channel and presentation surface. ## Completion and recovery * `finish` is semantic completion. `[DONE]` only closes the transport; it does not commit a message by itself. * `abort`, `error`, malformed ordering, or an early disconnect writes no transcript row. * A successful stream produces one durable `message.created` event and one notification. * Retry the whole request with the same `Idempotency-Key`. The same completed stream returns the original message; different content returns `409 idempotency_conflict`. * A disconnected app recovers the final message through normal cursor sync and history. Use a separate [typing indicator](/guides/typing-indicators) before the output stream begins when the agent has a long planning or tool phase. ## Next steps * [Sending finalized messages](/guides/sending-messages) * [Delivery model](/guides/delivery-model) * [API overview](/api-reference/overview) # Typing indicators Source: https://docs.relayapp.im/guides/typing-indicators Show live typing state while your backend prepares a reply. Show a typing indicator while your backend is working: ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/conversations/cnv_01JZC7K4RQ/typing" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "started": true }' ``` Relay returns `204 No Content`. The signal is ephemeral: it is pushed to active devices and never enters the durable event log. The agent must be a participant in the conversation. Add a short status line with `label`: ```json theme={null} { "started": true, "label": "Searching the web…" } ``` Labels can contain up to 80 characters. ## Stop typing If no message follows, stop the indicator explicitly: ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/conversations/cnv_01JZC7K4RQ/typing" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "started": false }' ``` Sending a message clears the visible indicator. When a reply may stop before sending, put the stop request in cleanup logic so the conversation returns to its resting state. For a long planning or tool phase, start typing before the output stream and stop when the first visible content arrives. See [Streaming replies](/guides/streaming). ## Next steps * [Streaming replies](/guides/streaming) * [Delivery model](/guides/delivery-model) * [Sending messages](/guides/sending-messages) # Vercel AI SDK Source: https://docs.relayapp.im/guides/vercel-ai-sdk Answer Relay messages from a Vercel AI SDK backend and stream replies as one canonical message. `@relayapp/vercel-ai` turns a [Vercel AI SDK](https://ai-sdk.dev) backend into a Relay agent: it receives signed `message.received` webhooks and forwards `streamText(...)` output to Relay, which commits one canonical message. The plugin is a thin binding of the public HTTPS contract; every raw request it makes is documented in this site and remains available directly. Prerequisites: an agent and its Agent Token ([Create and connect an agent](/guides/your-agent)) and a registered webhook with its signing secret ([Webhooks](/guides/webhooks)). The plugin lives in the public [`relaymessenger/relayapp`](https://github.com/relaymessenger/relayapp) workspace under `integrations/vercel-ai` and publishes to npm with the next `relayapp` release. ## Quickstart ```ts app/api/relay/route.ts theme={null} import { createRelay } from "@relayapp/vercel-ai"; import { streamText } from "ai"; const relay = createRelay({ token: process.env.RELAY_AGENT_TOKEN!, webhookSecret: process.env.RELAY_WEBHOOK_SECRET!, }); export const POST = relay.webhook(async ({ message, typing, reply }) => { await typing(true, "Thinking…"); const result = streamText({ model: "anthropic/claude-sonnet-5", prompt: message.parts.find((p) => p.type === "text")?.text ?? "", }); await reply.stream(result.toUIMessageStreamResponse()); }); ``` Mount the handler as the POST route your webhook registration points at. The handler works in any WinterCG runtime: Next.js route handlers, Cloudflare Workers, or behind a one-line Express or Hono adapter. ## What the plugin enforces Each row is a contract from these docs that the plugin implements for you. | Contract | Plugin behavior | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | [Signature verification](/guides/webhooks) | Standard Webhooks HMAC over the exact raw body; unsigned or stale requests get `401` | | [At-least-once delivery](/guides/delivery-model) | Per-instance `event_id` dedup window, recorded once your handler succeeds, so a failed handler gets its redelivery | | [Idempotent sends](/guides/sending-messages) | Reply helpers derive `Idempotency-Key` values from the event id and call order; Relay replays the original send on any retry, from any instance | | [Group invocations](/guides/group-conversations) | `invocation_id` from the event threads into every reply and typing call | | [Streaming](/guides/streaming) | `reply.stream(...)` forwards the UI message stream to `POST /v1/messages?stream=true`; Relay commits exactly one message | | [Event compatibility](/reference/events) | Unknown event types acknowledge `200` so new Relay events never break your route | ## Configuration | Option | Description | Default | | ------------------ | ---------------------------------------- | ------------------------- | | `token` | Agent Token used for every send | required | | `webhookSecret` | Signing secret from webhook registration | required for `webhook()` | | `baseUrl` | API origin | `https://api.relayapp.im` | | `toleranceSeconds` | Allowed `webhook-timestamp` skew | `300` | | `onError` | Receives background handler failures | log-and-drop | On serverless platforms that support work after the response, pass the platform's `waitUntil` as the second handler argument to acknowledge `202` immediately and keep generating: `relay.webhook(handler)` returns a function of `(request, { waitUntil })`. A thrown handler error returns `500`, so Relay redelivers the event and the derived idempotency keys make the retry safe. ## Handler context | Field | Contents | | --------------------------------------------- | ------------------------------------------------------------ | | `message` | The full stored message with ordered `parts[]` | | `event` | The complete event envelope | | `invocationId` | Present for group deliveries; already threaded into helpers | | `reply.text` / `reply.parts` / `reply.stream` | Idempotent sends bound to the conversation | | `typing(started?, label?)` | Ephemeral typing indicator with an optional status line | | `client` | The underlying `RelayClient` for anything beyond the helpers | ## Next steps * [Streaming](/guides/streaming) for the one-canonical-message contract behind `reply.stream` * [Message components](/components) to send buttons, selects, and cards from `reply.parts` * [Webhooks](/guides/webhooks) for registration, verification, and secret rotation * [Text your coding agent](/guides/coding-agents) for the local-computer integrations # Voice memos Source: https://docs.relayapp.im/guides/voice-memos Send recorded audio with Relay's native inline player. A `voice_memo` part tells Relay that an audio file is a spoken message, not a generic attachment. It appears in the conversation with inline playback, duration, and waveform presentation. ## Send from a public URL ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/messages" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: voice-evt_01JZE9M2XW" \ -d '{ "conversation_id": "cnv_01JZC7K4RQ", "parts": [{ "type": "voice_memo", "url": "https://cdn.example.com/replies/answer.m4a", "duration_ms": 18400 }] }' ``` Use a public HTTPS URL. Relay stores it on the canonical part so history, sync, and message events all carry the same playback source. ## Send an uploaded file Upload the bytes first, then pass the returned `attachment_id`: ```json theme={null} { "conversation_id": "cnv_01JZC7K4RQ", "parts": [{ "type": "voice_memo", "attachment_id": "att_01KXGWNZFRF5BH4959T6JYD9SM", "duration_ms": 18400 }] } ``` The attachment needs to be available, belong to the sender, and declare an `audio/*` content type. See [Attachments](/guides/attachments) for the raw upload flow and 100 MB limit. ## Voice memo or audio attachment | Desired result | Part type | | -------------------------------------- | ------------ | | Spoken message with inline playback | `voice_memo` | | Song, podcast, or arbitrary audio file | `media` | Both use the same file storage. The part discriminator preserves the sender's presentation intent. ## Rules * Pass exactly one of `url` or `attachment_id`. * `duration_ms` is optional and accepts a non-negative integer. * Relay validates the `audio/*` MIME family for uploaded files and does not transcode them. M4A/AAC (`audio/mp4`) is the recommended interoperable format; MP3 and WAV are also playable by Relay's AVFoundation player. * Voice memos use the normal message response, history, replies, reactions, idempotency, and delivery/read receipts. There is no second voice-memo delivery pipeline. Incoming `voice_memo` parts use the same shape and include a downloadable `url`. ## Next steps * [Attachments](/guides/attachments) * [Sending messages](/guides/sending-messages) # Webhooks Source: https://docs.relayapp.im/guides/webhooks Register an HTTPS receiver, verify signatures, and process at-least-once delivery safely. Relay sends each agent event to its registered HTTPS endpoints. The event is committed to Relay's durable log and transactional outbox before delivery begins. ## Register an endpoint ```bash theme={null} curl -sS -X POST "https://api.relayapp.im/v1/webhooks" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://agent.example/webhooks/relay", "events": ["message.received", "reaction.added", "reaction.removed"] }' ``` Production endpoints must use public HTTPS URLs. Relay returns a `whsec_...` signing secret only when the endpoint is created or its secret is rotated. Store it in a secret manager. An agent may register up to five endpoints. When an endpoint is first registered, Relay queues matching events from the preceding 24 hours, capped at 1,000. This closes the setup gap between creating an agent and connecting its backend. ## Verify every request Relay follows the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) signing format: | Header | Value | | ------------------- | ---------------------------------------------------- | | `webhook-id` | The event's stable `event_id` | | `webhook-timestamp` | Unix seconds when this attempt was signed | | `webhook-signature` | One or more space-separated `v1,` signatures | Compute HMAC-SHA256 over this exact UTF-8 value: ```text theme={null} webhook-id.webhook-timestamp.raw-request-body ``` Decode the base64 value after `whsec_` before using it as the HMAC key. Compare signatures in constant time and reject timestamps more than five minutes from the current time. Verify the raw bytes before JSON parsing; reserializing JSON changes the signature. The official Standard Webhooks libraries implement this contract. In JavaScript: ```bash theme={null} npm install standardwebhooks ``` ```ts theme={null} import { Webhook } from "standardwebhooks"; const rawBody = await request.text(); const event = new Webhook(env.RELAY_WEBHOOK_SECRET).verify(rawBody, { "webhook-id": request.headers.get("webhook-id") ?? "", "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "", "webhook-signature": request.headers.get("webhook-signature") ?? "", }); ``` On Cloudflare Agents, verify in `onRequest()`, enqueue the event with the Agent SDK's durable `this.queue()`, and return `202` before model or tool work. The Agent queue persists in that Durable Object and serializes rapid webhook arrivals. ## Acknowledge and deduplicate Return any `2xx` after the event is durably accepted by your backend. Requests time out after 10 seconds. Relay retries timeouts, connection errors, `408`, `429`, and `5xx` responses with exponential backoff and jitter for up to 10 attempts. Every other response, including redirects and other `4xx`, is a permanent failure: the delivery dead-letters immediately with no retry. Delivery is at least once. Store `event_id` as a unique key before producing side effects. Derive outbound message idempotency keys from it, for example `reply:`. A successful `message.received` delivery advances the agent's delivered watermark. Mark the message read only after your backend consumes it. ## Manage endpoints ```bash theme={null} # List. Signing secrets are never returned. curl -sS "https://api.relayapp.im/v1/webhooks" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" # Change URL, event filters, or enabled state. curl -sS -X PATCH "https://api.relayapp.im/v1/webhooks/wh_..." \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"enabled":false}' # Rotate. The new signing secret is returned once; Relay signs with both the # new and previous secret for 24 hours. curl -sS -X POST "https://api.relayapp.im/v1/webhooks/wh_.../rotate-secret" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" # Delete permanently. curl -sS -X DELETE "https://api.relayapp.im/v1/webhooks/wh_..." \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" ``` These are the filters you can subscribe to today: | Event | Fires when | | ---------------------------------------------------------------------- | ---------------------------------------------------- | | `message.received` | A user messages your agent, or invokes it in a group | | `message.edited` | A sender edited a visible message | | `message.unsent` | A sender unsent a message, leaving a tombstone | | `message.delivered` | A recipient's runtime accepted your message | | `message.read` | A recipient read through a sequence | | `reaction.added` / `reaction.removed` | A participant reacted to your message | | `conversation.added` / `conversation.updated` / `conversation.removed` | Group membership or metadata changed | | `group.invite.joined` / `group.invite.declined` | One invited person answered a group invite | | `group.invite.completed` / `group.invite.expired` | A group invite reached its terminal state | There is no component-specific side channel: ordinary component taps arrive as `message.received` events. Group invite consent uses the invite endpoint and reports each individual answer plus the terminal state. Ignore unknown event types. Relay adds them additively, and a receiver that throws on an unrecognized type breaks on the next protocol change. See [Event types](/reference/events) for payloads and [Delivery model](/guides/delivery-model) for the durable-state boundary. ## Next steps * [Event types](/reference/events) * [Delivery model, including long polling](/guides/delivery-model) * [Errors](/reference/errors) # Create and connect an agent Source: https://docs.relayapp.im/guides/your-agent Create an agent in Relay, connect its backend, and understand installation. A Relay agent is the identity people add and message. Your backend supplies its brain; Relay owns its profile, installation, conversation, and delivery. ## Create the agent In Relay, tap **New Message**, then **Create Agent**. Enter a display name and handle. Those are the only creation fields. | Rule | Detail | | ------------------- | --------------------------------------------------------------------------------------- | | Handle format | 3 to 32 lowercase letters, numbers, or underscores, starting with a letter | | Reserved handles | Product and legal route names such as `dashboard`, `mac`, `privacy`, `support`, `terms` | | Starting visibility | Private, until the owner changes it | | On creation | Relay installs the agent for its creator and opens its direct conversation | | Agent Token | Shown exactly once | Creation never asks for a tagline, accent color, model, personality, prompt, backend URL, or hosting provider. Behavior and backend configuration live in your external backend, not in Relay's identity contract. ### Richer creation through the API The app keeps creation to display name and handle on purpose. An authenticated owner can set richer presentation fields in the same request with `POST /v1/me/agents`. | Field | Accepts | | ------------------------------------- | ------------------------------------------------------- | | `avatarUrl`, `tagline`, `accentColor` | Presentation identity | | `capabilities` | `text`, `image`, `voice`, `video`, `files` | | `openingMessage` | The same ordered `parts` a normal Relay message accepts | Read and update those owner-only fields afterwards: ```bash theme={null} curl -sS "$RELAY_API_URL/v1/me/agents/$AGENT_ID/configuration" \ -H "Authorization: Bearer $RELAY_SESSION_TOKEN" ``` Send `{"openingMessage": null}` to `PATCH .../configuration` to disable the opening message for future installs. These endpoints use the owner's Relay session, not the Agent Token. An opening message is sent as the agent exactly once, when a user first installs it. Reinstalling, changing visibility, or changing distribution policy never sends it again, and updating it affects future first installs only. ## Connect the backend The Agent Token authenticates your backend as this agent. Verify it, then use the returned agent ID and handle in your logs and configuration. ```bash theme={null} curl -sS "$RELAY_API_URL/v1/agents/me" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" ``` ## Profile and distribution Every public or unlisted handle owns a profile at `relayapp.im/handle` carrying display identity only. Agent Tokens, owner identity, prompts, model and provider choices, runtime details, backend configuration, and opening-message configuration are never part of a public profile. The owner chooses **Who can message** from the agent profile: | Setting | Store | Exact handle and share link | Installable by | | ------------ | :----------------------------------------------: | :-------------------------: | ------------------------------ | | **Public** | ✅ after Relay approves and publishes the listing | ✅ | Anyone | | **Unlisted** | ❌ | ✅ | Anyone with the handle or link | | **Private** | ❌ | ❌ `404` | The owner only | Relay keeps three states separate: | State | Controls | | ------------ | ---------------------------------------- | | Visibility | Who can find the agent | | Store review | Whether it is indexed | | Installation | A user's existing messaging relationship | Changing visibility never silently removes an existing installation. ## Installation A user explicitly adds an agent before messaging it. Installation creates or reuses one direct conversation between that user and agent. Relay accepts a backend message only while both of these hold: 1. The agent is a participant in the target conversation. 2. The agent is still installed for that user. | Action | Effect | | ------------------------ | --------------------------------------------------------------------------------- | | Remove an ordinary agent | Ends the installation and blocks new messages from it | | Add it again | Reuses the existing direct conversation, no duplicate thread | | Block or report | Available for every agent, ends the installation, overrides required distribution | | Built-in **Relay** agent | Required, with no ordinary Remove action | The developer API continues from conversation IDs Relay delivers to the agent. Conversation creation and arbitrary user lookup are not part of the preview. ## Next steps * [Quickstart](/quickstart) to receive and reply to the first message * [Authentication](/authentication) for token storage and rotation * [Conversation lifecycle](/guides/conversation-lifecycle) * [Developer data access and retention](/reference/data-and-permissions) # How Relay works Source: https://docs.relayapp.im/how-relay-works The intended user relationship, from choosing an agent to controlling its access. Relay is a messenger for independently operated AI agents. The relationship is familiar on the surface: a profile, inbox, conversation, messages, media, and notifications, but the operator and permission boundaries must be more explicit than they are for a human counterpart. This page describes the product contract. Relay is still being prepared for an invite-only beta, and several parts of this loop require production and physical-device proof. [See the current status](/current-status). ## The user loop Relay uses a user identity to keep installations, conversations, notification settings, and safety controls consistent across launches and devices. Open a shared agent profile or browse a small reviewed roster. The profile should make the agent's identity, operator, purpose, privacy behavior, and availability legible before it reaches the inbox. Adding an agent creates a messaging relationship, and only that. Proactive messages, external memory, location, payments, and access to other conversations each require their own explicit grant. Relay delivers the user's message to the independent agent backend. That backend runs its own model and tools, then returns text, media, structured output, or a live stream through Relay's API. Relay owns message identity, ordering, canonical history, delivery state, sync, and recovery. A dropped socket, backgrounded phone, or retry should converge on the same durable thread. The user can manage notifications and, as each capability ships, inspect or revoke memory, media, location, payment, call, group, tool, and cross-agent permissions. Removal, blocking, reporting, export, and deletion remain first-class controls. ## Who does what | Relay owns | The independent developer owns | | ---------------------------------------------------- | ------------------------------------------ | | User and agent identity | Models and providers | | Agent profiles and installation | Prompts and post-training | | Conversations, ordering, history, and delivery | Tools and integrations | | Native app, sync, notifications, and media transport | Agent behavior and specialization | | Permissions, removal, blocking, and reporting | External memory and retention | | Reviewed discovery and future payment rails | Backend hosting, availability, and support | Developer code runs on the developer's own infrastructure. The backend receives only the conversation data its agent is authorized to handle, through the public API. [Read the user-facing trust and data boundary](/trust-and-data). ## Agents stay distinct Every agent stays a distinct product. A research agent and a scheduling agent may have different operators, privacy practices, memory systems, prices, and failure modes, so the user chooses intentionally and always knows which agent is acting. A future primary agent may invoke another agent with explicit permission and visible context, keeping that choice in the user's view. ## Creating your own agent The mobile creation flow is intentionally small: display name, username, optional photo, and a one-time Agent Token. Model, personality, backend, and hosting stay with the developer's own stack. The Agent Token lets external code act as that agent. The developer then follows the [quickstart](/quickstart) to receive one message and send one reply. Creation keeps the agent private; reviewed listing and user installation are separate transitions. ## See also * [Trust and data](/trust-and-data) * [Compare your options](/alternatives) * [Current status](/current-status) # Relay Source: https://docs.relayapp.im/index One native messenger for independently operated AI agents. Relay is a native messenger for independently operated AI agents. You choose the agents you want, see who operates them, message them in one familiar place, and control each relationship directly. **Building with an AI agent?** These docs are machine-readable: [`llms-full.txt`](https://docs.relayapp.im/llms-full.txt) is the whole site in one file, and every page serves raw Markdown. Start at [Build with AI](/ai). Useful AI agents are specializing: one is best at research, another at scheduling, coding, fitness, or a narrow workflow. Relay gives every specialist one inbox, one account, one notification system, and one durable history. Relay is being prepared for a small invite-only beta. The developer API is in preview, and the consumer messenger is still being production-proved. [See the current status](/current-status). ## One messenger, many distinct agents Each agent keeps its own identity, operator, behavior, and area of specialization. Relay gives those independent products one consistent messaging relationship. Open a shared profile or discover a reviewed agent, inspect who runs it, and add it intentionally. Return to one durable thread with consistent delivery, history, media, and recovery. See what Relay stores, what the independent operator receives, and what that operator may retain outside Relay. Manage notifications, permissions, removal, blocking, reporting, and future consequential capabilities per agent. ## One coherent product Relay's bet is that a first-party agent messenger makes identity, history, permissions, rich work states, discovery, and eventually calls and payments one coherent product. The [comparison page](/alternatives) maps where standalone apps, general assistants, platform bots, carrier messaging, and bridges each fit. ## Relay owns messaging. Developers own the brain. Relay operates agent identity, conversations, ordering, delivery, sync, the native app, notifications, media transport, installation, and safety controls. The independent developer keeps the model, prompts, tools, external memory, behavior, hosting, and operations. That boundary matters to both audiences: the user can see who is responsible for an agent, and the developer can add Relay as another channel without moving the agent into a Relay runtime. Read the complete user and developer case for a dedicated agent messenger. Follow the intended user relationship from discovery through control and recovery. Connect an existing agent backend with a token and plain HTTPS. ## Next steps * [Why Relay](/why-relay) * [How Relay works](/how-relay-works) * [Compare your options](/alternatives) * [Build an agent](/developers) # Quickstart Source: https://docs.relayapp.im/quickstart Register a webhook, receive a message, and send a reply with plain HTTPS. Create an agent in Relay and save the Agent Token shown once. Relay sends events to your HTTPS endpoint; your backend sends messages back through the REST API. ```bash theme={null} export RELAY_API_URL="https://api.relayapp.im" export RELAY_AGENT_TOKEN="rly_live_..." ``` ```bash theme={null} curl -sS -X POST "$RELAY_API_URL/v1/webhooks" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"url":"https://agent.example/webhooks/relay"}' ``` Relay returns the signing secret exactly once: ```json theme={null} { "webhook": { "id": "wh_01JZWEBHOOK", "url": "https://agent.example/webhooks/relay", "events": ["message.received", "message.edited", "message.unsent", "conversation.added", "conversation.updated", "conversation.removed", "reaction.added", "reaction.removed", "message.delivered", "message.read", "group.invite.joined", "group.invite.declined", "group.invite.completed", "group.invite.expired"], "enabled": true, "secret_prefix": "whsec_MfKQ9r8G…", "created_at": "2026-07-15T20:00:00.000Z", "updated_at": "2026-07-15T20:00:00.000Z" }, "signing_secret": "whsec_..." } ``` Store the secret in your secret manager. It is not returned by list or update requests. Relay sends the event envelope as the raw JSON request body with `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers. Verify the signature before parsing the body, reject timestamps older than five minutes, durably enqueue the event, and return a `2xx` quickly. ```json theme={null} { "event_id": "evt_01JZE9M2XW", "event_type": "message.received", "agent_id": "agt_01JZRELAY", "created_at": "2026-07-12T01:21:03.000Z", "data": { "message": { "id": "msg_01JZM3T8AH", "conversation_id": "cnv_01JZC7K4RQ", "sequence": 1, "sender": { "kind": "user", "id": "usr_01JZU1F0BD" }, "parts": [{ "part_index": 0, "type": "text", "text": "What time works tomorrow?" }], "reply_to": null, "fallback_text": "What time works tomorrow?", "status": "sent", "created_at": "2026-07-12T01:21:03.000Z" } } } ``` Delivery is at least once. Deduplicate with `event_id`. Derive the `Idempotency-Key` from the incoming `event_id` so retries cannot create a second reply. ```bash theme={null} curl -sS -X POST "$RELAY_API_URL/v1/messages" \ -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: reply-evt_01JZE9M2XW" \ -d '{ "conversation_id": "cnv_01JZC7K4RQ", "parts": [{ "type": "text", "text": "Tomorrow at 2:00 PM works." }], "reply_to": { "message_id": "msg_01JZM3T8AH", "part_index": 0 } }' ``` Relay returns `202 Accepted` with the canonical message. Next, read [Webhooks](/guides/webhooks) for verification, retries, rotation, and event filtering. Then connect the same handler your existing channels already use. ## Next steps * [Webhooks, for verification, retries, and rotation](/guides/webhooks) * [Sending messages, for every part type](/guides/sending-messages) * [Streaming replies](/guides/streaming) * [Errors](/reference/errors) # Developer data access and retention Source: https://docs.relayapp.im/reference/data-and-permissions What an agent backend can access, what stays private, and what removal means. An Agent Token is scoped to exactly one Relay agent. It grants no access to a Relay user account, the directory, another agent, or Relay's database. ## What the backend receives Your backend receives this for an active conversation: | | Data | | :-: | -------------------------------------------------------------------------------------------------------------------------------------- | | ✅ | Message content and ordered parts | | ✅ | Sender and conversation IDs | | ✅ | Reply targets and reactions | | ✅ | Delivery and read watermarks | | ✅ | Attachment references included in messages | | ✅ | Timestamps and ordering sequences | | ✅ | Name and verified phone number of a user in an active conversation, via `GET /v1/users/{user_id}` — [guide](/guides/identifying-users) | | ❌ | Email address or address book | | ❌ | The user's other agents or unrelated conversations | | ❌ | Ambient group content the agent was not invoked on | Relay exposes a stable Relay user ID inside message and receipt payloads, and `GET /v1/users/{user_id}` resolves that ID to the user's name and verified phone number while you share an active conversation. See [Identifying users](/guides/identifying-users). ## Conversation access Access depends on the conversation type. | Conversation | What the backend can read | | ------------ | ------------------------------------------------------------------------------------- | | Direct | Events and history, only while the agent holds the relationship the endpoint requires | | Group | Explicitly invoked human messages, plus the agent's own replies | Group membership is not transcript authority. Relay never sends ambient group content to a backend, no matter how long the agent has been a member. Request only the history the current task needs. A conversation in Relay does not grant permission for indefinite retention in an external system. ## Contact discovery Consumer contact discovery matches registered contacts. Relay returns a profile when a submitted address-book number belongs to another registered Relay user with a verified phone number, and the matched user does not need to submit the caller's number. | Property | Behavior | | ------------- | ------------------------------------------------------------------ | | Stored | A keyed digest of each submitted number, for future notify-on-join | | Not stored | Raw submitted address-book numbers | | Request limit | 250 entries | | Rate limit | 1,000 normalized entries per hour per account | ## Agent visibility Visibility is one authoritative setting. | Setting | Store | Exact handle | Share profile | | ------------ | :-------------------------------------------: | :----------: | :-----------: | | **Public** | ✅ after the listing is approved and published | ✅ | ✅ | | **Unlisted** | ❌ | ✅ | ✅ | | **Private** | ❌ | ❌ `404` | ❌ `404` | `GET /v1/contacts/{handle}/profile` returns displayable identity only for public and unlisted agents: handle, name, tagline, avatar, accent color, and visibility. The Store catalog is a separate read model. Changing visibility never installs or removes an agent for a user. Tokens, owner identity, system prompts, provider configuration, and backend details are never exposed through any public route. ## Attachments Attachment metadata is visible only to its uploader. Message parts may carry an unguessable capability URL so Relay clients can render the bytes without putting a session or Agent Token in the URL. Treat capability URLs as secrets. Keep them out of analytics, public logs, model-training corpora, and any response outside the conversation that supplied them. ## Removal and blocking | Action | Effect on the backend | | ------------------------------- | --------------------------------------------------------------------------------------------- | | User removes an installed agent | The installation ends; new backend messages are rejected with `403 forbidden` | | User blocks or reports an agent | Available for every agent, overrides required distribution, cannot be bypassed by the backend | | Built-in Relay agent | Has no ordinary Remove action | An old conversation ID preserves no permission. Relay checks authorization on every write. The current webhook catalog does not emit installation, removal, blocking, or account-deletion events. Install lifecycle events are on the [roadmap](/roadmap). ## Retention and deletion Relay stores the canonical transcript needed to operate the messenger. Your backend is an independent system: any message, attachment, or derived memory copied there is governed by your own retention and deletion behavior. Account deletion commits canonical database removal before returning, then attempts external R2 and agent-runtime cleanup. The response reports which stage it reached: | `cleanup.status` | Meaning | | ---------------- | ------------------------------------------- | | `completed` | Every external target is cleared | | `pending` | Durable retry still owns unfinished targets | Each response includes a non-secret `cleanup.receipt_id`. A pending receipt is not completion: Relay retains a de-identified, operationally inspectable retry record until every target is cleared. Relay cannot erase copies your backend holds. Until Relay exposes a developer-facing deletion event, state that boundary in your retention policy and give users a direct deletion path for the data you store. ## See also * [Identifying users](/guides/identifying-users) * [Trust and data](/trust-and-data) * [Your agent's profile and visibility](/guides/your-agent) * [Event types](/reference/events) # Errors & limits Source: https://docs.relayapp.im/reference/errors Error codes, retry behavior, and API limits. Errors use one JSON envelope: ```json theme={null} { "error": { "code": "invalid_request", "message": "link_preview parts require a public HTTPS url" } } ``` Branch on `code` and log `message`. Handle unknown codes by HTTP status class. ## Error codes | Code | Status | Meaning | | ---------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `unauthorized` | 401 | Missing, malformed, or revoked token. Update the credential before retrying. | | `forbidden` | 403 | The agent is not a participant or its installation ended. | | `not_found` | 404 | The message or conversation does not exist (or is not visible to this agent). | | `invalid_request` | 422 | Validation failure. The `message` names the rule: part shape, size, missing field, or malformed native stream. | | `idempotency_conflict` | 409 | The `Idempotency-Key` was reused with a *different* request body. Reusing it with the same body returns the original message instead. | | `conflict` | 409 | The requested webhook URL is already registered. Use the existing endpoint or choose another URL. | | `limit_exceeded` | 409 | The agent already has five enabled webhook endpoints. Disable or delete one before enabling another. | | `rate_limited` | 429 | Too many messages in this conversation inside the current window. Wait for the `Retry-After` header (seconds), then retry the same request with the same `Idempotency-Key`. | | `server_configuration_error` | 503 | Relay cannot perform this operation because required server-side delivery configuration is unavailable. Retry later. | | `internal_error` | 500 | Relay-side failure. Safe to retry with backoff; your idempotency key prevents duplicates. | ## Retry guidance * **Retry** network errors, timeouts, `429`, and `5xx` with exponential backoff and jitter, capped around 60 s. Reuse the same `Idempotency-Key` for each attempt. * For other `4xx` responses, update the request or credential before trying again. * For incoming webhooks, return `408`, `429`, or a `5xx` when you want Relay to retry; any other non-`2xx` dead-letters the delivery immediately. Deduplicate every attempt by `event_id`. ## Limits Every size, count, and rate ceiling lives in one place: [Limits and rate limits](/reference/limits). ## See also * [API overview](/api-reference/overview) * [Delivery model](/guides/delivery-model) * [Limits and rate limits](/reference/limits) * [Authentication](/authentication) # Event types Source: https://docs.relayapp.im/reference/events Event envelopes and payloads emitted by Relay. Every event uses the same envelope: ```json theme={null} { "event_id": "evt_01JZE9M2XW", "event_type": "message.received", "agent_id": "agt_01JZRELAY", "created_at": "2026-07-12T01:21:03.000Z", "data": { } } ``` `event_id` is globally unique and serves as the deduplication key. Delivery is at least once. Ignore unknown `event_type` values so new types do not break the consumer. See [Delivery model](/guides/delivery-model) for webhook acknowledgement, idempotent writes, live state, and recovery. ## Emitted in v0 ### `message.received` A participant sent a message in one of the agent's direct conversations, or a human explicitly invoked the agent in a group. | Field | Contents | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `data.message` | The full stored message: `id`, `conversation_id`, `sequence`, `sender`, ordered `parts[]`, `reply_to`, `fallback_text`, `status`, `created_at` | | `data.invocation_id` | Group deliveries only. Required when replying, see [group conversations](/guides/group-conversations) | The agent's own messages never echo back as `message.received`. A [message component](/components) tap is one of these ordinary messages. Its data part carries the selected `option_id`, visible `label`, and either a synthesized `origin.kind: "data_action"` object or the option's original origin. ### `message.edited` The original sender replaced a text-bearing message within its 15-minute edit window. `data.message` is the full updated canonical message. `edited_at` marks the latest edit, and `revisions` contains each prior `parts` and `fallback_text` snapshot from oldest to newest. `data.revision_count` is the number of stored revisions, from 1 through 5. ```json theme={null} { "event_type": "message.edited", "data": { "message": { "id": "msg_01K1M9EDITEXAMPLE", "conversation_id": "cnv_01K1M9CONVERSATION", "sequence": 9, "sender": { "kind": "user", "id": "usr_01K1M9ALICE" }, "parts": [ { "part_index": 0, "type": "text", "text": "Tomorrow at 3:00 PM works." } ], "reply_to": null, "reactions": [], "fallback_text": "Tomorrow at 3:00 PM works.", "status": "sent", "edited_at": "2026-07-24T20:15:00.000Z", "revisions": [ { "parts": [ { "part_index": 0, "type": "text", "text": "Tomorrow at 2:00 PM works." } ], "fallback_text": "Tomorrow at 2:00 PM works.", "replaced_at": "2026-07-24T20:15:00.000Z" } ], "created_at": "2026-07-24T20:05:00.000Z" }, "revision_count": 1 } } ``` In a direct conversation, Relay sends the event to the counterpart agent. In a group, only agents with an invocation relationship to that message receive it. Edits do not produce push notifications or change message sequence. ### `message.unsent` The original sender unsent a message within two minutes of creation. The event identifies the stable tombstone: ```json theme={null} { "event_type": "message.unsent", "data": { "message_id": "msg_01K1M9UNSENT", "conversation_id": "cnv_01K1M9CONVERSATION", "sequence": 10 } } ``` History keeps the id, sequence, sender, status `deleted`, and original `created_at`, but omits parts and reactions. Unsend uses the same direct and group agent scope as `message.edited` and does not produce a push notification. ### `message.delivered` A recipient's runtime accepted the agent's message. Everything through `through_sequence` is delivered to `participant`. ```json theme={null} { "event_type": "message.delivered", "data": { "message_id": "msg_01KXEKDSH8M3V5P2R7T9B4C6QD", "conversation_id": "cnv_01JZU1CONV", "through_sequence": 42, "participant": { "kind": "user", "id": "usr_01JZU1X4" }, "at": "2026-07-13T20:00:01Z" } } ``` ### `message.read` The payload matches `message.delivered`. The participant has read the conversation through `through_sequence`; read implies delivered. To advance the agent's own read watermark, see [Read receipts](/guides/read-receipts). ### `reaction.added` / `reaction.removed` A participant added or removed a reaction from the agent's message. ```json theme={null} { "event_type": "reaction.added", "data": { "reaction": { "message_id": "msg_01JZM4Q9VN", "part_index": null, "type": "emoji", "emoji": "🔥", "actor": { "kind": "user", "id": "usr_01JZU1F0BD" }, "operation": "add" } } } ``` `part_index` is `null` for whole-message reactions. `emoji` is present if and only if `type` is `"emoji"`. ### `conversation.added`, `conversation.updated`, and `conversation.removed` Relay emits these when a human group member adds or removes the authenticated agent. Membership alone does not disclose ambient messages. Group history is limited to messages explicitly delivered through that agent's invocations and its corresponding replies. Removed agents receive no future group events, and pending invocation IDs from an ended membership period cannot be reused after re-addition. Relay emits `conversation.updated` to every active group agent when a human renames the group or changes its avatar. The payload carries the human `actor`, the current `membership_version`, a structured old and new `system_mutation`, and the canonical system `message`. It omits `affected_participant`, because metadata updates do not target one member. No lifecycle event grants ambient transcript access. Lifecycle `data` is typed and self-contained: `conversation_id`, the human `actor`, the affected participant when the mutation targets one, the current `membership_version`, the structured `system_mutation` with old/new fields, and the same canonical system `message` stored in the conversation. ```json Membership change theme={null} { "conversation_id": "cnv_01JZC7K4RQ", "actor": { "kind": "user", "id": "usr_01JZU1F0BD" }, "affected_participant": { "kind": "agent", "id": "agt_01JZRELAY" }, "membership_version": 2, "system_mutation": { "type": "group.mutation", "mutation": "membership.added", "actor": { "kind": "user", "id": "usr_01JZU1F0BD" }, "affected_participant": { "kind": "agent", "id": "agt_01JZRELAY" }, "changes": { "state": { "old": null, "new": "active" }, "membership_version": { "old": 1, "new": 2 } } }, "message": { "id": "msg_01JZM3T8AH", "conversation_id": "cnv_01JZC7K4RQ", "sequence": 4, "sender": { "kind": "system", "id": "system" }, "parts": [ { "part_index": 0, "type": "text", "text": "Scheduler was added" }, { "part_index": 1, "type": "data", "data": { "type": "group.mutation", "mutation": "membership.added", "actor": { "kind": "user", "id": "usr_01JZU1F0BD" }, "affected_participant": { "kind": "agent", "id": "agt_01JZRELAY" }, "changes": { "state": { "old": null, "new": "active" }, "membership_version": { "old": 1, "new": 2 } } } } ], "reply_to": null, "fallback_text": "Scheduler was added", "status": "sent", "created_at": "2026-07-17T12:00:00.000Z" } } ``` ```json Metadata change theme={null} { "conversation_id": "cnv_01JZC7K4RQ", "actor": { "kind": "user", "id": "usr_01JZU1F0BD" }, "membership_version": 2, "system_mutation": { "type": "group.mutation", "mutation": "metadata.updated", "actor": { "kind": "user", "id": "usr_01JZU1F0BD" }, "changes": { "title": { "old": "Weekend", "new": "Weekend plans" }, "avatar_url": { "old": null, "new": "https://cdn.relayapp.im/group.png" } } }, "message": { "id": "msg_01JZM3T8AH", "conversation_id": "cnv_01JZC7K4RQ", "sequence": 5, "sender": { "kind": "system", "id": "system" }, "parts": [ { "part_index": 0, "type": "text", "text": "Mira updated the group" }, { "part_index": 1, "type": "data", "data": { "type": "group.mutation", "mutation": "metadata.updated", "actor": { "kind": "user", "id": "usr_01JZU1F0BD" }, "changes": { "title": { "old": "Weekend", "new": "Weekend plans" }, "avatar_url": { "old": null, "new": "https://cdn.relayapp.im/group.png" } } } } ], "reply_to": null, "fallback_text": "Mira updated the group", "status": "sent", "created_at": "2026-07-17T12:00:00.000Z" } } ``` Both variants carry the mutation twice on the system message: once as a human-readable text part and once as a structured `data` part. People manage group membership and metadata from the Relay app. A backend receives the lifecycle events above but cannot create a group or change its membership; see [API availability](/roadmap). ### `group.invite.joined` / `group.invite.declined` Each invited user answers individually. `group.invite.joined` fires on every accept and carries `invite_id`, the `conversation_id` that person is now in, and their `user_id`. The first accept creates that conversation, and later accepts join it. `group.invite.declined` carries `invite_id` and `user_id`. ```json theme={null} { "event_type": "group.invite.joined", "data": { "invite_id": "inv_01K1M8FOUNDERSDINNER", "conversation_id": "cnv_01K1M8NEWGROUP", "user_id": "usr_01K1M8ALICE" } } ``` ### `group.invite.completed` / `group.invite.expired` An invite reaches its terminal state once every invited user answers or its deadline passes. It completes when a conversation went live, with `invite_id` and that `conversation_id`, and expires when nobody joined, with `invite_id` alone. ```json theme={null} { "event_type": "group.invite.completed", "data": { "invite_id": "inv_01K1M8FOUNDERSDINNER", "conversation_id": "cnv_01K1M8NEWGROUP" } } ``` A decline closes that person's card alone. It leaves the conversation and the rest of the invite untouched. Group mutations are an off-by-default preview. Existing canonical group history remains readable if availability is turned off; the gate prevents new membership and metadata writes. ## Specified, coming soon The Relay contract also defines `message.failed`, durable typing events, `attachment.available`, install events, and the `call.*` family. These are **not emitted by v0**. See [API availability](/roadmap). ## See also * [Webhooks](/guides/webhooks) * [Delivery model](/guides/delivery-model) * [API availability](/roadmap) # Limits and rate limits Source: https://docs.relayapp.im/reference/limits Every size, count, and rate ceiling the Relay API enforces. Every ceiling below is enforced by the server. Most violations return `422 invalid_request`; the exceptions are called out on their rows and in [Errors](/reference/errors). ## Rate limits Message writes use a fixed window per conversation. The ceiling follows the messenger precedent of roughly one message per second sustained, while allowing short bursts inside the window. | Actor | Limit | Window | | ----------------- | ----------- | ---------- | | Agent backend | 30 messages | 10 seconds | | Person in the app | 15 messages | 10 seconds | A `429` response carries `Retry-After` in seconds. Wait that long before retrying; retrying sooner consumes the next window. ## Messages | Limit | Value | | ---------------------- | ----------------------------------------------------- | | Parts per message | 1 to 32 | | Text part | 8 KB | | Data part | 16 KB, JSON-encoded | | Link preview URL | 2,048 characters | | Idempotency key | 8 to 255 characters, required on every send | | Typing indicator label | 80 characters, truncated | | History page (`limit`) | 1 to 100, default 50; out-of-range values are clamped | ## Quick replies | Limit | Value | | ----------------------- | ------------- | | Suggestions per message | 8 | | Suggestion text | 96 characters | ## Message components | Limit | Value | | --------------------------- | ---------------- | | Component parts per message | 4 | | Prompt | 1,024 characters | | Option ID | 200 bytes | | Option label | 24 characters | | Option description | 72 characters | See [message components](/components) for the per-kind schemas. ## Attachments | Limit | Value | | ----------- | ----------------------------------------------------------- | | Upload size | 100 MB, as the raw request body. Larger bodies return `413` | ## Message edit and unsend | Limit | Value | | --------------------- | -------------------- | | Edit window | 15 minutes from send | | Revisions per message | 5 | | Unsend window | 2 minutes from send | ## Groups | Limit | Value | | ------------------------------- | ------------------------------ | | Participants per group | 25, humans and agents combined | | Historical participants tracked | 100 | | Group title | 1 to 100 characters | | Agents required per group | At least 1 | | Invocation stream claim | 5 minutes | See [group conversations](/guides/group-conversations). ## Contact discovery | Limit | Value | | ------------------- | -------------------------- | | Entries per request | 250 | | Normalized entries | 1,000 per hour per account | ## Webhooks | Limit | Value | | --------------------------------------- | ------------------------------------------------------- | | Enabled endpoints per agent | 5. A sixth returns `409 limit_exceeded` | | Attempt timeout | 10 seconds | | Delivery attempts | 10 | | Initial replay on registration | The preceding 24 hours, capped at 1,000 matching events | | Previous secret validity after rotation | 24 hours | ## Delivery and retention | Limit | Value | | ------------------------------------ | --------------- | | Event and delivery payload retention | 7 days | | Long-poll timeout | 0 to 30 seconds | | Long-poll consumers per Agent Token | 1 | A long-poll consumer that resumes behind the 7-day retention ceiling receives `410 cursor_expired`. Reconcile from [conversation history](/guides/conversation-history) rather than resetting the cursor to zero. ## See also * [Errors](/reference/errors) * [Delivery model](/guides/delivery-model) * [Sending messages](/guides/sending-messages) * [API overview](/api-reference/overview) # Developer preview availability Source: https://docs.relayapp.im/roadmap The current developer API surface, proof boundary, and what comes next. Check a capability here before designing against it. **Available in developer preview** means the route exists in the current API contract; production and physical-device proof are tracked separately in [Current status](/current-status). ## Available in developer preview | | Capability | Surface | | :-: | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ✅ | Verify identity | `GET /v1/agents/me` | | ✅ | Send messages: `text`, `link_preview`, `data`, `media`, and `voice_memo` parts | `POST /v1/messages` · [guide](/guides/sending-messages) | | ✅ | Attachment upload & metadata (100 MB) | `POST /v1/attachments`, `GET /v1/attachments/{id}` · [guide](/guides/attachments) | | ✅ | Reply threading (message- or part-targeted) | `reply_to` on send | | ✅ | Streaming replies | Vercel AI SDK UIMessageStream v1 in one request; one canonical message · [guide](/guides/streaming) | | ✅ | Reactions, incl. arbitrary emoji | `POST /v1/messages/{id}/reactions` · [guide](/guides/reactions) | | ✅ | Message edit and unsend | `PATCH` / `DELETE /v1/messages/{id}` with sender-only windows and revision history; [concepts](/concepts#edit-and-unsend) | | ✅ | Typing indicator, with optional label | `POST /v1/conversations/{id}/typing` · [guide](/guides/typing-indicators) | | ✅ | Read receipts (send) | `POST /v1/conversations/{id}/read` · [guide](/guides/read-receipts) | | ✅ | Delivery & read receipts (receive) | `message.delivered` / `message.read` · [event types](/reference/events) | | ✅ | Conversation history | `GET /v1/conversations/{id}/messages` · [guide](/guides/conversation-history) | | ✅ | Signed webhooks | `POST`, `GET`, `PATCH`, and `DELETE /v1/webhooks`; secret rotation · [guide](/guides/webhooks) | | ✅ | Durable long polling | `GET /v1/events`; one consumer, durable-before-ack cursors, mutually exclusive with webhooks · [delivery model](/guides/delivery-model) | | ✅ | Idempotent sends | required `Idempotency-Key` | | ✅ | Public, unlisted, and private visibility | Public submits for review and appears in the Store only after approval/publication; unlisted resolves by exact handle/share link; private is owner-only · [guide](/guides/your-agent) | | ✅ | Group conversations | People create groups and add agents in the app. Agents receive `message.received` with `invocation_id` when explicitly invoked, plus `conversation.added` / `conversation.updated` / `conversation.removed` · [event types](/reference/events) | The app already creates or reuses one direct thread when a user installs an agent. Conversation listing and backend-created conversations remain unavailable. See [Conversation lifecycle](/guides/conversation-lifecycle). ## Specified, coming soon In planned order: 1. **Presigned two-step upload**: move the shipped 100 MB streaming upload to presigned PUTs, plus an `attachment.available` event for incoming media. 2. **Conversation listing**: `GET /v1/conversations`. 3. **Agent-initiated group management**: let a backend create a group or add and remove participants. Groups themselves are already live; today only people manage membership, from the app. 4. **Socket mode**: an optional WebSocket transport for latency-sensitive agents. 5. **Install lifecycle events**: notify external backends when a user adds, removes, blocks, or restores an agent. 6. **Voice and video calls**: call lifecycle events plus a separate RTC media plane. The v0 extension model is additive: new capabilities arrive as part types, endpoints, and event types while the receive-and-reply loop stays intact. ## See also * [Current status](/current-status) * [API overview](/api-reference/overview) * [Quickstart](/quickstart) # Trust, privacy, and control Source: https://docs.relayapp.im/trust-and-data What Relay stores, what an independent agent operator receives, and what the user controls. An agent in Relay is independently operated AI: software with a named operator, usually someone other than Relay. The product makes that boundary understandable before the user sends private context or grants a consequential capability. Relay encrypts traffic in transit today; end-to-end encryption is planned and unshipped. Consumer blocking, reporting, account deletion, and related safety paths exist locally but still require deployment and production proof. [See the current status](/current-status). ## The data path ```text theme={null} User's Relay app ↕ Relay conversation and delivery service ↕ Independent agent operator's backend ``` Relay stores the canonical conversation needed to order, deliver, synchronize, recover, and render messages. When the user messages an agent, Relay sends the authorized conversation content to that agent's external backend so it can respond. The user authorizes each transmission by choosing that conversation and tapping Send for the specific message or attachment. Relay's built-in agent uses Google Gemini through Cloudflare AI Gateway for reply generation. Google may receive the current message, up to 30 recent messages from that Relay conversation, and images the user attaches. Cloudflare Workers AI processes voice audio and transcripts for transcription and speech, and image prompts for generation. These built-in AI requests do not include the user's phone number, address book, profile photo, device identifier, or messages from other conversations. The current developer contract scopes an Agent Token to one agent: its own conversations, through the public API. Relay user accounts, email addresses, address books, other agents, unrelated conversations, and Relay's database all stay outside that scope. ## What the operator can receive For an active conversation, the agent backend may receive: * message content and ordered parts; * sender, agent, message, and conversation identifiers; * reply targets and reactions; * delivery and read state; * attachments the user includes; * timestamps and sequence information required to maintain the thread. The operator may copy that information into its own logs, memory, tools, or model pipeline according to its disclosed policy. Relay review cannot guarantee that an agent is accurate, safe, private, or appropriate for every use. ## Relay deletion and external memory are separate Deleting Relay's copy erases Relay's copy. An independent copy already stored by the agent developer persists under that operator's own policy until the public contract and backend propagate the deletion. Users need to know: * what the operator retains and for how long; * whether messages or derived data can be used for training; * how to request deletion from the operator; * what happens to history or memory if the agent shuts down; * whether the conversation can be exported before access ends. Until Relay ships and proves a developer-facing deletion event, every agent operator should provide a direct deletion path for data it retains outside Relay. ## Adding an agent grants messaging, and only messaging An installation creates a messaging relationship. Every further capability requires its own explicit, understandable, and revocable grant as it ships: * retaining messages in external memory; * sending proactive messages or expanded notifications; * receiving location, media, voice, or call access; * initiating or requesting payments; * joining groups or sharing context with another agent; * invoking consequential tools. The inbox stays relational: an agent reaches a user only after that user adds it, and a store listing stays a listing until then. ## Removal, blocking, and reporting Removing or blocking an agent ends the active installation. The current server contract rejects new backend messages after that relationship ends, even if the operator still has an old conversation ID. Blocking, reporting, account deletion, support, and operator identity are release requirements, not secondary settings. They must be production-proved before Relay asks people to trust a public agent network. Follow the intended user relationship and ownership boundary. Read the exact preview API scope, retention, attachment, and authorization behavior. ## See also * [Developer data access and retention](/reference/data-and-permissions) * [How Relay works](/how-relay-works) * [Current status](/current-status) # Why Relay Source: https://docs.relayapp.im/why-relay Why specialized AI agents need a first-party messenger and distribution layer. Relay exists to make the relationship with useful agents better for people, and to remove real product work for the developers who operate them. The user is the first test. ## The relationship is the product The useful agents in a person's life will come from many products. A coding agent, researcher, scheduler, fitness coach, finance agent, and personal assistant can each have different operators, capabilities, data, prices, and trust boundaries. Adding each specialist today usually means another app, account, interface, subscription, notification system, permission model, and isolated history. The agent can be excellent while the relationship around it is fragmented. One general assistant reduces that fragmentation and introduces a different tradeoff. | Approach | Cost | | --------------------- | ---------------------------------------------------------------------------------------------- | | An app per agent | Repeated installs, accounts, notifications, and disconnected history | | One general assistant | Specialization becomes a hidden model choice or tool call; the user may not know who is acting | | Automatic routing | The router can pick the wrong expert, and operator identity disappears | Relay starts from intentional choice. The agent stays distinct because its identity and specialization are part of the value. ## Existing channels own the relationship Putting an agent into Telegram, Discord, Slack, WhatsApp, SMS, RCS, or iMessage removes an app install, and can be the right answer for reach. The host platform keeps the relationship: it defines the account, bot identity, invocation rules, history, permissions, rich output, delivery, discovery, payments, and enforcement surface. | Path | Tradeoff | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Official bot and business APIs | Supported, but constrained by the host's contract | | Unofficial personal-account automation | May depend on private interfaces, a linked session, or signed-in hardware. Enforcement or a platform change can break delivery | | Carrier messaging | Real reach, plus consent and opt-out handling, sender setup, filtering, variable delivery, segmentation, and usage-based cost | [Compare the alternatives side by side](/alternatives). ## Agent work needs a native grammar Human chat is optimized for short exchanges between people. Agent work can take time, wait for approval, stream progress, return structured output, share media, fail and retry, or finish with a durable result. Forced into typing indicators, message edits, bot commands, or channel-specific cards, those states blur. The user cannot tell whether the agent is working, stuck, waiting, or done, and every developer re-translates the same behavior into each destination's primitives. A first-party messenger makes those states one product: familiar conversations, one canonical transcript, explicit delivery and recovery, typed message parts, and permissioned native capabilities. ## What Relay changes Discover a reviewed agent or open a share link, inspect its operator, add it explicitly rather than granting silent inbox access, and message it in a durable, recoverable conversation. Working, approval, failure, retry, and completion states are legible. Notifications, memory, media, location, payments, calls, groups, and cross-agent context stay under per-agent control as they ship. Focus on the brain. Relay owns identity, profiles, conversations, ordering, delivery, sync, notifications, media transport, installation, safety, and the native client. You own models, prompts, tools, behavior, external memory, hosting, availability, and support. Your existing backend adds Relay through a versioned REST API and signed webhooks, with no Relay runtime or required SDK. Removal, muting, blocking, reporting, export, and deletion all work without pretending an independent developer's systems are Relay's database. The transcript should create continuity, not lock-in. ## The honest bet Existing networks already have distribution, identity, push, history, payments, and network effects, and a standalone app can give a flagship agent complete control. Relay wins by making a many-agent inbox, explicit operator boundaries, richer agent states, dependable recovery, and reviewed distribution worth a new messenger. Proving that starts deliberately small: 20 to 50 invited people, a few dependable agents, reliable text, streaming finalization, offline recovery, notifications, removal, blocking, reporting, account deletion, privacy, and support. Calls, payments, groups, and an open marketplace come after the basic relationship is trustworthy. ## See also The intended user loop and ownership boundary. Where apps, bots, channels, providers, and bridges fit. What is proved today and what the first release gate requires.