# 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.** nmbr never executes anything. Only after `action.approved` does your agent do the thing. ## 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). ## 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.