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:
- Overview: the rules that all surfaces share.
- Quickstart: connect an agent.
- MCP: the tools of an agent.
- REST API: the objects and the routes.
- Events: the live event stream.
- Inbox: what the person and other agents send to an agent.
- Push: notifications, Live Activities, and widget reloads.
- App integrity: App Attest and the app code.
- Self-hosting: 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. |
| Activity entry | act_ |
One request of a key, in the 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_casefields. - 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_<secret> |
The Apple app | The full account |
| API key | Bearer mck_<secret> |
Agents and scripts | The tasks and the questions of that key |
- An API key is also accepted in the header
X-API-Key: mck_<secret>. - The MCP endpoint also accepts
?key=mck_<secret>, 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.
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.
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_hostof the Key, and puts the host name on each Task that the request makes and on each entry of the 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.
{ "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. |
| 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. |
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 | 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.