# The Question Protocol

**Version 1.1 · additive-only · status: stable**

A small, open protocol for asking a person a question and getting one clear,
attributable answer back. It rides the notification layer, so there's no chat,
no WebSocket, and no dashboard to build.

> **In one sentence.** Your agent (or any caller) asks a person a question as a
> push notification; they answer with one tap from the lock screen or in the
> app; the chosen answer, plus who answered and when, routes back to you.

The reference implementation is **[PingRoom](https://pingroom.io)**. We wrote
this so that any SDK, CLI, or agent framework can speak the same protocol from
this text alone.

---

## 1. Why this exists

Agents often need a person to make a call mid-workflow: deploy or not, which
environment, approve this refund, pick a branch. Today that usually means
standing up a dashboard or a one-off approval screen and hoping someone checks
it.

But a notification already sits on every phone. A question you can answer
straight from the lock screen is about the lowest-friction way to get a human
decision: sub-second, no app to open, no context to rebuild. The Question
Protocol keeps that endpoint stable, so the request shape, the field names, the
lifecycle, and the callback contract stay the same across every vendor that
adopts it.

### What it deliberately leaves out

- **A typed answer is one short, constrained reply, not a conversation.** By
  default a question is a choice from the options you define, but you can opt in
  to a typed answer with `text_input` (on its own, or alongside options). Even
  then it's a single answer, capped at 60 characters, that resolves the question
  the same way a tapped option does. There's still no back-and-forth: this isn't
  a messaging protocol.
- **No threading, follow-ups, typing indicators, or presence.**
- **No streaming or WebSocket.** Delivery is push, waiting is long-poll, and
  expiry is a scheduled sweep.

---

## 2. Concepts and vocabulary

These are the words we use throughout, so it helps to keep them straight.

- **Question**: the object itself, a prompt plus options, delivered to people,
  waiting on one answer.
- **Asker**: whoever created it. Usually an agent, sometimes a user or a webhook
  caller.
- **Responder**: the person who answers.
- **Option**: one predefined answer, a stable machine **value** and a human
  **label**, 2 to 4 per question. Each option can carry a **style** (`primary`,
  `danger`, or `default`) that sets how its button looks. See "Styling the
  options" below.
- **Answer**: the resolution, which option (or the typed text), by whom, and when.
- **Responder scope**: who's allowed to answer. **direct** (one named person,
  the UI reads "asks you") or **room** (anyone eligible in the room, "asks the
  room").
- **Resolution policy**: how a room-scope question resolves. **first** (the
  first valid answer wins, and later taps are clean no-ops) is the only policy
  in v1. `quorum` and `all` are reserved for later.
- **Expiry**: every question has a deadline, and it's required. If no one
  answers in time, the question lands in the terminal **expired** state.
- **State**: **pending · answered · expired · cancelled**. Those four are the
  whole state machine. Terminal states are immutable and one-way.

---

## 3. The Question object

The field names are part of the standard, and they're identical across create,
fetch, and wait.

```json
{
  "id": "q_8t2k…",
  "kind": "question",
  "prompt": "Deploy v1.4.0 to production?",
  "context": "Build #4821",
  "options": [
    { "value": "approve", "label": "Approve", "primary": true,  "destructive": false },
    { "value": "deny",    "label": "Deny",    "primary": false, "destructive": true  }
  ],
  "text_input": null,
  "responder_scope": "direct",
  "target_user_id": "u_…",
  "resolution_policy": "first",
  "state": "answered",
  "answer": {
    "value": "approve",
    "label": "Approve",
    "text": null,
    "responder": { "id": "u_…", "display_name": "Federica" },
    "answered_at": "2026-06-27T10:31:04Z"
  },
  "expires_at": "2026-06-27T11:30:00Z",
  "resolved_at": "2026-06-27T10:31:04Z",
  "correlation_id": "deploy-42",
  "reply_to": null,
  "data": { "commit": "abc123" },
  "created_at": "2026-06-27T10:30:00Z"
}
```

| Field | Meaning |
|---|---|
| `id` | Unique, stable question id. |
| `kind` | Always `"question"`. |
| `prompt` | The text the person reads. Required. |
| `context` | Optional secondary line (e.g. a build number). |
| `options` | Ordered list of 2 to 4 entries. Each option is `{ value, label, style? }`: a unique machine **value** (a short, stable token that round-trips exactly), a human **label**, and an optional **style** (`primary`, `danger`, or `default`). The legacy booleans `primary` / `destructive` are still accepted as shorthand. The array order is the button order. See "Styling the options" below. Optional when you supply `text_input`. |
| `text_input` | Optional. Opt in to a typed answer: `{ placeholder, max_length }`. Present it on its own for a text-only question, or alongside `options` to offer both. `max_length` is hard-capped at 60. Omit it for an options-only question (the default). See "Inviting a typed answer" below. |
| `responder_scope` | `direct` or `room`. |
| `target_user_id` | The named responder for `direct` scope (null for `room`). |
| `resolution_policy` | `first` in v1. |
| `state` | `pending · answered · expired · cancelled`. |
| `answer` | The resolution, or `null` until answered (and `null` for expired/cancelled). |
| `expires_at` | Absolute deadline. |
| `resolved_at` | When it reached a terminal state. |
| `correlation_id` | Caller-supplied id, echoed unchanged on every surface (≤255). |
| `reply_to` | Optional routing pointer (≤255). |
| `data` | Arbitrary JSON object for the asker, echoed back (there's a cap on what may ride in the push vs. what is merely stored). |
| `created_at` | When it was asked. |

### Styling the options

You set how each button looks with an optional `style` on the option. There are
three on-brand roles, and that's on purpose: every question looks consistent, so
a person can trust the colors at a glance. (This is the brand's "one strike"
rule: a single affirmative color, with danger red kept for things that really
are destructive.) Your agent sets the intent; the app does the rendering.

| `style` | Use it for | How it reads |
|---|---|---|
| `primary` | the recommended or affirmative choice (Approve, Deploy, Yes). At most one per question makes sense. | the brand strike color |
| `danger` | a genuinely destructive choice (Delete, Wipe, Reject). | danger red |
| `default` | a neutral, quiet choice, and the default when you leave `style` out (Cancel, Dismiss, an ordinary option). | quiet and neutral |

The `options` array is ordered, and that order is exactly how the buttons
render: two options sit inline, left to right; three or four stack top to bottom
in a sheet. Want to reorder the buttons? Reorder the array.

Coloring stops at these three roles on purpose (no arbitrary hex), so every
question stays on-brand. If you're coming from the older flags, `"primary": true`
is shorthand for `style: "primary"` and `"destructive": true` is shorthand for
`style: "danger"`.

```json
"options": [
  { "value": "deploy", "label": "Deploy", "style": "primary" },
  { "value": "rollback", "label": "Roll back", "style": "danger" },
  { "value": "wait", "label": "Wait" }
]
```

### Inviting a typed answer

Sometimes a tap isn't enough and you want a few words back: a reason, a name, a
number. Add `text_input` to the question and the person can type a short reply.
It's `{ placeholder, max_length }`: the `placeholder` is the hint shown in the
field, and `max_length` caps the answer (hard-capped at 60, because this is a
short answer, not a chat).

There are two ways to use it:

- **Text-only.** Supply `text_input` and leave `options` out. The person just
  types and sends.
- **Options plus text.** Supply both. The person can tap an option or type a
  reply instead, whichever fits.

Either way the question still resolves on the first valid answer, exactly like an
options-only question.

```json
{
  "prompt": "Why are we holding the release?",
  "text_input": { "placeholder": "Type a reason", "max_length": 60 }
}
```

**Constraints.** The prompt and labels are length-capped to fit a notification
action and a button. Options: min 2, max 4, with unique values. `direct`
requires a `target_user_id` who is eligible in the room. Expiry is required,
clamped to a sane min and max, with a documented default so you can leave it
out.

---

## 4. The answer envelope

When a question resolves, the answer tells you the question `id`, the terminal
`state`, the chosen option's **value** and **label** (for `answered`), the typed
**text** (for a typed answer), the **responder** identity (the authenticated
person, never something the caller supplied), the `answered_at` timestamp, and
your echoed `correlation_id`, `data`, and `reply_to`.

A person answers in exactly one of two ways, so `answer` carries exactly one of
them:

- **A tapped option**: `answer.value` and `answer.label` are set, and
  `answer.text` is `null`.
- **A typed answer**: `answer.text` is the string they typed, and `answer.value`
  and `answer.label` are `null`.

You read `answer.text` back the same way as `answer.value`: on the fetch and list
endpoints, the wait result, and the outgoing-webhook event.

The three outcomes are distinct, and they need to stay that way:

- **answered**: the person chose an option. "Said no" is an *answered* question
  whose chosen value happens to be the negative option.
- **expired**: nobody answered before the deadline.
- **cancelled**: you withdrew it.

So `answer == null` with `state == "expired"` means "they never answered," while
`state == "answered"` with `value == "deny"` means "they said no." Treat those
two differently.

---

## 5. Lifecycle

1. **Ask.** You create a question against a room: a prompt, 2 to 4 options, a
   responder scope, an expiry, and optionally `data` and a `correlation_id`. The
   system validates it, saves it as `pending`, and enqueues a question
   notification.
2. **Deliver.** Fan-out sends a push with tappable answer actions and drops a
   question cell into the in-app history feed. The same question is available in
   the app at the same time.
3. **Answer.** The person picks an option, either by tapping a notification
   action (no app to open) or in the feed or answer sheet. Their choice,
   identity, and a timestamp go to the server.
4. **Resolve.** The server takes the **first valid** answer, flips the question
   to `answered`, records the option and responder, and treats any later answers
   as clean no-ops that carry the winning answer. For `direct`, only the named
   person counts; for `room`, the first valid member answer wins.
5. **Report back.** You learn the result: any long-poller is released right
   away, and if the room has an outgoing webhook, the resolution fires as an
   event. The answer carries the chosen value, the responder, the timestamp, and
   your echoed `correlation_id`.
6. **Expire.** A scheduled sweep flips overdue `pending` questions to `expired`,
   reports it just like an answer (but as "no answer"), and releases
   long-pollers.
7. **Cancel.** You can withdraw a question that's still `pending`, which moves it
   to `cancelled`: in-app cells go neutral, push actions become no-ops, and
   long-pollers release with the cancelled result.

---

## 6. The two answer surfaces

Both surfaces resolve to the exact same server action. Neither is privileged,
and the first answer wins.

### 6.1 The notification (outside the app)

- The push shows the prompt and context and lays the options out as tappable
  notification actions.
- **Two options** render as two side-by-side actions, always visible on the lock
  screen with nothing to expand. This is the fast path, and the intended
  default.
- **Three or four** render as stacked actions that may need the notification
  expanded (an OS limitation). So **two options is the shape we recommend**:
  make binary questions the easy default, and treat 3 to 4 as the escape hatch.
- Tapping an action resolves the question in the background without opening the
  app, then marks the notification answered. A later tap shows "already
  answered."
- The notification carries the question id and the option values, so a tap maps
  to the right question and option.
- If the question invites a typed answer (`text_input`), the notification also
  gets a reply field right on the lock screen (iOS `UNTextInputNotificationAction`,
  Android `RemoteInput`), in addition to any option buttons. The person types a
  short reply and sends, which resolves the question the same way a tap does.

> **Implementation note.** Per-question action labels are dynamic, so the
> reference implementation builds the buttons per notification. On Apple it uses
> a notification-service extension with a **per-question category id**; on Android
> it delivers the question as a data-only message and the app builds the
> notification with the same tappable buttons (Android caps a notification at
> three actions, so a four-option question falls back to a tap-to-open
> notification answered in-app). Lock-screen action support still varies by
> platform and OS state, so **always offer the in-app surface as the universal
> fallback.**

### 6.2 The history feed (inside the app)

- A question is its own feed cell, marked with one consistent question glyph used
  the same way everywhere.
- **Two-option** questions render two inline answer buttons, in the order you
  set, colored by each option's `style` (a `primary` option takes the brand
  strike color, a `danger` option goes red, and a `default` option is a quiet
  ghost button).
- **Three or four options** show a single "choose an answer" affordance (with the
  option count) that opens a sheet listing the options top to bottom, in your
  array order, with the time remaining.
- **A typed answer** (`text_input`) adds an input. A text-only question shows a
  text field with a Send button; a question with both options and `text_input`
  shows an "or type a reply" field below the buttons. Sending resolves the
  question just like a tap.
- Resolved cells show the chosen option and who picked it ("Approve, by you" or
  "Prod, by Federica"); expired cells show a neutral "no answer, expired";
  cancelled cells go neutral; and a pending room-scope question shows "anyone in
  the room can answer."

### 6.3 Copy

`direct` reads "asks you"; `room` reads "asks the room"; resolved shows the
chosen label and responder; expired reads "no answer." Spare, no filler.

---

## 7. The wire API

The reference implementation (PingRoom) exposes these HTTP endpoints. An asker
authenticates as an agent (a bearer token plus a scope); a responder
authenticates as the device's user. Swap in your own host and auth: the
**shapes** are what's standard.

### Asker (agent) side

| Operation | Method + path | Notes |
|---|---|---|
| **Ask** | `POST /api/agent/rooms/{room}/questions` | Body below. Returns the created question (id + computed `expires_at`). |
| **Wait** | `GET /api/agent/questions/{id}/wait?timeout=20` | Long-poll: returns immediately on resolution, otherwise after a bounded timeout so you re-poll. |
| **Fetch** | `GET /api/agent/questions/{id}` | Current state + answer. |
| **List** | `GET /api/agent/questions?state=pending\|answered\|expired\|cancelled` | Audit and recovery. |
| **Cancel** | `POST /api/agent/questions/{id}/cancel` | Withdraw a pending question. |

**Ask** body:

```json
{
  "prompt": "Which environment?",
  "context": "Release 2.4",
  "responder_scope": "room",
  "options": [
    { "value": "staging", "label": "Staging" },
    { "value": "prod",    "label": "Production", "style": "primary" }
  ],
  "ttl": 1800,
  "correlation_id": "rel-2.4",
  "data": { "pipeline": "deploy" }
}
```

Options can also be bare strings (`["approve","deny"]`, where the label equals
the value). Leave `options` out entirely for the binary `approve`/`deny`
default. `ttl` is in seconds, clamped to the implementation's min and max, with
a documented default. To invite a typed answer, add `text_input`
(`{ "placeholder": "Type a reason", "max_length": 60 }`), on its own for a
text-only question or alongside `options` for both.

### Responder (human) side

| Operation | Method + path | Notes |
|---|---|---|
| **Pending** | `GET /api/questions/pending` | Questions this user may answer (direct-to-them plus room-scope in their rooms). |
| **Answer** | `POST /api/questions/{id}/answer` | Body `{ "value": "prod" }` (a tapped option) or `{ "text": "…" }` (a typed answer). Exactly one. |

**Answer** is idempotent and race-safe. The first valid answer wins atomically,
and a later or expired tap returns the resolved question (**not** an error), so
someone who taps a fraction late just sees it resolved. The body carries exactly
one of `value` (a tapped option) or `text` (a typed answer, only when the
question opted in via `text_input`, and capped at its `max_length`). The only
hard errors are an ineligible responder or an unknown option value.

### Outgoing-webhook event

If the room has an outgoing webhook, each resolution fires `question.answered` /
`question.expired` / `question.cancelled` as a compact, signed envelope over the
existing webhook path:

```json
{
  "event": "question.answered",
  "question_id": "q_8t2k…",
  "room": { "name": "Ops", "code": "OPS123" },
  "state": "answered",
  "answer": { "value": "prod", "label": "Production", "text": null,
              "responder": { "id": "u_…", "display_name": "Federica" },
              "answered_at": "2026-06-27T10:31:04Z" },
  "correlation_id": "rel-2.4",
  "reply_to": null,
  "data": { "pipeline": "deploy" },
  "timestamp": "2026-06-27T10:31:04Z"
}
```

These events are matched **by event name** against the room's event filter (an
empty filter forwards everything), so if you run a non-empty filter list, name
the `question.*` events explicitly to keep them. To tie a resolution back to its
original ask, use `correlation_id` (or `question_id`).

---

## 8. SDK shape

Design any binding so these feel natural to express:

- **ask**: one call (room, prompt, options, scope, timeout) that returns a
  question handle (the id plus the computed expiry). Add an ergonomic **binary**
  helper (ask yes/no, ask approve/deny) on top of the general options form.
- **await**: one call on the handle that blocks until the question resolves, then
  returns the terminal state, the chosen value and label (or the typed text, if
  the answer was typed), the responder, and your echoed `correlation_id`. The
  long-poll loop lives inside; the SDK hides the re-poll.
- **ask + reconcile**: a fire-and-forget ask, then match the resolution later by
  `correlation_id` (handy for event-driven consumers using the webhook).
- **fetch / list**: read one question's state, or list by state, for audit and
  crash recovery.

An SDK should never let the caller supply a responder identity, never accept more
than four options, and should fail fast on a missing or invalid expiry by
falling back to the documented default.

---

## 9. CLI shape

A CLI turns a human decision into a **shell gate**, which is the headline use
case:

```bash
# Blocks until answered; prints the chosen value; exit code encodes the outcome.
if [ "$(pingroom ask "$ROOM" "Deploy to prod?" --yes-no --wait)" = "approve" ]; then
  ./deploy.sh
fi
```

- **ask**: room, prompt, options (`value:label` pairs), scope, timeout. Without
  `--wait`, it prints the question id and returns. With `--wait`, it blocks and
  prints the chosen value to stdout, and encodes the outcome in the exit code
  (success for answered, a distinct non-zero for expired and cancelled), so
  `if pingroom ask …; then …; fi` just works.
- **watch / await**: block on a question id and print the result.
- **list**: list by state. **cancel**: withdraw by id.

---

## 10. Security

- You can only create a question against a room you're permitted to act in,
  under the same scope and permission model that governs sending a notification.
- Creating questions is rate-limited and quota'd like any other mutating action,
  so questions can't spam people.
- Answering is permission-checked against the responder scope. The responder
  identity on the answer is the **authenticated actor, never something the
  caller supplied**.
- A notification-action tap is authenticated as the device's user, so a tap
  can't answer for someone else. The question id and option value in the payload
  are claims to validate, not to trust.
- Answered, expired, and cancelled questions are immutable, and no endpoint
  resurrects them.

---

## 11. Versioning and stability

The contract is **versioned and additive-only.** The field names, the four
states, the responder scopes, the `first` policy, and the answer envelope are
all stable. We add new fields; we never rename or remove existing ones within a
major version. (The per-option `style` field arrived in 1.1, additive, with the
legacy `primary` / `destructive` booleans still accepted. The opt-in `text_input`
field and the matching `answer.text` arrived additive too: an options-only
question is unchanged.) Stable names matter more than cleverness.

---

## 12. Worked examples

- **Deploy approval**: a two-option question (`approve`/`deny`),
  `responder_scope: direct`. Ask, wait, act on the chosen value. It renders as
  two lock-screen buttons.
- **Environment picker**: a four-option question
  (`dev`/`staging`/`prod`/`cancel`). It renders as a "choose an answer" sheet
  in the app and as expandable actions on the notification.
- **CI gate**: `pingroom ask "$ROOM" "Ship $SHA?" --yes-no --wait`. The exit
  code reflects answered vs. expired, so a human decision becomes a pipeline
  gate.

---

## Reference implementation

The Question Protocol is implemented end to end in **PingRoom**: delivery rides
the existing notification pipeline (push), waiting is long-poll, expiry is a
scheduled command, and the agent contract is published in the PingRoom agent
spec. Approvals fold in as the canonical two-option question.

A standalone home for the protocol lives at **questionprotocol.io**.
