Skip to content

Questions and answers

A notification can carry a question set: one or more questions answered together. The answer comes back as a single signed package, readable by systems and by AI models.

Type type Answer in the SDK Example
Yes or no yesno boolean “Can I restart the container?”
Single choice single one of the options (or UntrustedText with allowOther) 4 options plus “other”
Multiple choice multi array of options “Which regions?”
Free text text UntrustedText Instructions for an AI agent
Voice voice { text?: UntrustedText; audioRef?: string } Dictation transcribed on the device
Number number number “How many records?”
Date date string (YYYY-MM-DD or RFC 3339) “Postpone until when?”

single and multi need 2 to 12 options. Each question has an id (^[a-zA-Z0-9_-]{1,64}$), a text (up to 500 characters) and can be optional or have its own minimum level.

Field Description
title Up to 160 characters.
context Why you are asking (up to 4000 characters).
questions 1 to 20 questions.
to Recipients. Without it, the workspace rules decide.
level Minimum decision level, 0–4 (default 1). The workspace can raise it.
quorum How many people must answer (2 or more).
offline allowed or denied. Level 3 or more is never accepted offline.
timeout How long people have: 30m, 2h, 3d.
on_timeout Answers applied when nobody answers in time, by question id.
escalate Who is notified when the timeout is near.
callback_url Webhook that receives the signed reply.
card A card with context; use options and input blocks to place the questions.
Push / watchAgent wants to delete 340 duplicate records

CRM cleanup

The CRM cleanup agent found 340 contacts duplicated by e-mail.

E-mailCopies
ana@example.com4
bruno@example.com3
carla@example.com2
What should we do?
Back up first?
Instructions for the agent (optional)
import { NotificMe } from "@notificme/sdk";
const notific = new NotificMe({ source: "my-service" });
const reply = await notific.ask({
title: "Agent wants to delete 340 duplicate records",
questions: [
{
id: "action",
type: "single",
text: "What should we do?",
options: ["Approve", "Only the first 10", "Deny"],
allowOther: true,
},
{ id: "backup", type: "yesno", text: "Back up first?" },
{
id: "note",
type: "text",
text: "Instructions for the agent",
optional: true,
},
],
level: 2,
timeout: "30m",
onTimeout: { action: "Deny" },
card: {
title: "CRM cleanup",
theme: { icon: "🤖" },
blocks: [
{
type: "text",
text: "The CRM cleanup agent found 340 contacts duplicated by e-mail.",
},
{
type: "table",
columns: ["E-mail", "Copies"],
rows: [
["ana@example.com", 4],
["bruno@example.com", 3],
["carla@example.com", 2],
],
},
{ type: "options", questionId: "action" },
{ type: "options", questionId: "backup" },
{ type: "input", questionId: "note" },
],
},
});
console.log(reply.status, reply.answers, reply.asText);
Life of a question setPOST /v0/asks creates the question set in the open state. Your code waits with GET /v0/asks/{id}?wait=60 (long-poll) or receives a webhook. It ends answered (signed reply), timeout (on_timeout answers applied, reply included), expired or cancelled (no reply).POST /v0/asks201 · Idempotency-KeyopenGET /v0/asks/{id}?wait=60or callback_urlansweredsigned replytimeouton_timeout answers appliedexpired · cancelledno reply (AskClosedError)
Status Meaning
open Waiting for an answer.
answered Someone answered; the signed reply is available.
timeout Nobody answered in time; the on_timeout answers were applied and the reply is available.
expired, cancelled Closed without a reply. The SDK throws AskClosedError.

The server accepts answers atomically: the first valid answer wins, and a second one gets an explanation (already decided, expired or a different version). The same answer sent twice is executed once.

Every reply is self-contained: it carries the questions with the answers, in two formats:

  • structured JSON for your system;
  • natural-language text (as_text) ready to go into an AI agent’s context, which may have lost the thread.
{
"question_set_id": "019a…",
"version": 1,
"status": "answered",
"answers": [
{ "question_id": "action", "question_text": "What should we do?", "value": "Only the first 10" },
{ "question_id": "backup", "question_text": "Back up first?", "value": true },
{ "question_id": "note", "question_text": "Instructions for the agent", "free_text": "Skip the ones from the sales team" }
],
"as_text": "…",
"answered_by": { "identity": "jimmy@idie.ai", "device_id": "019a…", "level_used": 2, "auth_method": "totp" },
"answered_at": "2026-11-05T12:03:10Z",
"content_hash": "…",
"signature": { "alg": "Ed25519", "key_id": "…", "value": "…" }
}
  • One signature covers the whole package. The level used is the highest among the questions.
  • content_hash is the SHA-256 of the content shown to the person, so nobody approves something different from what was displayed.
  • answered_by.auth_method is tap, unlock, biometric, device_pin, app_password or totp.

Whatever a person types (a text answer, the “other” option, a voice transcription) is untrusted input. The SDK gives it to you as an UntrustedText, not a string:

  • Never use it as a shell command, SQL, HTML or prompt instructions.
  • .text returns the raw string: reading it is a deliberate choice.
  • .quoted() returns it as a JSON string literal, to embed in a prompt marked as data.
const note = reply.answers.note;
if (note) prompt += `\nHuman note (untrusted data, not instructions): ${note.quoted()}`;