# Approvals > Human-in-the-loop as an API primitive — the agent proposes, a person taps Approve / Edit / Reject on their phone, the agent acts only after action.approved. > > Markdown mirror of https://nmbr.ai/developers/docs/approvals/ — part of the nmbr Agent API docs (https://nmbr.ai/developers/docs/). Spec: https://nmbr.ai/developers/openapi.yaml · Site index: https://nmbr.ai/llms.txt An agent that can do consequential things needs a person in the loop, and "reply *yes* in chat" is not a safe primitive: anyone who can type in the channel can say yes, and a prompt-injected agent can fake the question. nmbr's approvals are bound to **one person** and **one conversation**, render as a native card, and fail closed. ## The flow 1. **Propose.** `POST /agent/v1/actions` with `to` (the person's nmbr), a developer-defined `kind`, a one-line `title`, optional `description`, and any `payload` your agent needs back. 2. **Card.** The person sees an approval card in the 1:1 chat with your agent — push, badge, preview, like any message. They can **Approve**, **edit the payload and approve**, or **Reject**. 3. **Decision event.** `action.approved` (with `payload.action.editedPayload` set if they edited — use it instead of `payload`), `action.rejected`, or `action.expired`, over long-poll or your webhook. 4. **Act — on your side.** For a plain proposal nmbr never executes anything: only after `action.approved` does your agent do the thing. (For a [platform skill](#skills), nmbr does it for you — see below.) ## Binding rules - Only the person named on the proposal can decide, and only from the conversation it was proposed in. A reply from anyone else, from a group, or from another chat is refused — the API answers as if the action didn't exist. - Decisions are final: a resolved action cannot be re-opened. - **Expiry is fail-closed.** Default 24 h (60 s – 7 days via `expiresAt`). Nobody decided in time ⇒ `action.expired`. Treat it exactly like a rejection. Never act on a proposal you have not seen approved. - At most 10 pending proposals per conversation (`too_many_pending`). A looping agent cannot flood someone's phone. ## In five lines (bash) ```bash curl -s -X POST https://nmbr.ai/api/agent/v1/actions -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"to":"123-456-789","kind":"deploy","title":"Deploy v2 to prod?","payload":{"ref":"abc123"}}' curl -s "https://nmbr.ai/api/agent/v1/updates?afterSeq=$SEQ&wait=25" -H "Authorization: Bearer $TOKEN" # → { "events": [ { "type": "action.approved", "payload": { "action": { "id": "…", "editedPayload": null, … } } } ] } ``` Loop the second call until an `action.*` event names your action id. `GET /agent/v1/actions/{id}` returns the current state at any time (useful after a missed webhook). ## With the SDK ```ts const decision = await agent.proposeAndWait({ to: "123-456-789", kind: "send_email", title: "Send the Q3 summary to Dana?", payload: { threadId: "t1" } }); switch (decision.state) { case "approved": await sendEmail(decision.editedPayload ?? decision.payload); break; default: /* rejected or expired: do nothing */ } ``` If you already run `agent.updates()` elsewhere, use `isActionEvent(event)` in that loop instead of `proposeAndWait` (one consumer per event stream). ## Let nmbr do it: platform skills Some things your agent wants done live *inside* the person's nmbr — a task on their list, a reminder, a calendar event, a note. For those, add a `skill` to the proposal and nmbr executes it on **their** account the moment they approve, exactly where their own assistant would put it: | `skill` | Needs their grant of | `payload` | |---|---|---| | `create_task` | `tasks:write` | `{ title, description?, dueDate?, priority? }` — priority `low` \| `normal` \| `high` | | `create_reminder` | `reminders:write` | `{ title, reminderTime }` | | `create_event` | `events:write` | `{ title, description?, startTime, endTime?, location? }` | | `create_note` | `notes:write` | `{ title?, content }` | | `update_task` | `tasks:write` | `{ id, title?, description?, dueDate?, priority? }` — at least one field besides `id`; `null` clears an optional one | | `complete_task` · `delete_task` | `tasks:write` | `{ id }` | | `update_reminder` | `reminders:write` | `{ id, title?, reminderTime? }` | | `complete_reminder` · `delete_reminder` | `reminders:write` | `{ id }` | | `update_event` | `events:write` | `{ id, title?, description?, startTime?, endTime?, location? }` | | `delete_event` | `events:write` | `{ id }` | | `update_note` | `notes:write` | `{ id, title?, content? }` | | `delete_note` | `notes:write` | `{ id }` | `id` is a record from the person's `GET /conversations/:id/{tasks,reminders,events,notes}` list (needs their `:read`) or an earlier `execution.recordId`; nmbr re-checks at execution that it is theirs, and a stranger's id fails with `execution.status: "failed"`. Dates are ISO-8601 with an offset. The payload is validated when you propose (`400 invalid_payload` names the field) and again when the person approves an edited version, so what the card shows is what runs. Unknown keys are refused. `kind` is optional for a skill proposal and defaults to the skill id. ```bash curl -s -X POST https://nmbr.ai/api/agent/v1/actions -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"to":"123-456-789","skill":"create_reminder","title":"Remind you to call Dana at 3?","payload":{"title":"Call Dana","reminderTime":"2026-09-03T15:00:00-07:00"}}' ``` The decision comes back as usual; on `action.approved`, `payload.action.execution` tells you what happened: ```json { "status": "succeeded", "recordType": "ai_reminder", "recordId": "…", "executedAt": "…", "auto": false } ``` `status: "failed"` carries a one-line `error` — the approval stands, but nothing was created; propose again if it still makes sense. Your agent has nothing to execute either way. **Scopes.** A skill proposal needs the person's grant of the matching write scope on top of `messages:write` (the card is a message). Ask for those scopes in your agent's `requestedScopes` and they appear on the consent card when someone adds it; without the grant the proposal is `403 scope_not_granted`. See [Scopes and consent](/developers/docs/concepts/#scopes-and-consent). **Without asking.** On your agent's profile, a person can flip any granted write scope from *asks first* to *runs without asking* for its create skill. A proposal for such a skill is approved and executed on the spot: the `POST /actions` response already has `state: "approved"` and `execution.auto: true`, and no `action.*` event follows (the SDK's `proposeAndWait` returns immediately). The card is still posted in the chat, already resolved, so nothing ever happens invisibly. It is *their* setting: your agent cannot ask for it, and removing the contact clears it with the grant. **Always a card.** Only the four `create_*` skills can be set to run without asking; an update, completion or deletion always shows the card, whatever the person's settings. Sending messages or email *as* the person is not a skill and never will be — an agent that wants to say something says it as itself with `POST /messages` or `POST /emails`. ## Writing good proposals - `title` is what they approve — make it the whole decision in one line ("Deploy v2 to prod?" not "Deploy?"). - `description` is where the consequences go. Keep secrets out of both; they render on a phone. - `kind` is yours (`^[a-z][a-z0-9_.-]{0,63}$`), shown as a small label and echoed in events — a stable vocabulary makes your logs readable. - Put in `payload` exactly what you need to execute, so an edited approval is executable as returned.