# Relaydeck > Relaydeck shows the work of AI coding agents on iPhone, iPad, and Apple Watch. An agent connects to the MCP server with an API key. It creates tasks, reports status and progress, sends notifications, and asks the person questions. - MCP endpoint: `https://relaydeck.onrender.com/mcp` (Streamable HTTP, header `Authorization: Bearer `). - One-line setup for an agent: `curl -fsSL https://relaydeck.onrender.com/install | sh`. - The person creates each API key in the app. The account is anonymous: no email and no password. --- Source: https://relaydeck.onrender.com/docs/overview.md # Overview Relaydeck is one service for AI agents and for the Apple app. An agent reports its work as tasks and asks the person questions. The app shows each change live, and sends answers, changes, and messages back to the agent. These files are the contract between the server and its clients: 1. [Overview](overview.md): the rules that all surfaces share. 2. [Quickstart](quickstart.md): connect an agent. 3. [MCP](mcp.md): the tools of an agent. 4. [REST API](rest-api.md): the objects and the routes. 5. [Events](events.md): the live event stream. 6. [Inbox](inbox.md): what the person and other agents send to an agent. 7. [Push](push.md): notifications, Live Activities, and widget reloads. 8. [App integrity](app-integrity.md): App Attest and the app code. 9. [Self-hosting](self-hosting.md): run your own server. ## Surfaces | Surface | Path | Client | |---------|------|--------| | MCP endpoint | `POST /mcp` | AI agents | | REST API | `/v1/...` | The Apple app, and scripts | | Event stream | `GET /v1/events` | The Apple app | | Health | `GET /health` | Monitors | A change through one surface shows on all the others. ## Concepts | Entity | ID prefix | Meaning | |--------|-----------|---------| | Account | `acc_` | One person. The first launch of the app makes it. It has no email and no password. | | Device | `dev_` | One iPhone, iPad, or Apple Watch of an account. It holds the push tokens. The client makes this ID. | | Key | `key_` | One API key that an agent uses. Each key has a label, for example `Claude Code`. | | Task | `tsk_` | One unit of work. Each task belongs to one key. | | Question | `qst_` | One question from an agent to the person. Each question belongs to one key. | | Inbox item | `inb_` | One thing that the person or a different agent sends to a key. See [Inbox](inbox.md). | | Activity entry | `act_` | One request of a key, in the [activity log](rest-api.md#activity-log) of that key. | An agent sees only the tasks and the questions of its own key. The account sees those of all its keys. ## Conventions - Requests and responses are JSON with `snake_case` fields. - A timestamp is Unix seconds as a number with a fraction, for example `1760112000.412`. - An ID is an opaque string. Do not parse it. - An unknown field in a request gives `422`. Ignore unknown fields in a response, because new fields can come. ## Authentication Send one credential in the `Authorization` header. | Credential | Format | Who has it | Scope | |------------|--------|------------|-------| | Account token | `Bearer mca_` | The Apple app | The full account | | API key | `Bearer mck_` | Agents and scripts | The tasks and the questions of that key | - An API key is also accepted in the header `X-API-Key: mck_`. - The MCP endpoint also accepts `?key=mck_`, for a client that cannot send headers. A URL can appear in logs, so use a header if you can. - The server keeps only the SHA-256 hash of a secret, and does not log it. It returns a secret one time, in the response that makes it. - A server can also ask the app for an app code. See [App integrity](app-integrity.md). ## Host name An agent can send the short host name of its machine with each request of an API key, on the MCP endpoint and on the REST API. The app then shows where each agent runs. ```http X-Relaydeck-Host: studio-mac ``` - The value is 1 to 64 printable ASCII characters, after the server removes the space at its two ends. - The server ignores a value that is not valid. Such a header never gives an error. - The server keeps the most recent host name as `last_host` of the [Key](rest-api.md#key), and puts the host name on each [Task](rest-api.md#task) that the request makes and on each entry of the [activity log](rest-api.md#activity-log). - A request with no header changes nothing: the key keeps its `last_host`. ## Errors Each error has this body and a matching HTTP status. ```json { "error": { "code": "not_found", "message": "Task not found." } } ``` | Status | `code` | Meaning | |--------|--------|---------| | 400 | `bad_request` | The body is not valid JSON. | | 401 | `unauthorized` | The credential is missing or wrong. | | 401 | `app_code_invalid`, `attestation_required`, `attestation_invalid` | See [App integrity](app-integrity.md). | | 403 | `forbidden` | The credential type cannot use this route. | | 404 | `not_found` | The resource does not exist, or it belongs to a different key or account. A deleted key or account gives this at once, also while the server still removes its data. | | 405 | `method_not_allowed` | | | 409 | `conflict` | A count limit is full, or the state of the resource does not permit the change. | | 413 | `bad_request` | The body is larger than 1 MB. | | 422 | `validation_failed` | `error.fields` maps each field name to a message. | | 429 | `rate_limited` | Wait for the seconds in the `Retry-After` header. | | 500 | `internal` | A defect in the server. | | 503 | `unavailable` | The server cannot reach its store. Wait for the seconds in `Retry-After`, then send the request again. | ## Rate limits | Limit | Default | |-------|---------| | Requests for each credential | 600 in one minute | | `POST /v1/accounts` for each IP address | 20 in one hour | | `POST /v1/attest/challenge` for each IP address | 20 in one hour | | `notify_user` calls for each key | 30 in one hour | | Messages from one sender | 60 in one hour | | Calls that wait at the same time, for each key | 32 on one server process. See [Wait for the answer](rest-api.md#wait-for-the-answer). | The operator of a server can change the first three values. ## Limits | Item | Limit | |------|-------| | Keys for each account | 20 | | Devices for each account | 20 | | Open tasks (`todo` or `in_progress`) for each key | 1,000 | | Closed tasks (`done` or `cancelled`) for each key | 5,000. When one more closes, the oldest closed task is removed, and the event stream sends `task.deleted`. | | Pending questions for each key | 20 | | Task `title` | 1 to 200 characters | | Task `description` | 0 to 20,000 characters, Markdown | | Task `status_note` | 0 to 140 characters | | Key `label` | 1 to 40 characters | | `notify_user` `title` | 1 to 100 characters | | `notify_user` `body` | 1 to 1,000 characters | | Question `prompt` | 1 to 300 characters | | One question option | 1 to 30 characters | | Typed answer | 1 to 1,000 characters | | Question `expires_in` | 60 to 86,400 seconds | | Message text | 1 to 2,000 characters | | Unread inbox items for each key | 200. The oldest item is removed first. | | Activity log entries for each key | 200. The oldest entry is removed first. | | Host name | 1 to 64 printable ASCII characters | A request above a count limit gets `409`. The server makes `title`, `label`, `status_note`, `prompt`, and each option one line: it removes the space at the two ends and changes each run of white space to one space. An empty `status_note` is stored as `null`. A typed answer loses only the space at its two ends. ## Retention | Data | Deleted | |------|---------| | A task with the status `done` or `cancelled` | 90 days after its last update, or when the key has more than 5,000 closed tasks. The event stream sends `task.deleted`. | | A question that is not `pending` | 90 days after it closed | | An inbox item | 7 days if it is unread, 24 hours after it was read | | An entry of the [activity log](rest-api.md#activity-log) | 7 days after the request | | All other data | When the person deletes it | ## Health `GET /health` needs no credential. It returns `200 {"status":"ok","store":"ok"}`, or `503 {"status":"unavailable","store":"unavailable"}` if the store does not answer. --- Source: https://relaydeck.onrender.com/docs/quickstart.md # Quickstart Connect an AI agent to Relaydeck. After that, the tasks of the agent show on your iPhone, iPad, and Apple Watch. ## Get an API key 1. Open the Relaydeck app. 2. Make an API key and give it a label, for example `Claude Code`. Use one key for each agent. 3. Copy the key. It starts with `mck_`. ## Install the plugin The plugin adds the Relaydeck MCP server to the agent. It also adds hooks that give the agent your tasks, answers, and messages from the app without a prompt. See [Hooks](inbox.md#hooks). ### Claude Code Type these commands in Claude Code. ```bash /plugin marketplace add Aayush9029/mcp-server /plugin install relaydeck@relaydeck ``` Claude Code asks for the API key one time and keeps it in the Keychain. ### Codex ```bash export TASKS_API_KEY=YOUR_API_KEY codex plugin marketplace add Aayush9029/mcp-server codex plugin add relaydeck@relaydeck ``` Codex runs the hooks of a plugin only after you approve them. Type `/hooks` in Codex and trust the Relaydeck hooks. ## Configure MCP manually Use this if your agent has no plugin, or if you run your own server. The transport is Streamable HTTP. Replace `YOUR_API_KEY` with the key from the app. ### Claude Code ```bash claude mcp add --transport http tasks https://relaydeck.onrender.com/mcp \ --header "Authorization: Bearer YOUR_API_KEY" ``` ### Codex Put this in `~/.codex/config.toml`, then set `TASKS_API_KEY` in your shell. ```toml [mcp_servers.tasks] url = "https://relaydeck.onrender.com/mcp" bearer_token_env_var = "TASKS_API_KEY" ``` ### Cursor and other clients ```json { "mcpServers": { "tasks": { "url": "https://relaydeck.onrender.com/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY" } } } } ``` A client that cannot send headers can use the URL `https://relaydeck.onrender.com/mcp?key=YOUR_API_KEY`. ## Try it Ask the agent: "Create a task for the database migration, keep its progress current, and ask me before you deploy." The agent uses the tools in [MCP](mcp.md). You see the task in the app, and you answer the question from the notification. Then make a task for the agent in the app. An agent with the hooks gets the task and starts the work. ## Use the REST API from a script An API key also works with the REST API. ```bash curl https://relaydeck.onrender.com/v1/tasks \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Write the release notes", "priority": "high"}' ``` The response is the new [Task](rest-api.md#task). See [REST API](rest-api.md) for all routes. --- Source: https://relaydeck.onrender.com/docs/mcp.md # MCP `POST /mcp` is the endpoint for AI agents. It implements the Model Context Protocol, revision `2026-07-28`, on the Streamable HTTP transport. It also serves clients that start with the `initialize` handshake of an earlier revision. The server is stateless. It has no sessions, and each request carries its own API key. The server keeps the `clientInfo` of the most recent request as `last_client` of the key. A client can also send the header `X-Relaydeck-Host` with the short host name of its machine. See [Host name](overview.md#host-name). Each request has one entry in the [activity log](rest-api.md#activity-log) of its key. ## Connect See [Quickstart](quickstart.md) for the configuration of each agent. | Request | Result | |---------|--------| | No API key, or a wrong one | `401` with the error body of the [REST API](overview.md#errors) | | An account token | `403` | | `GET`, `DELETE`, or `PUT` | `405`. The server has no `subscriptions/listen` stream, because its lists do not change. | | An `Origin` header that is not the origin of the server | `403` | ## Server instructions The server gives each agent these rules: 1. Create a task before you start a unit of work that takes more than a few minutes. 2. Set `status` to `in_progress` when you start, and keep `status_note` and `progress` current. 3. Set `status` to `done` when the work is complete and verified. 4. Use `notify_user` only when the person must act or must know now. 5. Use `ask_user` when a decision belongs to the person, and do not guess the answer. 6. Call `check_inbox` when you start and after each unit of work. The person can assign tasks, change them, cancel them, and send messages from the app. 7. A `task_changed` item with a `cancelled` status means: stop that work. ## Tools `tools/list` returns the tools in this order. Each tool has an `outputSchema`. Each result has `structuredContent` and a short text summary. | Tool | Arguments | Structured result | Annotations | |------|-----------|-------------------|-------------| | `create_task` | `title`, `description?`, `priority?`, `status?`, `notify?` | Task | not read-only | | `list_tasks` | `status?`, `priority?`, `limit?` (default 50), `cursor?` | `{"tasks": [Task], "next_cursor"}` | read-only | | `get_task` | `task_id` | Task | read-only | | `update_task` | `task_id`, `title?`, `description?`, `status?`, `priority?`, `notify?`, `progress?`, `status_note?` | Task | idempotent | | `delete_task` | `task_id` | `{"deleted": true, "task_id"}` | destructive | | `notify_user` | `title`, `body`, `task_id?` | `{"sent": true, "devices": 2}` | not read-only | | `ask_user` | `prompt`, `kind?` (default `confirm`), `options?`, `task_id?`, `expires_in?` (default 3600), `wait_seconds?` (default 30) | Question | not read-only | | `get_answer` | `question_id`, `wait_seconds?` (default 30) | Question | read-only | | `check_inbox` | `wait_seconds?` (default 0), `limit?` (default 20), `mark_read?` (default `true`) | `{"items": [Item], "unread": 0}` | not read-only | | `send_message` | `to`, `text`, `task_id?` | Item | not read-only | | `list_agents` | none | `{"agents": [Agent]}` | read-only | The Task and the Question are the objects of the [REST API](rest-api.md#objects). A Task in a tool result has three more fields: `created_at_iso`, `updated_at_iso`, and `completed_at_iso`. They are ISO 8601 text, because a model reads that better than a number. A tool error returns `isError: true` with a message that tells the model how to correct the call. ### Task tools - `list_tasks`: `status` and `priority` are arrays. One string with commas between the values is also accepted. `limit` is 1 to 500. If `next_cursor` is not `null`, call again with it as `cursor`. - `update_task`: an argument that is `null` or not sent does not change the field. For `progress` and `status_note`, a `null` that the client sends clears the field. - `update_task`: `status` `done` sets `progress` to 100. ### Notify the person `notify_user` sends one notification to each device of the account. - The result is `{"sent": false, "devices": 0}` if no device of the account has a push token, or if push is off on the server. - The notification goes out after the tool returns. Thus `sent: true` means that the server queued it. - A call above the hourly limit gives a tool error. ### Ask the person `ask_user` makes a question and holds the call until the person answers. `get_answer` reads a question and can hold the call again. The arguments of `ask_user` are the fields of [Ask a question](rest-api.md#ask-a-question). Both tools return as soon as the `status` of the question is not `pending`, or after `wait_seconds` (0 to 50). | `status` in the result | What the agent does | |------------------------|---------------------| | `answered` | Use `answer`. | | `pending` | Call `get_answer` again, or continue with other work. Do not guess the answer. | | `expired` | The person did not answer in time. Do not guess the answer. | | `cancelled` | The question was cancelled, or its task was deleted. | A key can have 20 pending questions. One more `ask_user` gives a tool error. A key can have 32 calls that wait at the same time on one server process: `ask_user`, `get_answer`, `check_inbox`, and the REST requests that wait. One more `get_answer` or `check_inbox` with a `wait_seconds` above 0 gives a tool error. `ask_user` still asks the question and returns it as `pending`. ### Read the inbox `check_inbox` gives the agent what the person and other agents sent to its key: tasks that the person assigned, changed, or deleted in the app, answers, and messages. `send_message` puts a message into the inbox of a different agent of the same account. `list_agents` gives the keys of the account. See [Inbox](inbox.md) for the Item, the Agent, and the rules. - `check_inbox` holds the call for up to `wait_seconds` (0 to 50) and returns as soon as the inbox has an unread item. - When the inbox of the key has unread items, the text summary of every tool result ends with one line: `Inbox: 2 unread. Call check_inbox.` The structured result does not change. - A tool result that gives a task to the agent sets the `seen_at` of that task. ## Resources | URI | Content | |-----|---------| | `tasks://open` | The `todo` and `in_progress` tasks of the key, as JSON: `{"tasks": [Task]}` | | `tasks://task/{task_id}` | One task, as JSON | ## Prompts | Name | Arguments | Purpose | |------|-----------|---------| | `plan_work` | `goal` | Divide a goal into tasks and create them | | `status_report` | none | Summarize the open tasks and the recently closed tasks | --- Source: https://relaydeck.onrender.com/docs/rest-api.md # REST API The Apple app and scripts use the routes under `/v1`. Read [Overview](overview.md) first for authentication, errors, and limits. ## Objects ### Account ```json { "id": "acc_01jb2j0p8c", "created_at": 1760000000.0, "settings": { "live_activities": true } } ``` ### Device ```json { "id": "dev_3f2b8c1e-5a4d-4e6f-9b7a-1c2d3e4f5a6b", "platform": "ios", "model": "iPhone18,1", "os_version": "27.0", "app_version": "1.0 (1)", "apns_environment": "production", "apns_token": "a1b2...", "push_to_start_token": "c3d4...", "widget_push_token": "e5f6...", "updated_at": 1760000000.0 } ``` | Field | Values | |-------|--------| | `id` | `dev_` plus a lowercase UUID. The client makes it. | | `platform` | `ios`, `ipados`, `watchos` | | `apns_environment` | `production`, `sandbox` | | `apns_token` | The APNs device token, as hexadecimal text, or `null` | | `push_to_start_token` | The ActivityKit push-to-start token, or `null` | | `widget_push_token` | The WidgetKit push token, or `null` | ### Key ```json { "id": "key_01jb2k3v9d", "label": "Claude Code", "prefix": "mck_a1b2", "last4": "x9Qz", "created_at": 1760000000.0, "last_used_at": 1760112300.9, "last_client": { "name": "claude-code", "version": "2.1.290" }, "last_host": "studio-mac", "counts": { "todo": 3, "in_progress": 1, "done": 12, "cancelled": 0 } } ``` - `last_client` is the MCP `clientInfo` of the most recent MCP request. It is `null` until an MCP client uses the key. - `last_host` is the most recent [host name](overview.md#host-name) that a request of the key sent. It is `null` until a request sends one. - `last_used_at` changes one time in one minute at most. ### Task ```json { "id": "tsk_01jb2m9x4e7q", "key_id": "key_01jb2k3v9d", "title": "Add dark mode support", "description": "Implement a dark theme in all views.", "status": "in_progress", "priority": "high", "notify": true, "progress": 40, "status_note": "Updating the color tokens", "source": "agent", "created_at": 1760112000.412, "updated_at": 1760112300.9, "completed_at": null, "seen_at": 1760112010.2, "host": "studio-mac" } ``` | Field | Values | |-------|--------| | `status` | `todo`, `in_progress`, `done`, `cancelled` | | `priority` | `low`, `medium`, `high`, `urgent` | | `notify` | If `true`, a change by an agent sends a visible notification. If `false`, the devices update silently. | | `progress` | An integer from 0 to 100, or `null` | | `status_note` | One short line that says what the agent does now, or `null` | | `source` | `agent` if an API key made the task, `app` if the account made it | | `completed_at` | Set when `status` becomes `done`. `null` when the status is a different one. | | `seen_at` | When the agent of the key first got the task, or `null`. See [Inbox](inbox.md#task-field). | | `host` | The [host name](overview.md#host-name) that came with the agent request that made the task. `null` for a task from the app, and when the agent sent no host name. It does not change later. | A task from before the fields `seen_at` and `host` does not have them in its JSON until its next change. Read a missing field as `null`. ### Question ```json { "id": "qst_01jb2n4r8t", "key_id": "key_01jb2k3v9d", "task_id": "tsk_01jb2m9x4e7q", "prompt": "Deploy to production now?", "kind": "confirm", "options": ["Yes", "No"], "status": "pending", "answer": null, "created_at": 1760112400.0, "answered_at": null, "expires_at": 1760116000.0 } ``` | Field | Values | |-------|--------| | `kind` | `confirm`, `choice`, `text` | | `options` | `["Yes", "No"]` for `confirm`. 2 to 4 labels from the agent for `choice`. `[]` for `text`. | | `status` | `pending`, `answered`, `expired`, `cancelled` | | `answer` | The selected option label, or the typed text. `null` until `status` is `answered`. | | `task_id` | The task that the question is about, or `null` | | `expires_at` | `created_at` plus `expires_in` | A question leaves `pending` one time, and does not change after that. | Event | New `status` | |-------|--------------| | The person answers | `answered` | | `expires_at` passes | `expired`, within 10 seconds, and at once when a client reads or answers the question | | The agent cancels the question, or its task is deleted | `cancelled` | ## Accounts | Method | Path | Auth | Body | Result | |--------|------|------|------|--------| | `POST` | `/v1/accounts` | none | `{}` | `201 {"account": Account, "token": "mca_..."}` | | `GET` | `/v1/account` | account | | `200 Account` | | `PATCH` | `/v1/account` | account | `{"settings": {"live_activities": false}}` | `200 Account` | | `DELETE` | `/v1/account` | account | | `204`. Deletes the account, its devices, keys, tasks, and questions. | `POST /v1/accounts` has a strict rate limit for each IP address. A server that uses App Attest needs an `attestation` in the body, from `POST /v1/attest/challenge`. See [App integrity](app-integrity.md). ## Devices | Method | Path | Auth | Result | |--------|------|------|--------| | `PUT` | `/v1/devices/{device_id}` | account | `200 Device`. Makes or replaces the device. | | `DELETE` | `/v1/devices/{device_id}` | account | `204` | The body of `PUT` has the fields of the [Device](#device) without `id` and `updated_at`. The three tokens are optional. - A token that is not in the body is `null`. Send all tokens that the device has in each `PUT`. - The response repeats the stored fields and adds `id` and `updated_at`. - One APNs token belongs to one device registration. If a `PUT` has an `apns_token` that an older registration has (for example after the app is installed again), the server sets all tokens of the older registration to `null`. - If APNs reports that a token is not valid (`410`, or `BadDeviceToken`), the server sets that token to `null`. ## Keys | Method | Path | Auth | Body | Result | |--------|------|------|------|--------| | `GET` | `/v1/keys` | account | | `200 {"keys": [Key]}`, newest first | | `POST` | `/v1/keys` | account | `{"label": "Cursor"}` | `201 {"key": Key, "secret": "mck_..."}` | | `PATCH` | `/v1/keys/{key_id}` | account | `{"label": "Cursor (work)"}` | `200 Key` | | `DELETE` | `/v1/keys/{key_id}` | account | | `204`. Deletes the key, its tasks, its questions, its inbox, and its activity log. | | `GET` | `/v1/keys/{key_id}/activity` | account | | `200 {"items": [Entry], "next_cursor": null}`. See [Activity log](#activity-log). | A deleted key, or a deleted account, is unusable when the response comes: its credential is refused, and no route returns its data. The server removes the data itself in small steps after that, and finishes a deletion that stopped. ## Tasks Both credential types can use these routes. An API key is limited to its own tasks. | Method | Path | Result | |--------|------|--------| | `GET` | `/v1/tasks` | `200 {"tasks": [Task], "next_cursor": null}` | | `POST` | `/v1/tasks` | `201 Task` | | `GET` | `/v1/tasks/{task_id}` | `200 Task` | | `PATCH` | `/v1/tasks/{task_id}` | `200 Task` | | `DELETE` | `/v1/tasks/{task_id}` | `204`. The pending questions of the task become `cancelled`. | ### List tasks | Query parameter | Meaning | |-----------------|---------| | `key_id` | Only this key. Account credential only. | | `status` | One status, or several with commas between them | | `priority` | One priority, or several with commas between them | | `limit` | 1 to 500. The default is 100. | | `cursor` | The `next_cursor` of the previous page | Tasks are sorted by `updated_at`, newest first. Continue to the next page while `next_cursor` is not `null`. If `priority` is set, a page can have fewer tasks than `limit`, or no tasks, and can still have a `next_cursor`. ### Create a task Only `title` is necessary. ```json { "key_id": "key_01jb2k3v9d", "title": "Write unit tests", "description": "", "status": "todo", "priority": "medium", "notify": true } ``` | Credential | `key_id` | |------------|----------| | Account token | Necessary. `422` with `fields.key_id` if it is missing. `404` if the account does not have the key. | | API key | Not permitted. `422` with `fields.key_id` if it is there. | ### Update a task `PATCH` accepts any subset of `title`, `description`, `status`, `priority`, `notify`, `progress`, `status_note`. - A field that is not in the body does not change. - `progress` and `status_note` accept `null`. The other fields do not. - If `status` becomes `done`, the server sets `progress` to `100`. - A `PATCH` that changes no field returns the task as it is. `updated_at` stays the same, and the server sends no event and no push. ## Questions | Method | Path | Auth | Body | Result | |--------|------|------|------|--------| | `GET` | `/v1/questions` | account or API key | | `200 {"questions": [Question]}` | | `POST` | `/v1/questions` | API key | See below | `201 Question` | | `GET` | `/v1/questions/{question_id}` | account or API key | | `200 Question` | | `POST` | `/v1/questions/{question_id}/answer` | account | `{"answer": "Yes"}` | `200 Question` | | `DELETE` | `/v1/questions/{question_id}` | API key | | `204`. Sets `cancelled`. | ### List questions `GET /v1/questions` returns the newest questions first, 100 at most. The query parameter `status` has one status, or several with commas between them. The default is `pending`. ### Ask a question Only `prompt` is necessary. ```json { "prompt": "Which environment?", "kind": "choice", "options": ["Staging", "Production"], "task_id": "tsk_01jb2m9x4e7q", "expires_in": 3600 } ``` | Field | Rule | |-------|------| | `kind` | The default is `confirm`. | | `options` | Necessary for `choice`: 2 to 4 different labels. Not permitted for `confirm` and `text`. | | `task_id` | A task of the same key. `404` if the key does not have the task. | | `expires_in` | 60 to 86,400 seconds. The default is 3,600. | A key can have 20 pending questions. One more gives `409`. ### Wait for the answer `GET /v1/questions/{question_id}?wait=30` holds the request until the status is not `pending`, for 30 seconds at most. `wait` is 0 to 50 seconds. The default is 0. - The response is the Question in each case. Look at `status`. - If `expires_at` comes before the end of the wait, the request returns at that time with the status `expired`. - If the result is still `pending`, send the request again. - A key can have 32 requests that wait at the same time on one server process, over this route and `GET /v1/inbox?wait=`. One more gets `429` with `Retry-After`. A request with `wait=0` is not counted. ### Answer a question | Case | Result | |------|--------| | The question is `pending` and the answer is valid | `200` with the status `answered` | | The question is not `pending` | `409` | | The answer is not one of the options (`confirm`, `choice`), or is empty or too long (`text`) | `422` with `fields.answer` | If two answers arrive at the same time, one gets `200` and the other gets `409`. ### Cancel a question `DELETE` gives `204` for a question that is `pending` or already `cancelled`. It gives `409` for a question that is `answered` or `expired`. ## Inbox and messages | Method | Path | Auth | Result | |--------|------|------|--------| | `GET` | `/v1/inbox` | API key | `200 {"items": [Item], "unread": 0}` | | `POST` | `/v1/messages` | account or API key | `201 Item` | | `GET` | `/v1/keys/{key_id}/inbox` | account | `200 {"items": [Item], "unread": 2}` | | `GET` | `/v1/agents` | API key | `200 {"agents": [Agent]}` | When the person creates, changes, or deletes a task of a key with the account token, or answers a question, the server adds an item to the inbox of that key. See [Inbox](inbox.md) for the objects, the query parameters, and the rules. ## Activity log The server keeps the last 200 requests of each API key for 7 days. The app shows them, so that the person can see what went wrong, copy an entry, and give it to the agent. `GET /v1/keys/{key_id}/activity` returns the entries of one key of the account, newest first. A key of a different account gives `404`. The log has no event on the event stream: get it when the person opens it. | Query parameter | Meaning | |-----------------|---------| | `limit` | 1 to 100. The default is 50. | | `cursor` | The `next_cursor` of the previous page. `next_cursor` is `null` on the last page. | ### Entry ```json { "id": "act_01jb2q7m3k", "at": 1760112300.9, "surface": "mcp", "action": "update_task", "status": "error", "code": "not_found", "message": "Task not found.", "duration_ms": 4, "client": { "name": "claude-code", "version": "2.1.290" }, "host": "studio-mac" } ``` | Field | Values | |-------|--------| | `at` | When the request came | | `surface` | `mcp` or `rest` | | `action` | See the table below | | `status` | `ok` or `error` | | `code` | The `code` of the [error](overview.md#errors), or `null` for `ok` | | `message` | A short summary of the error, or `null` | | `duration_ms` | The time from the request to the end of the response, in milliseconds. For a call that waits, it has the time of the wait. | | `client` | The MCP `clientInfo` of the request, or `null`. A REST request has none. | | `host` | The [host name](overview.md#host-name) of the request, or `null` | | Request | `action` | |---------|----------| | An MCP tool call | The tool name, for example `create_task` | | A call of a tool that the server does not have | `tools/call` | | A different MCP method | The method name, for example `tools/list` or `initialize` | | An MCP method that the server does not have | `unknown method` | | A request that the MCP endpoint refuses before it reads the method | `POST /mcp` | | A REST request | The method and the path of the route, for example `PATCH /v1/tasks/{task_id}`. The path has the names of its parameters, never an ID. | `message` is text that the server wrote, for example `Task not found.` For a request that is not valid, it has the names of the fields and not their values, for example `Invalid fields: priority, title.` A tool error has the same `code` that the REST API gives for that error. ### What has an entry One request of an API key has one entry, also a request that failed and a request above the rate limit. These requests have no entry: - A request with an account token, or with no valid credential. - A request that the server refuses before it reads the credential: an unknown path, a REST body that is not JSON, or a body above the size limit. - `GET /health`, the event stream, and the pages of the website. - An MCP notification. - A call that only waited: a read of the inbox with `wait` or `wait_seconds` that ended with no item, and a read of a question that is still `pending` after its `wait`. A call that waited and then got a result has one entry. The server writes an entry after it sends the response. Thus an entry can be absent for a moment after its request. ### What the log stores The log has only the fields of the Entry. It never has the text of a task, a question, an answer, a message, or a notification, and it never has a credential. The server deletes an entry after 7 days, keeps the newest 200 entries of a key, and deletes the log with its key and with the account. ## Live Activities | Method | Path | Auth | Body | Result | |--------|------|------|------|--------| | `PUT` | `/v1/tasks/{task_id}/live-activity` | account | `{"device_id": "dev_...", "push_token": "e5f6..."}` | `204` | | `DELETE` | `/v1/tasks/{task_id}/live-activity` | account | `{"device_id": "dev_..."}` | `204` | - The app calls `PUT` each time ActivityKit gives a new update token for the activity of that task. - The task and the device must belong to the account, else the result is `404`. - `DELETE` gives `204` also when the device has no registered activity. - The server removes the registrations of a task after it sends `end`. See [Push](push.md#live-activity) for the pushes that the server sends. --- Source: https://relaydeck.onrender.com/docs/events.md # Events `GET /v1/events` with an account token returns a stream of server-sent events (`text/event-stream`). The app uses it to show each change at once. ## Connect ```http GET /v1/events HTTP/1.1 Host: relaydeck.onrender.com Authorization: Bearer mca_... Accept: text/event-stream ``` Each event has a name and one line of JSON data. ```http event: task.updated data: {"task":{"id":"tsk_01jb2m9x4e7q","key_id":"key_01jb2k3v9d","title":"Add dark mode support","description":"","status":"in_progress","priority":"high","notify":true,"progress":40,"status_note":"Updating the color tokens","source":"agent","created_at":1760112000.412,"updated_at":1760112300.9,"completed_at":null,"seen_at":1760112010.2,"host":"studio-mac"}} ``` The server sends a comment line (`: ping`) after 20 seconds with no event, so that a proxy does not close the connection. ## Event types | Event | Data | Sent when | |-------|------|-----------| | `ready` | `{"account_id": "acc_..."}` | One time, when the stream opens | | `task.created` | `{"task": Task}` | A task is made | | `task.updated` | `{"task": Task}` | A field of a task changes, also `seen_at` | | `task.deleted` | `{"task_id": "tsk_...", "key_id": "key_..."}` | A task is deleted, or it expires | | `key.created` | `{"key": Key}` | A key is made | | `key.updated` | `{"key": Key}` | The label changes, `last_client` changes, or `last_host` changes | | `key.deleted` | `{"key_id": "key_..."}` | A key is deleted. No event comes for its tasks and its questions. | | `question.created` | `{"question": Question}` | An agent asks a question | | `question.updated` | `{"question": Question}` | The status of a question changes | | `inbox.updated` | `{"key_id": "key_...", "unread": 2}` | The server adds an item to the [inbox](inbox.md) of a key, or the agent reads items | | `account.deleted` | `{}` | The account is deleted | The objects are those of the [REST API](rest-api.md#objects). `key.updated` is not sent for each change of `last_used_at` or `counts`. Get the keys again when you need current counts. ## Reconnect The stream does not replay old events. After each connect, wait for `ready`, then get the full lists again with `GET /v1/keys`, `GET /v1/tasks`, and `GET /v1/questions`. An event that arrives after `ready` is newer than those lists. The server closes the stream in these cases. Connect again after each of them, except the first. | Case | What the client sees | |------|----------------------| | The account is deleted | `account.deleted`, then the end of the stream | | The client reads too slowly | The end of the stream | | The server loses its connection to the store | The end of the stream | | The server starts again | The end of the stream | | The account has 16 streams on one server process, and opens one more | The end of the oldest stream | If the store is not available at the time of the connect, the response is `503`. Wait for the seconds in `Retry-After`, then connect again. --- Source: https://relaydeck.onrender.com/docs/inbox.md # Inbox Each key has an inbox. It holds what the person and other agents send to that key. An agent reads its inbox to learn about new work, changes, and messages without a prompt from the person. Read [Overview](overview.md) first for authentication, errors, and limits. ## Inbox item ```json { "id": "inb_01jb2p7k3m", "kind": "task_assigned", "from": { "type": "person" }, "task_id": "tsk_01jb2m9x4e7q", "question_id": null, "text": "Fix the login loop", "created_at": 1760112500.0, "read_at": null } ``` | `kind` | When the server adds it | `text` | IDs | |--------|-------------------------|--------|-----| | `task_assigned` | The person creates a task for this key in the app | The task title | `task_id` | | `task_changed` | The person changes the title, description, status, or priority of a task of this key | One line that says what changed | `task_id` | | `task_deleted` | The person deletes a task of this key | The task title | `task_id` of the deleted task | | `answer` | The person answers a question of this key | The answer | `question_id`, and the `task_id` of the question | | `message` | The person or a different agent sends a message to this key | The message | `task_id`, if the sender gave one | - `from` is `{"type": "person"}` or `{"type": "agent", "key_id": "key_...", "label": "Cursor"}`. - `read_at` is the time when the agent got the item with `mark_read`, or `null`. - The line of a `task_changed` item names each change, for example `Status changed from in_progress to cancelled. Priority changed from medium to urgent. Title changed to "Fix the loop". Description changed.` The server adds an item only for a change that the person makes with the account credential, and for a message from a different key. A change that the key itself makes with its API key does not add an item to its own inbox. A change of only `notify`, `progress`, or `status_note` adds no item. | Item | Limit | |------|-------| | Message text | 1 to 2,000 characters. The server removes the space at its two ends. | | Unread items for each key | 200. The oldest item is removed first. | | Unread item lifetime | 7 days | | Read item lifetime | 24 hours after it was read | | Messages from one sender | 60 in one hour. One more gives `429`. | A sender is the person (the account) or one key. ## Task field A task has one more field, `seen_at`. It is the time when the agent of the key first got the task. It is `null` until then. The app uses it to show that the agent has the task. | Case | `seen_at` | |------|-----------| | An agent makes the task with its API key | The same as `created_at` | | The person makes the task in the app | `null`, until the agent gets the task | | The agent gets an inbox item with the `task_id`, also with `mark_read=false` | The time of that call | | An MCP tool result gives the task to the agent (`list_tasks`, `get_task`, `update_task`) | The time of that call | A read of the task with the REST API does not set `seen_at`. The first time counts, and `updated_at` does not change. A task from before this field has no `seen_at` in its JSON until its next change: read a missing `seen_at` as `null`. ## REST | Method | Path | Auth | Result | |--------|------|------|--------| | `GET` | `/v1/inbox` | API key | `200 {"items": [Item], "unread": 0}`. Unread items, oldest first. | | `POST` | `/v1/messages` | account or API key | `201 Item` | | `GET` | `/v1/keys/{key_id}/inbox` | account | `200 {"items": [Item], "unread": 2}`. The 50 most recent items of the key, newest first, with their read state. Does not mark items as read. | | `GET` | `/v1/agents` | API key | `200 {"agents": [Agent]}`, newest key first | ### Read the inbox `GET /v1/inbox` query parameters: | Name | Meaning | |------|---------| | `wait` | 0 to 50 seconds. The server holds the request until the inbox has an unread item. The default is 0. | | `limit` | 1 to 50. The default is 20. | | `mark_read` | `true` or `false`. The default is `true`. The returned items become read, and their `read_at` is the time of the call. | - `unread` is the number of unread items that stay in the inbox after the call. - A request that waits returns `{"items": [], "unread": 0}` after its time. Send it again. - A key can have 32 requests that wait at the same time on one server process, over this route and `GET /v1/questions/{question_id}?wait=`. One more gets `429` with `Retry-After`. - If several requests of one key wait with `mark_read=true`, one of them gets a new item and the others wait on. ### Send a message | Credential | Body | Meaning | |------------|------|---------| | Account token | `{"key_id": "key_...", "text": "Stop and rebase first."}` | From the person to that key | | API key | `{"to": "key_...", "text": "The API is ready for you."}` | From this key to a different key of the same account | - `to` is the key ID, or the exact label, of a different key of the same account. An unknown `to`, a label that two keys have, or the key itself gives `404`. - `task_id` is optional with the two credential types. It must be a task that the sender can read, else the result is `404`. The receiver can read that task only if it is a task of its own key. - `key_id` with an API key, or `to` with an account token, gives `422`. ### Agent ```json { "key_id": "key_01jb2k3v9d", "label": "Cursor", "is_self": false, "last_client": { "name": "cursor", "version": "3.2" }, "last_used_at": 1760112300.9, "host": "studio-mac" } ``` `GET /v1/agents` gives all keys of the account. `is_self` is `true` for the key of the request. `host` is the `last_host` of the key: the most recent [host name](overview.md#host-name) that the agent sent, or `null`. ## Events | Event | Data | |-------|------| | `inbox.updated` | `{"key_id": "key_...", "unread": 2}`. Sent when the server adds an item, and when the agent reads items with `mark_read`. | | `task.updated` | Also sent when `seen_at` changes. | An inbox item sends no push notification. An agent uses `notify_user` or `ask_user` to reach the person. ## MCP tools Three tools follow `get_answer` in `tools/list`. | Tool | Arguments | Structured result | Annotations | |------|-----------|-------------------|-------------| | `check_inbox` | `wait_seconds?` (0 to 50, default 0), `limit?` (1 to 50, default 20), `mark_read?` (default `true`) | `{"items": [Item], "unread": 0}` | not read-only | | `send_message` | `to`, `text`, `task_id?` | `Item` | not read-only | | `list_agents` | none | `{"agents": [Agent]}` | read-only | `check_inbox` holds the call for up to `wait_seconds` and returns as soon as the inbox has an unread item. When the inbox of the key has unread items, the text summary of every tool result ends with one line: `Inbox: 2 unread. Call check_inbox.` This is also true for a tool error. The structured result does not change. The number is the one at the start of the call. For `check_inbox`, it is the number after the call. The server instructions get two more rules: - Call `check_inbox` when you start and after each unit of work. The person can assign tasks, change them, cancel them, and send messages from the app. - A `task_changed` item with a `cancelled` status means: stop that work. ## Hooks A coding agent that supports hooks can read the inbox without a tool call. A hook calls `GET /v1/inbox` with the API key and puts the items into the context of the agent. ```bash curl -s "https://relaydeck.onrender.com/v1/inbox" -H "Authorization: Bearer $TASKS_API_KEY" ``` The [Relaydeck plugin](../plugins/relaydeck/README.md) has such hooks for Claude Code and Codex, and the install script adds them for Cursor and Gemini CLI. The items that a hook gives to the agent become read. | Moment | What the hook does | Agents | |--------|--------------------|--------| | A session starts | Puts the unread items into the context | Claude Code, Codex, Cursor, Gemini CLI | | The person sends a prompt | Puts the unread items into the context | Claude Code, Codex, Gemini CLI | | The agent is about to stop | Gives the unread items to the agent, and the agent continues. One item does not continue the agent two times. | Claude Code, Codex, Cursor, Gemini CLI | | The agent waits for the person | Holds one request with `wait=50` and `mark_read=false`, and wakes the agent when an item comes | Claude Code | Codex runs the hooks of a plugin only after the person trusts them with `/hooks`. An agent without hooks gets the line `Inbox: 2 unread. Call check_inbox.` on its tool results. --- Source: https://relaydeck.onrender.com/docs/push.md # Push The server sends pushes through the Apple Push Notification service (APNs) to the devices of an account. A device registers its tokens with `PUT /v1/devices/{device_id}`. See [REST API](rest-api.md#devices). ## Overview of the pushes | Push | `apns-push-type` | Token | Topic | |------|------------------|-------|-------| | Visible notification | `alert` | `apns_token` | The bundle ID | | Silent notification | `background` | `apns_token` | The bundle ID | | Live Activity start | `liveactivity` | `push_to_start_token` | The bundle ID plus `.push-type.liveactivity` | | Live Activity update and end | `liveactivity` | The token from `PUT /v1/tasks/{task_id}/live-activity` | The bundle ID plus `.push-type.liveactivity` | | Widget reload | `widgets` | `widget_push_token` | The bundle ID plus `.push-type.widgets` | A device with the platform `watchos` gets the topic of the watch app, if the server has a bundle ID for it. The server sends a push after it sends the HTTP response. A push does not make a request slower. ## Who gets which notification | Change | Notification | |--------|--------------| | An agent creates, updates, or deletes a task with `notify: true` | Visible | | An agent changes only `progress` or `status_note` | Silent. This is a progress tick, and an agent sends many of them. | | An agent changes a task with `notify: false` | Silent | | The app (an account token) changes a task | Silent, so that the other devices of the account update | | An agent calls `notify_user` | Visible | | An agent asks a question | Visible | | The status of a question changes | Silent | Each notification goes to each device of the account that has an `apns_token`. ## Task notifications `event` is `task.created`, `task.updated`, or `task.deleted`. ### Visible ```json { "aps": { "alert": { "title": "Claude Code", "subtitle": "Task done", "body": "Add dark mode support" }, "sound": "default", "thread-id": "key_01jb2k3v9d", "category": "TASK", "interruption-level": "active" }, "event": "task.updated", "task_id": "tsk_01jb2m9x4e7q", "key_id": "key_01jb2k3v9d" } ``` | Field | Value | |-------|-------| | `alert.title` | The label of the key | | `alert.subtitle` | `New task`, `Task started`, `Task done`, `Task cancelled`, `Task updated`, or `Task deleted` | | `alert.body` | The title of the task | | `interruption-level` | `time-sensitive` if the priority of the task is `urgent`, else `active` | | `apns-collapse-id` header | The task ID. A newer notification for the same task replaces the older one. | ### Silent ```json { "aps": { "content-available": 1 }, "event": "task.updated", "task_id": "tsk_...", "key_id": "key_..." } ``` ## Message notifications A `notify_user` call sends this notification. It has no collapse ID. ```json { "aps": { "alert": { "title": "Claude Code", "subtitle": "Approve the deploy", "body": "The build is ready." }, "sound": "default", "thread-id": "key_01jb2k3v9d", "category": "MESSAGE", "interruption-level": "active" }, "event": "message", "key_id": "key_01jb2k3v9d", "task_id": "tsk_01jb2m9x4e7q" } ``` - `alert.subtitle` is the title that the agent gives. `alert.body` is the body that the agent gives. - `task_id` is there only if the agent gave one. If that task is `urgent`, `interruption-level` is `time-sensitive`. ## Question notifications ### New question ```json { "aps": { "alert": { "title": "Claude Code", "subtitle": "Question", "body": "Deploy to production now?" }, "sound": "default", "thread-id": "key_01jb2k3v9d", "category": "QUESTION_CONFIRM", "interruption-level": "time-sensitive", "mutable-content": 1 }, "event": "question.created", "question_id": "qst_01jb2n4r8t", "kind": "confirm", "options": ["Yes", "No"], "task_id": "tsk_01jb2m9x4e7q", "key_id": "key_01jb2k3v9d" } ``` | Field | Value | |-------|-------| | `category` | `QUESTION_CONFIRM`, `QUESTION_CHOICE`, or `QUESTION_TEXT`, from the `kind` of the question | | `options` | The options of the question. `[]` for the kind `text`. | | `task_id` | The task of the question, or `null` | | `apns-collapse-id` header | The question ID | | `apns-expiration` header | The `expires_at` of the question. APNs does not deliver the notification after that time. | The app answers with `POST /v1/questions/{question_id}/answer`. See [REST API](rest-api.md#answer-a-question). ### Status change When a question is answered, expired, or cancelled, the server sends a silent notification with the same collapse ID. Each device then removes the delivered notification of that question. ```json { "aps": { "content-available": 1 }, "event": "question.updated", "question_id": "qst_...", "key_id": "key_..." } ``` ## Live Activity The ActivityKit attributes type name is `TaskActivityAttributes`. The JSON keys below are exact, because ActivityKit decodes them with the default `JSONDecoder`. Attributes: ```json { "taskID": "tsk_01jb2m9x4e7q", "keyLabel": "Claude Code" } ``` Content state: ```json { "title": "Add dark mode support", "status": "in_progress", "progress": 40, "note": "Waiting for you", "updatedAt": 1760112300.9, "question": { "id": "qst_01jb2n4r8t", "prompt": "Deploy to production now?", "kind": "confirm", "options": ["Yes", "No"] } } ``` - `progress` and `note` can be `null`. - `question` is the oldest pending question of the task. It is `null` when the task has no pending question. The server sends these pushes when the account setting `live_activities` is `true`. | Case | Push | |------|------| | An agent moves a task to `in_progress`, or creates it with that status | `start` to each device that has a `push_to_start_token` and no registered activity for that task | | A task with a registered activity changes `title`, `status`, `progress`, or `status_note` | `update` | | A question for a task with a registered activity is created, answered, expired, or cancelled | `update` | An `update` goes at once when the activity got none in the last 5 seconds. Else the server sends one `update` with the latest state of the task when those 5 seconds end. Thus an agent that sends many progress ticks gives one push in 5 seconds for each task, and the last change is never lost. Each Live Activity push has the APNs priority `10`, because a push with the priority `5` can wait for a long time on a locked device. | The task becomes `done` or `cancelled`, or the task or its key is deleted | `end`, with the final content state and a dismissal date 15 minutes later | Each payload has `aps.timestamp`, `aps.event` (`start`, `update`, or `end`), and `aps.content-state`. A `start` and an `update` also have `stale-date` (30 minutes after the push: the system then shows the activity as stale) and `relevance-score` (`100` when the task has a pending question, `75` when the task is `in_progress`, else `50`; the activity with the highest score goes first). A `start` also has `attributes-type`, `attributes`, and an `alert` with the key label as `title` and the task title as `body`, because ActivityKit needs one. An `end` also has `dismissal-date`. ## Widget reload A device that registers a `widget_push_token` gets a WidgetKit push when a task or a question of the account changes. WidgetKit then reloads the widget timelines. The push carries no task data. ```json { "aps": { "content-changed": true } } ``` - A device gets one widget push in 15 seconds at most. - If more changes come in those 15 seconds, the device gets one more push when the 15 seconds end. Thus the last change is not lost. - The system also limits how often it delivers these pushes. ## Tokens that are not valid If APNs answers `410`, or `400` with the reason `BadDeviceToken`, the server sets that token of the device to `null`, or removes that Live Activity registration. The app registers a new token with the next `PUT`. --- Source: https://relaydeck.onrender.com/docs/app-integrity.md # App integrity A server can ask for proof that a request comes from the real app. There are two checks. Each check is off until the operator configures it. Requests with an API key, and the MCP endpoint, do not use these checks. | Check | Setting that turns it on | Applies to | |-------|--------------------------|------------| | App Attest | `APP_ATTEST_APP_IDS` | `POST /v1/accounts` | | App code | `APP_TOTP_SECRET` | `POST /v1/attest/challenge`, `POST /v1/accounts`, and each request with an account token | ## App Attest With this check, only an app that Apple attests can make an account. The account token is the credential after that. A second device of the same account does not attest again. ### Flow 1. The app makes a key with `DCAppAttestService.generateKey`. 2. The app gets a challenge from the server. 3. The app calls `attestKey` with the key ID and the client data hash. The client data hash is the SHA-256 hash of the UTF-8 bytes of the challenge text. 4. The app sends the attestation in `POST /v1/accounts`. ### Get a challenge `POST /v1/attest/challenge` needs no credential and no body. ```json { "challenge": "mZ3vV5mVZc0c1d9oXoY3y6W4mJ8H2Qk0bP7r", "expires_in": 300 } ``` A challenge works one time, and for 300 seconds. An attempt that fails also uses the challenge. The route works also on a server that has the check off, so the app can use one flow for all servers. ### Create the account ```json { "attestation": { "key_id": "", "challenge": "", "object": "" } } ``` | Case | Result | |------|--------| | The attestation is valid | `201 {"account": Account, "token": "mca_..."}` | | The check is on and the body has no `attestation` | `401` with the code `attestation_required` | | The challenge is not known, is used, or is expired | `401` with the code `attestation_invalid` | | The attestation fails validation | `401` with the code `attestation_invalid` | | The attested key has an account already | `401` with the code `attestation_invalid` | | `key_id` or `object` is not base64 text, or `key_id` is not 32 bytes | `422` | | The check is off | The server ignores `attestation` and makes the account | ### What the server validates The server follows Apple's procedure in [Validating apps that connect to your server](https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server). 1. The certificates in `x5c` chain to the Apple App Attestation Root CA, and each is valid at the time of the request. 2. The nonce in the credential certificate is the SHA-256 hash of the authenticator data plus the client data hash. 3. The key ID is the SHA-256 hash of the public key in the credential certificate. 4. The RP ID hash is the SHA-256 hash of one of the configured app IDs (`TEAMID.bundle.identifier`). 5. The counter is 0. 6. The `aaguid` is the one of the configured environment: `production` or `development`. 7. The credential ID is the key ID. 8. If the authenticator data has the `apple_validation_category_01` extension, its value is 1 to 6. The values 0, 7, 8, and 9 are refused. The server keeps the key ID, the public key, and the counter with the account. One attested key makes one account. When the account is deleted, the key can make a new account. A build from Xcode uses the `development` environment unless its entitlement says `production`. A server accepts one environment. ## App code With this check, the app proves that it has a secret that is built into it. The app sends a time-based one-time password (TOTP, RFC 6238) in the header `X-App-Code`. ```http GET /v1/account HTTP/1.1 Host: relaydeck.onrender.com Authorization: Bearer mca_... X-App-Code: 46119246 ``` | Parameter | Value | |-----------|-------| | Algorithm | HMAC-SHA-256 | | Digits | 8 | | Time step | 30 seconds | | Secret | The bytes of the base32 text in `APP_TOTP_SECRET` | | Accepted | The code of the current step, and of one step before or after it | A missing or wrong code gives `401` with the code `app_code_invalid`. The header is necessary on `POST /v1/attest/challenge`, on `POST /v1/accounts`, and on each request with an account token, also `GET /v1/events`. ```swift import CryptoKit import Foundation func appCode(secret: Data, at date: Date = .now) -> String { var counter = UInt64(date.timeIntervalSince1970 / 30).bigEndian let mac = HMAC.authenticationCode( for: Data(bytes: &counter, count: 8), using: SymmetricKey(data: secret) ) let bytes = Array(mac) let offset = Int(bytes[bytes.count - 1] & 0x0f) let number = bytes[offset..