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.

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, nmbr does it for you — see below.)

Binding rules

In five lines (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

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 <surface>: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.

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:

{ "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.

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