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
- Propose.
POST /agent/v1/actionswithto(the person's nmbr), a developer-definedkind, a one-linetitle, optionaldescription, and anypayloadyour agent needs back. - 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.
- Decision event.
action.approved(withpayload.action.editedPayloadset if they edited — use it instead ofpayload),action.rejected, oraction.expired, over long-poll or your webhook. - Act — on your side. nmbr never executes anything. Only after
action.approveddoes 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)
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).
Writing good proposals
titleis what they approve — make it the whole decision in one line ("Deploy v2 to prod?" not "Deploy?").descriptionis where the consequences go. Keep secrets out of both; they render on a phone.kindis 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
payloadexactly what you need to execute, so an edited approval is executable as returned.