API Reference
cueyou API guide
cueyou turns an Android phone into a programmable, full-screen takeover surface. Your code POSTs to the cueyou API, the server pushes via FCM, and the phone takes over its own screen with a title, a markdown description, an optional source chip, and a palette driven by urgency.
This document covers the public REST API. There is also a tRPC surface at /trpc/* used by the Android app and dashboard — that is not part of the public contract.
Base URL
https://api.cueyou.appAuthentication
Every request needs an API key in the Authorization header:
Authorization: Bearer att_xxxxxxxxxxxxxxxxxxxxxxxxKeys are minted in the dashboard. They start with att_ and are shown only once at creation — store them as secrets. A revoked or unknown key produces 401 {"error":"unauthorized"}. Firebase ID tokens are also accepted by the same handlers but are intended for the first-party app, not for integrations.
Endpoints at a glance
| Method | Path | Purpose | Blocking? |
|---|---|---|---|
| GET | /api/devices | List devices registered on the account | No |
| POST | /api/alert | Fire a screen-takeover alarm on a device | No |
| POST | /api/prompt | Ask the device a question and wait for an answer | Yes (long-poll) |
All bodies are JSON. All responses are JSON. All timestamps are ISO 8601 strings.
/api/devices
Returns the devices on the account that owns the API key. Use this to discover the deviceId you'll pass to /api/alert and /api/prompt.
Example
curl https://api.cueyou.app/api/devices \
-H "Authorization: Bearer $CUEYOU_API_KEY"Response — 200
{
"devices": [
{
"id": "65f3a1b2c4d5e6f7a8b9c0d1",
"name": "Pixel 9",
"platform": "ANDROID",
"lastSeenAt": "2026-05-07T09:14:02.000Z"
}
]
}/api/alert
Fire-and-forget. The server delivers an FCM push and the phone immediately takes over its screen. The HTTP call returns as soon as FCM accepts the push — it does not wait for the user to dismiss.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
| deviceId | string | no | Device id from GET /api/devices. Omit to fan out to every Android device on the account. |
| title | string | yes | 1–100 chars; shown large on the takeover screen |
| description | string | yes | 0–2000 chars, rendered as markdown. See Shared concepts. |
| urgency | string | no | "info" | "normal" | "critical". Default "normal". See Shared concepts. |
| source | string | no | ≤ 80 chars. Shown as a chip at the top of the takeover. |
Example
curl https://api.cueyou.app/api/alert \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Build failed",
"description": "main is red — last green was 12 minutes ago"
}'Example — critical, with markdown and source
curl https://api.cueyou.app/api/alert \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Production 5xx spike",
"description": "**p99 latency** doubled in the last 5m.\n\n- region: `us-east-1`\n- error rate: 8.4%\n\n[Open dashboard](https://grafana.example.com)",
"urgency": "critical",
"source": "datadog · synthetics"
}'Response — 200
{
"alertId": "f8b71c98-...",
"sentAt": "2026-05-07T09:14:02.123Z",
"delivered": true,
"deviceCount": 2,
"blockedCount": 0
}If the user has the device disabled in their account settings (or quiet hours, etc.), the call still returns 200 but with:
{ "alertId": "...", "sentAt": "...", "delivered": false, "reason": "blocked_by_settings" }Fan-out. When you omit deviceId, the alert is delivered to every Android device registered on the account. deviceCount is how many devices were targeted and blockedCount is how many of those were suppressed by account settings (disabled, quiet hours, muted scopes). An account with zero registered devices still returns 200 with "deviceCount": 0, "blockedCount": 0 — it is not an error.
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid_json"} | Body wasn't valid JSON |
| 400 | {"error":"invalid_input","details":...} | Schema validation failed |
| 401 | {"error":"unauthorized"} | Missing/invalid/revoked API key |
| 404 | {"error":"Device not found for this account"} | deviceId doesn't belong to this account |
| 502 | {"error":"FCM rejected the push: ..."} | Upstream FCM failure |
| 500 | {"error":"internal_error"} | Anything else |
/api/prompt
Ask the device a question and block until the user answers, dismisses, or the timeout fires. The server holds the HTTP connection open until a terminal state is reached. Pick this when you actually need a decision back, not just attention.
There are two modes, selected by the shape of the request:
- Options mode — pass
options: (string | Option)[]. The device shows horizontally swipeable cards, one per option;answerwill be the picked option'svalue. Plain strings are still accepted as a shorthand and mixed arrays are fine. - Text mode — omit
options. The device shows a free-text field;answeris whatever the user typed (up to 1000 chars).
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
| deviceId | string | no | Device id from GET /api/devices. Omit to fan out to every Android device on the account. |
| title | string | yes | 1–100 chars |
| description | string | yes | 0–2000 chars, rendered as markdown. See Shared concepts. |
| urgency | string | no | "info" | "normal" | "critical". Default "normal". |
| source | string | no | ≤ 80 chars. Shown as a chip at the top of the takeover. |
| options | (string | Option)[] | no | 1–10 entries. Plain strings or Option objects (see below). Omit for text mode. |
| timeoutMs | number | no | 5,000–900,000 (5s–15m). Default 300000 (5m). |
The Option object
| Field | Type | Required | Notes |
|---|---|---|---|
| label | string | yes | 1–200 chars. The text shown on the card. |
| value | string | no | 1–100 chars. Returned as answer when picked. Defaults to label. |
| primary | boolean | no | Marks this option as the recommended path. Only the first primary: true is treated as primary. |
When you pass a plain string, it's equivalent to { label: s, value: s }. The single-option label cap is 200 chars.
Example — yes/no (plain strings, legacy shape)
curl https://api.cueyou.app/api/prompt \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Approve deploy?",
"description": "v2.18.0 to production",
"options": ["Yes","No"],
"timeoutMs": 60000
}' \
--max-time 70With plain strings, answer comes back as "Yes" or "No" verbatim.
Example — structured options with primary, urgency, and source
curl https://api.cueyou.app/api/prompt \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Run destructive migration?",
"description": "Drops `legacy_events` (≈ 4.2M rows). **Irreversible** without a restore.",
"urgency": "critical",
"source": "claude-code · pre-commit",
"options": [
{ "label": "Yes, run the migration now", "value": "yes", "primary": true },
{ "label": "No, abort", "value": "no" },
{ "label": "Show me the SQL first", "value": "dry-run" }
],
"timeoutMs": 120000
}' \
--max-time 130Long, descriptive labels keep the user oriented; short values keep your code clean.
Example — free text
curl https://api.cueyou.app/api/prompt \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Override reason?",
"description": "Type a short note for the audit log"
}' \
--max-time 310Response — 200
{
"promptId": "8d2c61aa-...",
"status": "answered",
"answer": "yes",
"sentAt": "2026-05-07T09:14:02.123Z",
"resolvedAt": "2026-05-07T09:14:11.456Z"
}status values:
| Value | Meaning | answer |
|---|---|---|
| answered | User picked an option / typed a reply | Picked option's value (or typed string in text mode) |
| dismissed | User actively dismissed the prompt | null |
| timeout | timeoutMs elapsed before any user action | null |
| blocked | Prompt suppressed by account settings; never shown to the user | null |
Errors
Same shape as /api/alert. The most likely ones are 401, 404, and 502.
timeoutMs plus a few seconds of slack — otherwise your client will give up before the server resolves the prompt.Fan-out & first-responder-wins. When you omit deviceId, the prompt is sent to every Android device on the account and the first one to answer (or dismiss) wins — its response is what you get back. The other phones keep showing the prompt until the user clears it locally, but their input no longer affects the API call. If the account has no registered devices, the request returns 404 immediately rather than long-polling into a void.
Choosing alert vs. prompt
- Use
/api/alertwhen you only need to grab attention — a build failed, an error spiked, a long task finished. Returns immediately. - Use
/api/promptwhen you need a decision — gating a destructive operation, asking for an override, collecting a free-form note. Blocks until the user responds.
A common pattern is a Claude Code (or CI) hook that calls /api/alert on notifications and /api/prompt on PreToolUse for risky tools so the user can approve from their phone.
Operational notes
- Account settings can suppress delivery. Disabled devices, muted scopes, and quiet hours all flow through the same
getEffectivecheck. Alerts come back withdelivered: false, reason: "blocked_by_settings". Prompts come back withstatus: "blocked". In both cases the call returns200— handle these as non-errors. - Idempotency. There is no idempotency key. Retrying a
5xxmay produce duplicate phone alarms. Prefer surfacing the failure to the caller over blind retries. - Rate limits. Not enforced server-side today. Treat the phone as the rate limiter — repeated alarms are noisy and will train the user to ignore them.
- API key hygiene. Each integration should have its own key labelled in the dashboard. Revoke on leak; the next request with that key will get
401. - Urgency is visual-only today.
urgencydrives the takeover palette but does not currently change the sound. Per-tier ringing is a known follow-up.