The Miles API
Ask Miles in natural language. Process meeting transcripts. Sync contacts and tasks. Subscribe to webhook events. Bearer-token auth, JSON in, JSON out — same brain that powers the Miles app, exposed as a REST API.
Quick start
Three minutes from zero to "what was my last meeting about?"
1. Mint an API key
Open Settings → API Keys in the Miles app, click New key, name it after the integration ("Zapier production", "GPT custom action", etc.), and copy the value once — Miles never shows it again.
Keys are prefixed miles_live_. Treat them like passwords — they grant full read/write access to that user's data. Revoke instantly from the same Settings page.
2. Verify connectivity
# Public — no key needed curl https://api.heymiles.app/api/v1/health # Authenticated — replace with your key curl https://api.heymiles.app/api/v1/me \ -H "Authorization: Bearer miles_live_..."
3. Ask Miles something
curl -X POST https://api.heymiles.app/api/v1/ask \ -H "Authorization: Bearer miles_live_..." \ -H "Content-Type: application/json" \ -d '{"input":"What was my last meeting about?"}'
That's it. Every endpoint below follows the same shape: bearer auth, JSON body, JSON response. No SDK required.
Authentication
All authenticated endpoints expect a bearer token in the Authorization header:
Authorization: Bearer miles_live_<32-character-secret>
Tokens are bcrypt-hashed at rest. Miles only knows the prefix (miles_live_a…) for display. Lose the value? Mint a new key and revoke the old one.
Rate limits
60 requests per minute, per key. Every response carries headers so you can self-throttle:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Allowed per minute (always 60 today) |
X-RateLimit-Remaining | Calls left in the current window |
X-RateLimit-Reset | Unix timestamp when the window resets |
Retry-After | Seconds to wait (only on 429) |
Hitting the limit returns HTTP 429. Need more throughput? Mint a second key for the second workload.
Errors
Errors come back as JSON with a stable shape:
{
"error": {
"code": "invalid_request",
"message": "`input` is required and must be a non-empty string."
}
}
| Status | code | When |
|---|---|---|
400 | invalid_request | Bad input. message tells you which field. |
401 | unauthorized / invalid_api_key | Missing, malformed, or revoked key. |
404 | not_found | Resource doesn't exist or isn't yours. |
409 | conflict / batch_rolled_back | The card changed since your ifUpdatedAt — re-read and retry. Or an atomic batch failed at index; nothing was applied. Engineer tasks use 409 for setup problems (engineer_not_deployed, mac_not_set_up, …) — see Engineer handoffs. |
412 | gmail_not_connected | Endpoint needs Gmail/Calendar — connect in the Miles app. |
413 | payload_too_large | Bulk endpoint hit its sync cap (100 contacts, 200 ids, 100 batch operations). |
429 | rate_limited | Slow down — see Retry-After. |
500 | internal_error | Something on our side. Retry with backoff. |
Ask Miles
Single endpoint that wraps the entire Miles brain. Lets you ask anything in natural language.
Routes the input through Miles's intent parser and the matching handler — calendar, inbox, tasks, memory, transcripts, and more. Returns a natural-language response plus any structured data Miles surfaced.
Texting someone ("Text Dana with this exact message: running 10 late") goes through the same path as POST /api/v1/texts: it sends now when the recipient's tier allows, or waits for the user's OK. data.textSend carries the result (status, needsApproval, canConfirm, approveUrl), and response says it in a sentence you can show as is. When it waits and canConfirm is true, ask the user and POST /api/v1/texts/:id/confirm; otherwise show approveUrl and poll GET /api/v1/texts/:id.
Request body
input (string, required) | The question or instruction. |
history (array, optional) | Prior turns, each { role, content }, for multi-turn context. |
documentId (string, optional) | Reference an uploaded document Miles has parsed. |
curl -X POST https://api.heymiles.app/api/v1/ask \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"input":"Find inbox info for my 4pm meeting"}'
{
"intent": "meeting_prep",
"response": "From your inbox, three threads relate to your 4pm with Sarah...",
"data": {
"meetings": [{ "summary": "Pricing review", "time": "4:00 PM - 4:30 PM" }],
"emails": [{ "subject": "Re: Pricing draft", "from": "sarah@…", "snippet": "…" }]
},
"navigation": "/meetings/<id>/prep",
"suggestedFollowups": ["Draft a follow-up", "What's after this meeting?"]
}
Transcripts
Drop a meeting transcript in, get a Fireflies-style structured digest back.
Runs the transcript through Miles's processing pipeline: rich markdown summary (lead + Overview + sectioned notes), action items with priorities, decisions with reasoning, topics, attendees, and a drafted follow-up email. Side-effect: creates Tasks and Action records in the user's app.
meetingTitle (string, required) | Display title. |
rawTranscript (string, required) | The full transcript. Min 20 chars. |
meetingDate (ISO string, optional) | Defaults to now. |
attendees (string[], optional) | Names or emails. |
externalId (string, optional) | Your own id for dedup. |
source (string, optional) | Defaults to "api". |
curl -X POST https://api.heymiles.app/api/v1/transcripts \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{ "meetingTitle": "Vectris Q2 sync", "meetingDate": "2026-05-09T14:00:00Z", "attendees": ["Bill Barry", "Vicki Chen"], "rawTranscript": "Speaker 1: Lets review pricing..." }'
Response
{
"transcriptId": "clx…",
"summary": "Bill and Vicki aligned on Q2…\n\n**Overview**\n- …",
"actionItems": [{ "title": "Send pricing deck by EOD Friday", "priority": "immediate" }],
"decisions": [{ "topic": "Pricing model", "decision": "Per-seat for Q2" }],
"topics": ["pricing", "Q2 launch"],
"tasksCreated": 3,
"memoryRecords": 5,
"followUpDrafted": true
}
List the Recall.ai bot recordings available for grabbing. Each record carries a botId (use it directly with /transcripts/grab for the cheapest match), meeting title/date, current status, and whether it's already been processed.
Pull a transcript from a Recall.ai bot recording (Miles' own bot) and run it through the processing pipeline. Equivalent to the in-app "Grab Transcript" button, exposed for automation. Either pass botId directly (best) or any of meetingTitle, meetingDate, meetingId and Miles fuzzy-matches a recording.
{ "botId": "abc123" }
// — or —
{ "meetingTitle": "Q2 Pricing Call", "meetingDate": "2026-05-09T14:00:00Z" }
Response status values
processed | Transcript ready — payload includes summary, actionItems, decisions, topics, transcriptId, tasksCreated. |
already_processed | Returns the existing transcriptId — fetch full detail via /v1/meetings/:id. |
bot_active | Bot is still in the meeting. Retry after it ends. |
async_transcribing | Recall.ai is generating the transcript (2-5 min). Retry the same call later. |
no_transcript_yet | Recording exists but transcript not built yet. Retry. |
no_recording | Bot finished but no recording exists — bot likely failed to join. |
no_bot (404) | No bot matches the given criteria. |
Meetings
Read past meetings — list, single, "the last one", or delete.
The single most recent processed meeting with full detail. Powers "what was my last meeting about?" use cases without needing to list-then-fetch.
curl https://api.heymiles.app/api/v1/meetings/last \
-H "Authorization: Bearer $KEY"
List processed meetings, most-recent-first. range is past (default) or today. Returns brief previews + counts.
Full detail for one meeting: rich markdown summary, raw transcript, actionItems, decisions, topics, followUpDraft.
Remove a meeting and its derived records (action items, decisions, follow-up draft, memory entries). Returns { deleted: true }.
Delete up to 200 meetings in one call. Body: { "ids": [...] }. Returns { deleted, errors, requested }.
Contacts
Sync contacts in and out of Miles's CRM (Memory → People). Upsert by email, partial updates, and free-form tags.
Create or update a contact. Upserts by (userId, email). Email is optional when name or company is present. tags are trimmed + lowercased (max 20) and merged with existing tags by default — send "tagsMode": "replace" to make your payload authoritative. Every response includes tags: string[].
{
"name": "Sarah Chen",
"email": "sarah@weka.vc",
"company": "Weka Ventures",
"title": "Partner",
"notes": "Met at Demo Day. Lead on early-stage.",
"tags": ["investor", "warm"],
"tagsMode": "merge"
}
Sync up to 100 contacts in one call — each may carry its own tags; a request-level tagsMode applies to the batch. Returns { created, updated, skipped, errors, total }. Perfect for "new row in Sheets → push to Miles" Zapier flows.
{ "contacts": [
{ "name": "Sarah Chen", "email": "sarah@weka.vc", "tags": ["investor"] },
{ "name": "Bill Barry", "email": "bill@modius.com", "title": "CEO", "tags": ["ais6", "speaker"] }
]}
Paginated search. q matches across name / email / company / notes. tag=investor,warm returns contacts carrying all listed tags.
Every tag in use, with contact counts: { tags: [{ tag, count }], count }.
Fetch one contact by id.
Add tags to a contact (idempotent). Body { "tags": ["speaker"] }. Use PUT on the same path to replace the whole set ([] clears), and DELETE /api/v1/contacts/:id/tags/:tag to remove one. All return the updated contact.
Tasks
Push items onto Miles's radar. Mirrors to the in-app Actions page automatically.
Create a task. Required: title. Validated horizon: right_now | short_term | horizon. Validated status: pending | in_progress | completed | cancelled.
{
"title": "Send pricing proposal to Sarah",
"horizon": "right_now",
"dueDate": "2026-05-12",
"description": "Per the Vectris call — needs the per-seat tier laid out."
}
List tasks. Filter by horizon and/or status.
Memory
Stash arbitrary key/value facts that Miles remembers across sessions.
Upsert a memory by (userId, key). Required: category, key, value. Keys and categories Miles uses for its own state (such as keys starting slack_user_ or chat_pending_op_, and the integration category) are reserved; writing one returns 400 reserved_key.
{
"category": "preference",
"key": "preferred_meeting_time",
"value": "Mornings before 11am"
}
Search memory. q matches across key and value.
Delete a memory entry by id.
Decisions
Every decision Miles has captured. Each has topic, decision, context, and the source meeting.
Sprints
Read team sprint boards, and create, move, edit, or delete cards — one at a time or a whole board in one call.
Lists every sprint (active / planned / completed) with task and done counts. Each one also carries isMaster, parentSprintId, phasePosition and phaseName. A master holds no cards, so its counts are live totals from its sub-boards.
Full detail with stages and tasks. Each task carries stageId, stageName, status, priority, blocked reason, due date, primary assignee, full assignees roster, category, externalId, and updatedAt. Read stage ids here before writing — stages are addressed by id (names aren't unique).
Create a card. Defaults to the first stage. Assign by assigneeIds, assigneeEmails (team members only), and/or assigneeAgentIds. Give it an externalId (your CRM row / sheet id — unique per sprint) and a retried create updates that card instead of duplicating it (200 instead of 201). status is derived from the stage. category is an optional free-text board label (max 40 chars) — the in-app board can be filtered down to one category.
{
"title": "Draft AIS6 sponsor deck",
"stageId": "3f1c…",
"priority": "high",
"dueDate": "2026-09-01",
"assigneeEmails": ["troy@ignitegtm.com"],
"category": "Finance",
"externalId": "crm-42"
}
Edit, move, or reassign a card. Any of title, description, stageId, priority, dueDate, blockedReason, category, externalId, assignee keys (sending any assignee key replaces the whole roster; [] unassigns). Moving to a Done-type stage sets status: "done" + completedAt. Add ifUpdatedAt (the card's last updatedAt) to make the write conditional — you get 409 conflict with the card's current state if someone moved it first. DELETE on the same path removes the card.
Update a whole board in one request: up to 100 ordered create / update / delete operations, each with exactly the granular body above. Give a create a ref and later ops can address that card as "$ref". By default each op commits on its own and failures are isolated per item; "atomic": true makes it all-or-nothing (a failure returns 409 batch_rolled_back with the failing index and cause); "dryRun": true validates everything — refs, stages, assignees, ids — and writes nothing. A malformed op fails the whole batch with 400 + index before anything runs. Only real stage moves emit sprint.stage_changed / task.completed webhooks, after commit. For CSV / CRM sync: put an externalId on every create and re-run the same batch freely — it converges instead of duplicating.
{
"operations": [
{ "op": "create", "ref": "row-1", "data": { "title": "Book venue", "externalId": "sheet-1" } },
{ "op": "update", "taskId": "$row-1", "data": { "stageId": "<in-progress-stage-id>" } },
{ "op": "update", "taskId": "8f0c…", "data": { "priority": "critical" } },
{ "op": "delete", "taskId": "2b1e…" }
],
"atomic": false,
"dryRun": false
}
{
"results": [
{ "index": 0, "op": "create", "ref": "row-1", "ok": true, "created": true, "task": { … } },
{ "index": 1, "op": "update", "taskId": "…", "ok": true, "task": { … } },
{ "index": 3, "op": "delete", "taskId": "…", "ok": false, "error": { "code": "not_found", "message": "Task not found" } }
],
"succeeded": 3, "failed": 1, "total": 4, "atomic": false
}
Copy an existing Miles Action or Task onto the board as a card. Body { "source": "action" | "task", "sourceId", "stageId"? }. Preserves the assignee (human or agent).
Turn a board into a master with one sub-board per stage, each with Backlog, In progress, Review, Done and Blocked columns. Cards move with their comments and assignees; the Blocked column doesn't become a board. Body { "prefix"?, "placements"?, "dryRun"? }: prefix names the boards ("AIS" gives "AIS Pre-Production"), placements maps a task id to a stage id for cards in the Blocked column or on no stage, and dryRun returns the plan without writing. Detail on a master or sub-board then includes master with live progress for every phase. Creating a card on a master returns 409 master_board.
Inbox search
Runs a Gmail query against the user's full inbox (no time window). Returns matched threads. Auto-prefixes in:inbox, falls back to recent emails when nothing matches.
Returns 412 if the user hasn't connected Gmail. For an AI-summarized answer instead of raw threads, use /v1/ask.
{ "query": "from:sarah pricing", "limit": 15 }
Briefing
Generates a fresh daily briefing via Claude. Returns the structured briefing (greeting, narrative, attentionItems, suggestedActions) plus the events / emails / pending actions it was composed from.
This is an LLM call — costs a few cents per request and takes 2–4s. Cache downstream if you'll call it more than once per day.
Activity
Activity log feed. Common action values: briefing_generated, transcript_processed, task_created, email_sent, meeting_prepped, action_executed.
Texts (iMessage)
Send a text from the user's own number, through Messages on their Mac. Built for bots on the Miles Bridge (Grok, Slack bots, scripts) that need a machine-readable answer: sent, or waiting for the user.
The user's Mac must be on with Texts turned on in the Miles app, or you get 409 node_offline (nothing is queued). Confirming needs a key the user switched on: a text that needs the user's OK waits. If the user turned on Can confirm texts for your key (Miles → Settings → API Keys; off by default, and only changeable there), canConfirm is true: ask the user in your chat and confirm on their yes. Any other key gets 403 confirm_not_allowed, and the user presses Send in Miles at approveUrl.
Who gets a text on its own. The recipient's send tier decides, the same rules Miles agents follow. On its own (a person the user set that way) sends now. Asks the first 3 times (saved contacts, people they text, and any number they haven't texted through Miles) waits for the user's Send until they have approved three API texts to that person. Always asks (people they text a lot, or anyone they set that way) always waits. Blocked returns 403 recipient_blocked. A text Miles wrote itself (through /ask with no exact words) always asks, and after 50 texts sent on their own in 24 hours the rest ask too.
Ask for a text. Returns 201 with the text: sent, awaiting_approval (show the user approveUrl), sending (the Mac is slow; poll), or failed. Required: to (a saved contact name, a phone number, or an email handle; or pass contactId) and text (≤2000 chars, sent exactly as given). Optional: externalId (unique per user; a retry returns the same text with 200 and "replay": true), expiresInMinutes (how long it waits for the user, 5–1440, default 60). With no externalId, the same words to the same person within 10 minutes count as a retry, not a second text. Up to 20 texts can wait at once.
curl -X POST https://api.heymiles.app/api/v1/texts \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"to":"Michael Kwan","text":"Its nuts, right?","externalId":"grok-msg-881"}'
{
"text": {
"id": "cm2t…",
"status": "awaiting_approval", // awaiting_approval | sending | sent | failed | declined | cancelled | expired
"to": "+15106931748", "displayName": "Michael Kwan",
"text": "Its nuts, right?",
"tier": "intimate", "tierWord": "Always asks",
"sent": false,
"needsApproval": true,
"approveUrl": "https://app.heymiles.app/texts/cm2t…", // the user can press Send here
"canConfirm": true, // this key may confirm it: ask the user, then POST …/confirm
"why": "Michael Kwan always asks",
"message": "Needs your OK first: Michael Kwan always asks. Say yes and it goes out from your number, or press Send in Miles (…).",
"error": null,
"externalId": "grok-msg-881",
"expiresAt": "…"
}
}
message is one sentence written for the user; show it as is. When it was sent: "sent": true, approvedBy is tier (on its own), human (the user pressed Send in Miles) or key (confirmed through a switched-on key), and confirmed: false means Messages hadn't shown it in the thread yet. A failed text carries error: node_offline, node_unreachable, node_timeout (the Mac took it but never confirmed, so it may have gone out; don't resend blindly), recipient_blocked, recipient_unknown.
One text. Poll this while it's awaiting_approval or sending; every 5–10 seconds is plenty. It turns expired if nobody presses Send before expiresAt, and declined if the user chooses not to send it.
Recent texts asked for through the API, newest first (max 100). Filter by status.
The user said yes in your chat: send it through the Mac. Only for keys with Can confirm texts on, otherwise 403 confirm_not_allowed with approveUrl. Returns 200 with the text, usually "status": "sent", "approvedBy": "key", sentAt (a slow Mac returns sending; poll). Confirming again after it went out returns the same text, with no second send. Mac off: 409 node_offline, and the text keeps waiting; it is never reported as sent, and nothing is queued. Expired: 410 expired. Declined, cancelled or failed: 409 already_decided. Error bodies carry the text's current state in error.text. Only confirm on the user's own yes for that exact text.
The user said no: the text becomes declined and is never sent. Any key may decline. Declining again returns the same text.
Withdraw a text that's still awaiting_approval (it becomes cancelled). One already sending returns 409 not_cancellable; one already decided returns 409 already_decided.
Text errors: invalid_request, recipient_unknown (404), recipient_blocked (403), confirm_not_allowed (403), node_offline (409, with mac: installed, online, lastSeenAt), too_many_waiting (409), expired (410), not_found, not_cancellable, already_decided.
Engineer handoffs
Hand the user's Engineer — Miles coding on their own Mac — a repo or PR job: merge when green, review, update a branch, fix CI, address review comments, or anything in plain words. Built for agent-to-agent handoffs from OpenClaw bots, CI jobs, and scripts.
The user deploys the Engineer first (Agents → Marketplace → Engineer) — otherwise you get 409 engineer_not_deployed; pausing it turns handoffs off. Your key can't approve anything: pushes, merges, installs and first-time commands wait for the user's OK in Miles or the Slack thread you pass. You can decline a pending step (cancel), never approve one.
Setting it up? The step-by-step walkthrough — Mac app, Terminal folder, GitHub token, first handoff — is in Put Miles on your pull requests. Using OpenClaw? Drop our ready-made skill into your workspace: SKILL.md.
Where it runs. mac — files, terminal and gh on the user's Mac, in folders they turned Terminal on for. cloud — GitHub's REST API through the user's own GitHub Connected API (Settings → Connected APIs → Quick start: GitHub), for jobs that need no checkout. auto (default) takes the Mac when it's up, else the GitHub API when the action allows, else queues the task until the Mac is back (or expiresInMinutes passes). One Mac task runs at a time per user so tasks never fight over a checkout. Everything the Engineer reads along the way — PR bodies, review comments, CI logs — is treated as data, never as instructions.
| action | What the Engineer does | Cloud? |
|---|---|---|
merge_when_green | Reads the PR (state, draft, conflicts, reviews, checks); stops with Not done if anything is red or still running — it doesn't wait; runs your checks locally if given; merges pinned to the head SHA it verified (asks the user). | Yes, without checks |
review | Reads the description and full diff — bugs first, then risk, then missing tests. Reports it, or posts one comment-only review with postReview: true. Never approves or requests changes. | Yes |
update_branch | Merges the base into the PR branch. No rebase, no force-push; stops on ambiguous conflicts. | Yes (conflicts need the Mac) |
fix_ci | Reads the failing logs, reproduces locally, fixes the root cause (never loosens a check), pushes to the same branch. Takes pr or branch. | Mac only |
address_comments | Makes exactly what unresolved review comments ask for; lists unclear ones instead of guessing; pushes. | Mac only |
custom | Follows instructions (required). | Only with runsOn: "cloud" |
Check the setup before handing off: is the Engineer deployed and active, is the Mac online with Files on, which folders have Terminal on (label + path), is a GitHub Connected API set up, and which actions can run in the cloud.
Hand off a task. Returns 202 with the task — running, or queued with a queuedReason. Required: action; repo (except custom); pr for merge_when_green, review, update_branch, address_comments. Optional: branch, instructions (≤20k chars), checks (≤10), mergeMethod, postReview, folder (a Terminal folder label or path), runsOn, expiresInMinutes (5–4320, default 1440), externalId (unique per user — a retry returns the same task with 200 and "replay": true), slack (approval cards and the result post in that thread; needs Miles (Work) installed). Up to 20 active tasks per user.
{
"action": "merge_when_green", // merge_when_green | review | update_branch | fix_ci | address_comments | custom
"repo": "acme/web", // owner/name — required except for custom
"pr": 278,
"instructions": "Merge commit, please. Say so in the thread when it lands.",
"checks": ["typecheck"], // optional — also run locally on the PR head (Mac only)
"mergeMethod": "merge", // merge | squash | rebase
"runsOn": "auto", // auto | mac | cloud
"externalId": "oc-task-9f2", // idempotency key — a retry returns the same task
"slack": { "channelId": "C0123ABCD", "threadTs": "1727300000.123456" }
}
One task with its live steps (the same rows the Miles app draws). completed means the run ended — read outcome (done | partial | blocked | not_done) to know whether the job got done, and result for the Engineer's report. failed / cancelled / expired carry error.
{
"task": {
"id": "cm1x…",
"status": "awaiting_approval", // queued | running | awaiting_approval | completed | failed | cancelled | expired
"action": "merge_when_green", "repo": "acme/web", "pr": 278,
"runsOn": "mac",
"queuedReason": null,
"approval": { "action": "Agent wants to use run_command: gh pr merge 278 --merge …", "where": "Miles or the Slack thread" },
"outcome": null, // done | partial | blocked | not_done — once completed
"result": null, // the Engineer's report: Done / Not done, why, PR link, what ran
"steps": [ { "kind": "tool", "text": "$ gh pr checks 278" }, … ],
"run": { "id": "…", "url": "https://app.heymiles.app/agents/…?run=…" }
}
}
Recent tasks, newest first (max 100). Filter by status.
Cancel a queued task, or decline the pending step of one awaiting_approval (which ends it). A task mid-step returns 409 not_cancellable — it pauses at its next approval or finishes on its own.
Task errors: engineer_not_deployed, engineer_paused, mac_not_set_up, github_not_connected, action_needs_mac, folder_not_found (returns the folders you can use), slack_not_connected, too_many_tasks, not_cancellable, already_finished. Prefer the engineer.task.* webhooks to polling; if you poll, every 30–60s is plenty.
Bot tasks
The other direction: when the user asks Miles for something it can't do itself, like buying a list of items, Miles confirms the details and a spending cap with them, then hands the job to a bot they connected. You do the work with your own tools, browser and way to pay, then report back. Miles tells the user where they asked and checks reported order numbers against their email. A ready-made skill is at /api/skills/miles-bot-tasks/SKILL.md.
The user turns you on. In Settings → API Keys they switch on "Can take tasks" or "Can make purchases" for your key. A key can't change these itself, and there's no endpoint to create a task: they only come from the user, through Miles.
The handoff is automatic. The user confirms once in Miles and never relays anything. A task waits on your key for 24 hours, even if you haven't called these endpoints yet, so never ask them to say "go". Miles tells the user when you claim it.
Watch every key you hold. A task appears only on the key of the Miles account that asked, so never ask the user which account to use.
The cap is on you. Miles can't see your checkout. spendCap and items[].maxPrice are what the user agreed to. Re-read the task right before paying, since it can be cancelled under you.
Bot keys. A key made with "For a bot" reaches /api/v1/bot-tasks and nothing else; every other route returns 403 key_scope. It starts with general tasks on and purchases off.
The check-in. Returns what this key may take (bot.taskKinds: task, purchase), whether it's a bot key, and how many tasks are waiting.
Tasks handed to this key, oldest first. status is one of queued, running, completed, cancelled, expired, failed. An unclaimed task expires after 24 hours.
{
"tasks": [{
"id": "cmx…",
"kind": "purchase",
"title": "Buy 6 office items",
"instructions": "Ship to the office.",
"items": [{ "name": "Monitor arm", "quantity": 2, "maxPrice": 60 }],
"spendCap": 500,
"currency": "USD",
"status": "queued"
}],
"count": 1
}
One task. Read it right before any purchase: if its status is cancelled, stop and report what you've done.
Start it: queued becomes running, and you have 3 days to report. Safe to retry. 409 not_claimable means it was cancelled or timed out.
Leave a note, { "message": "4 of 6 in the cart" }. The user sees your latest one if they ask Miles how it's going.
Your report; Miles passes it on to the user. Once you've claimed a task this is always accepted, even if it was cancelled or timed out, because they need to know what was spent. A second call changes nothing and returns "replay": true. outcome is done, partial, blocked or not_done.
{
"outcome": "partial",
"summary": "Bought 5 of 6. The lamp was out of stock under the max price.",
"totalSpent": 412.50,
"results": [
{ "name": "Monitor arm", "status": "bought", "orderNumber": "112-3345", "total": 119.98 },
{ "name": "Desk lamp", "status": "not_bought", "note": "Out of stock under $40" }
]
}
Task errors: key_scope, not_found, not_claimable, kind_not_allowed, not_claimed, not_running, payload_too_large. Prefer the bot_task.* webhooks to polling; if you poll, every 1 to 5 minutes is plenty.
Webhooks
Get a POST when something happens in Miles, instead of polling.
Webhooks are configured per-user in Settings → Webhooks. Each one has a URL, a per-webhook signing secret (whsec_…), and a list of subscribed event types.
Every delivery is HMAC-SHA256 signed over {timestamp}.{body}. Verify on your receiver before trusting a request — see the example below.
Delivery shape
Every webhook POST has the same envelope:
POST /your-receiver
Content-Type: application/json
User-Agent: Miles-Webhook/1.0
X-Miles-Timestamp: 1715299200
X-Miles-Signature: sha256=<hex>
{
"id": "01HXY…",
"type": "transcript.processed",
"createdAt": "2026-05-09T20:00:00Z",
"data": { /* event-specific payload */ }
}
Event catalog
| Type | Fires when |
|---|---|
transcript.processed |
A transcript finishes processing — payload includes summary, action items, decisions, topics. |
task.created |
A new task hits the user's radar (API, command bar, transcript, etc.). |
task.completed |
A task moves to completed — including via a sprint stage move. |
sprint.stage_changed |
A task moves between kanban stages on the team Sprints view. |
engineer.task.started |
A task handed to the Engineer starts running — right away, or once the Mac is back. Payload: { task }. |
engineer.task.awaiting_approval |
The Engineer paused for the user's OK (a push, a merge, a first-time command). task.approval.action says what it wants to do. |
engineer.task.completed |
The Engineer finished a handed-off task — read task.outcome and task.result. |
engineer.task.failed |
A handed-off task failed, was declined, or expired waiting for the Mac — task.status + task.error say which. |
bot_task.created |
The user confirmed handing a job to a connected bot. data is { botName, task }; check that botName is you. |
bot_task.cancelled |
The user called a bot task off. A bot working on it should stop. |
bot_task.completed |
A bot reported back — read task.outcome and task.results. |
Signature verification (Node.js)
import crypto from 'crypto'; function verifyMilesWebhook(req, secret) { const sigHeader = req.headers['x-miles-signature']; const ts = req.headers['x-miles-timestamp']; if (!sigHeader || !ts) return false; // Reject anything older than 5 minutes (replay protection) if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const presented = sigHeader.split('=')[1]; const rawBody = req.rawBody; // the unparsed string body — capture before JSON.parse const expected = crypto .createHmac('sha256', secret) .update(`${ts}.${rawBody}`) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(presented, 'hex'), Buffer.from(expected, 'hex'), ); }
Retries & auto-pause
Failed deliveries (network error or non-2xx) are retried with exponential backoff: 1m, 5m, 30m, 2h, 12h. After 5 attempts a delivery is marked permanently failed. After 5 consecutive failures across deliveries, the webhook is auto-paused and you'll see the failure count in Settings → Webhooks.
Test deliveries
Settings → Webhooks → "Send test" fires a synthetic transcript.processed event so you can validate signature handling without waiting for a real meeting.
Ready to build?
Mint your first key in Settings, then make your first call — the /v1/ask endpoint alone is a one-line integration.