Questions
Status: current reference for the implemented queue-only question subsystem. The schema and guarded lifecycle described below are implemented; the final NOT in scope section records rejected or unbuilt ideas.
Design and implementation history (not operating instructions): docs/implemented-plans/questions-end-to-end.md.
Purpose
A question card is the agent borrowing authority it doesn't have — epistemic authority ("is this true / what do you prefer?") or decision authority ("what do I do with this item?"), usually co-occurring. Two things follow from that framing:
- Every answer is precedent, not just a one-shot effect. When the boxholder answers "receipts like this go in
finance/," the placement is the small part; the rule is the valuable part. A question that only moves one file and discards the rule wastes half the boxholder's answer. - The question declares its learning destination at ask time. The contexts that raise formal questions (jobs, triage, procedures) are prompt contexts where the asking agent already knows why it's asking and where a confirmed belief should live — so declaring the destination is cheap then and mechanical later, versus trying to reconstruct it after the fact.
Every answer therefore has up to two products:
- Directive effect (
directive:) — the concrete action the answer unblocks (move a file, pick a category, resolve an ambiguity). - Durable learning (
learning:) — the belief the answer confirms or denies, recorded in a knowledge sink as abasis: user-statedfact. This reuses the evidence/confidence vocabulary fromdocs/implemented-plans/box-retrospectives.md: inferred beliefs cap atmediumconfidence; a question card answered by the boxholder is the promotion mechanism to boxholder-endorsed fact.
Never auto-answer. A question that goes unanswered ages out of the active view (see Aging below), but the system never synthesizes a response on the boxholder's behalf — see Prior art in the plan for why (Claude Code's AskUserQuestion timeout default and LangGraph's indefinite-wait interrupts were both considered; this system follows the latter).
Card shape
Schema: src/schemas/question.ts (QuestionSchema, type question).
---
memo: > # why you're asking — so the boxholder can
# answer without looking anything up
prompt: "…" # the actual question
input:
type: select # select | text | confirm
options: # select only — at least 2, unique ids and
- { id: finance, label: "Finance" } # case-insensitively-unique labels
- { id: _other, label: "None of these — write a directive" }
learning: # optional
sink: guide # guide | briefing | personality
ref: /_config/finance.guide.card # optional; for sink briefing MUST be the
# root briefing (only it compiles into
# the box AGENTS.md — see below)
proposal: >
Receipts photographed at intake belong in _content/finance/receipts/.
directive: "Move the held file into _content/finance/receipts/."
context:
- { ref: /_content/inbox/triaged/_unsure/receipt.pdf }
asked-at: 2026-07-10T09:00:00-07:00 # set by the creating template
expires-after: P30D # optional ISO-8601 duration override
---
Filled in by the answer/dismiss/expire transitions, never by the asker: answer: { text, selected? }, answered-at, answered-via (web | cli), dismissed-at, expired-at. The card has no status field: its state is derived by questionState (question.ts) — answered-at means answered, else dismissed-at dismissed, else expired-at expired, else pending. A superRefine (refineQuestionLifecycle) allows at most one of the three timestamps, requires answer with answered-at, and rejects answer or answered-via without it. Re-answering a dismissed or expired question clears its dismissed-at/expired-at as part of the transition.
Input types and the option-id scheme
select—options: [{id, label}], at least two entries, enforced by a ZodsuperRefine(an illegal select-with-no-options card can't be created or loaded).idis the stable machine key an answer resolves to;labelis what the boxholder sees. Ids must be unique; labels must be unique case-insensitively (answer resolution matches a typed answer against labels withtoLowerCase(), so two labels differing only in case would be ambiguous). An answer may arrive as a letter (a,b, … indexing intooptionsin order), a label, or aselectedIddirectly —resolveSelectAnswer/resolveSelectinsrc/core/commands/answer.tsnormalize all three to the option's{id, label}pair.text— nooptions; the answer is free text.confirm— nooptions; the answer is normalized toselectedId: "yes" | "no"(the UI sends this directly as Yes/No buttons, with an optional free-textanswernote carried alongside), or accepted as typed free text (yes/y/true/1,no/n/false/0) for API/CLI callers.confirmandtextmust NOT carryoptions— the samesuperRefinerejects one that does.
Where questions live
All question cards live in _bookkeeping/questions/. This is the only place the system looks for questions: getSystemState (src/core/state.ts) globs _bookkeeping/questions/**/*.question.card to build the question list that feeds the header badge, the questions page, and the aging sweep. A question card written anywhere else (e.g. inside a capture session's .attach/ scope, which scan-import used to do) is invisible to all of that — no page, no badge, no notification, no aging. If a question needs to reference material that lives elsewhere (an attach-scope file, a held inbox item), that's what context: [{ref, text?}] is for — the card itself stays in _bookkeeping/questions/.
Lifecycle
pending ──answer──> answered │ ^ ├──dismiss──> dismissed ──answer──┘ │ │ └──(30d)──> expired ──answer──────┘
pending— awaiting a response; no lifecycle timestamp. Every question is created pending.answered(answered-at) — terminal: the card no longer accepts a fresh answer (answer.tsallows answering only frompending,expired, ordismissed). A new question is asked instead of reopening one.answered-viarecordsweb,cli, orchat; achatanswer is the agent recording what the person said in a conversation, and writes no follow-up job (see "The answer round-trip").dismissed(dismissed-at) — put away for now: the person chose "Later" (bbx dismiss/actions.dismiss) or "Decide for me" (actions.figureOut). Only apendingquestion can be dismissed. Dismissed questions remain answerable, and the daily question review brings each one back (bbx question reraise, which setsasked-atto now) or decides it (see "Figuring it out").provisional(field, not a state) — what the box decided in place of an answer (bbx question decide):decision,reasoning,undo,decided-at. It sits on a dismissed or expired card, and stays as history on one the person later answers. It is never the person's answer: it sets noanswered-atoranswer:, and what the box learns from it isbasis: inferred.expired(expired-at) — aged out of the active view by the aging sweep without an answer. Expiry demotes visibility, it does not close the question — the card stays in_bookkeeping/questions/and remains answerable. Only apendingquestion can expire (this is the sweep's own transition).
Before asking something new, agents are expected to check _bookkeeping/questions/ including answered/dismissed/expired cards, not just pending ones — see "The decision rule for agents" below.
Aging: nudge, then expire — never auto-answer
Implemented in src/core/question-aging.ts (ageQuestions), run from the same bbx finalize call site as the pending-question notification sweep (checkPendingQuestionsAndNotify, src/core/question-alert.ts) — both ride bbx finalize's existing per-wakeup cadence.
- Nudge at 7 days pending (default:
DEFAULT_NUDGE_AFTER_MS). One re-notification, sent through the box server frombbx finalize, latched (.beebox/notified-questions.json, thenudgedmap) once it was sent, so it fires once per question; a nudge that did not go out is tried again at the next finalize (notifications). - Expire at 30 days pending (default:
DEFAULT_EXPIRE_AFTER_MS). Setsexpired-at, commits with trailerExpired-By: question-aging, drops the question from the active list/header count/notification set. Never synthesizes an answer. expires-after:on the card overrides both defaults for that question — a time-sensitive ask can expire sooner, an evergreen one can last (or never) expire on the default clock. When overridden, the nudge fires at half the expiry window rather than the flat 7-day default.- Age is computed from the card's durable
asked-atfield, never from latch state. A lost or corrupt latch file can only cause a duplicate nudge — it can never reset a question's age or block its expiry. The latch's only job is nudge-dedup. - Expiry is not notification-channel-gated. The aging sweep's expiry half always runs — only nudge delivery depends on a channel existing (the header badge is the channel-independent surface). With nobody to reach, the nudge and the alert are skipped
no-audienceand latched. - Time comes from
getBoxTime/getBoxTimeISO(BBX_TIME-honoring), nevernew Date(), so the sweep is testable with frozen time (test/core/question-aging.test.ts).
The answer round-trip
Answering, dismissing, and expiring all go through the same guarded transition helper: withQuestionTransition in src/core/question-transition.ts.
Why a guard, not just withCardLock. withCardLock (src/lib/card-lock.ts) only serializes read-modify-write within one Node process; CLI answer, web answer, bbx finalize, and the scheduler are different processes. withQuestionTransition composes two locks:
- The cross-process file lock (
src/lib/file-lock.ts, one lock file per question under.beebox/question-locks/) serializes across processes. withCardLockserializes overlapping in-process callers on top.
After both locks are held, the card is re-read and its state re-checked against the caller's allowedStates — so a transition that lost a race (another process already committed a different state) sees the winner's committed state and is rejected with a clear error, rather than clobbering it.
One commit, write-order matters. Inside the guard, the caller's plan returns a list of writes plus a commit message/trailers. applyAndCommit writes every file, then commits them all in one stageAndCommitPaths call. For answer, the plan lists the follow-up job file before the answered card — the card's new timestamp is the commit point, so the companion job must already exist on disk when it lands. A crash between the two writes leaves job + still-pending-question (harmless — answering again just creates a second job), never the unrecoverable answered-card-with-no-job state that existed before this guard.
Rollback on commit failure. The filesystem writes aren't atomic with the git commit. If stageAndCommitPaths fails, applyAndCommit unstages the paths and restores every touched file to its pre-write snapshot (or deletes it, if it didn't exist before) — so a failed transition leaves the card exactly as it was on disk and stays retryable, instead of stranding an answered-on-disk card that rejects retries.
State guards per transition:
| Command | Allowed from | Sets | Follow-up job |
|---|---|---|---|
answer (src/core/commands/answer.ts) | pending, expired, dismissed | answer, answered-at, answered-via; removes dismissed-at, expired-at | Yes |
dismiss (src/core/commands/dismiss.ts) | pending | dismissed-at | No |
aging sweep's expire (src/core/question-aging.ts) | pending | expired-at | No |
Follow-up job: the two-product contract
Answering creates a question-followup-job card (src/schemas/question-followup-job.ts) carrying question-ref, directive, the resolved answer text, and (when present) the question's learning passthrough. Its instructions tell the executing agent to do, in order:
- Execute
directive:usinganswer:. - If
learning:is present, record it inlearning.sinkas abasis: user-statedbelief — quote the answer, ref the question card (same evidence discipline as the retrospective's integrate step). A "no" is also learning: record the decline against the proposal rather than dropping it. For sinkbriefing, only the root briefing compiles into any agent's context (compileBriefingsonly compiles the root — directory briefings are an explicit TODO,src/core/docs-gen/compile/core.ts:74-101), solearning.refmust resolve to the root briefing even if it names something else; the job instructions say to resolve and note the substitution. - If
learning:is absent, still ask whether the answer generalizes — many answers are precedent even when the asker didn't declare a destination. Record it if it does; skip if it's genuinely a one-off (the common, correct outcome — not a failure).
This is where learning.sink/basis: user-stated actually lands: the follow-up job is the only writer of the belief, and it always writes with that source tag, never a higher-confidence one — a question answer is a boxholder statement, which is exactly what user-stated means in the retro evidence model.
Surfacing
- Beside a new chat (
docs/implemented-plans/questions-beside-the-chat.md) — when a new root chat arrives with no cards open, the workspace opens the newest pending question (byasked-at, then path; never an unparseable card) as the active tab: on the desktop on top of the Box page, on the phone alone, with the composer under it (arrivalPlan,WorkspaceProvider/arrival.ts). A chat opened beside another card, or one with a saved arrangement, opens no question. Beside the chat the question is pasted onto a stub card with no answer controls (QuestionForm/beside.tsx, look inthemes/question-beside.css): the person answers in the chat, and aselectquestion lists its choices in a line. The pinned row offers "Later" (puts it away; the tab's ✕ does the same) and "Decide for me" (hands it to the box in the background; see "Figuring it out"). In a chat, the app bar's question count shows the newest pending question beside the chat, or puts the one in front away without "Later" (useQuestionToggle,question-tab.ts). With a pending question in front, the empty chat says "Answer the question “<card title>” here, or start something else." and shows no openers (useQuestionInFront), so a reply reads as a probable answer. The question reaches the agent as theopen-cardsnapshot attribute; the agent judges whether the message answers it, does the directive in the conversation, then records the answer withbbx answer --via chat. A dismissed or expired question comes back the same way: "Answer in chat" on its Questions page row, or the agent's link to the card. A new box seeds one question (installRootQuestion): "What do you want to keep track of here?" - App bar count — the app bar shows the pending-question count beside the todo count (
AttentionBadgesFace.tsx,id="bbx-nav-questions"), linking to the Questions page. The Dashboard's attention cards (fed by the samestatus.questionstRPC query, invalidated onquestion-answered/card-created/question-expiredbus events) carry the same signal. - Questions page (
QuestionsPage/QuestionsList.tsx) — the full list: pending at the top, an archive of answered/dismissed/expired below showing the recorded answer and state (not just a state word). Expired questions render with the answer form still available — demoted, not closed. - Card renderer — the
questionfile-type renderer (src/frontend/src/renderers/question.tsx) renders/views/<path>card deep links (from notifications, dashboard,context:refs): shows the embeddedQuestionFormwhen the question is answerable, or the recorded answer plus thelearning:proposal once it's answered. The proposal is what the box wanted to learn, stated at ask time — not a record of what was actually learned; that recording happens separately, in the follow-up job's commits. Every deep link into a question card lands somewhere actionable, never a bare frontmatter dump. - Type-driven form (
QuestionForm/view.tsx) — dispatches exhaustively oninput.type:confirm→ Yes/No buttons + optional note;select→ radios (submits the label; the backend resolves it);text→ textarea.memoandlearning.proposalrender above the widget — the boxholder sees what the box is trying to learn before answering. "Later" is available whilepending. Questions the box decided are listed under "The box decided for now", with the decision and its reasoning, and stay answerable. - Notifications + nudges —
checkPendingQuestionsAndNotify(question-alert.ts) notifies once per newly-pending question throughnotifyBoxholder(push); the aging sweep's nudge is a second, one-time reminder at the 7-day/half-window mark. Both are best-effort; regardless of notification-channel configuration, pending questions remain visible on the Dashboard attention surface and Questions page.
The decision rule for agents
Where an agent is deciding whether to ask, and how, is the operative question — not a separate policy from the lifecycle above. Stated in the QUESTIONS section of src/core/agent-guide/guide.md, which is always-on agent-guide context:
- In chat, just ask. A synchronous conversation is not a question-card situation — the boxholder is right there. Never create a question card for something answerable in the reply. (This is also why in-chat interactive questions were rejected as a separate primitive — see NOT in scope below.)
- In a job, triage pass, procedure, or wakeup, creating a question card with
learning:declared is a good default when the learning destination is known — not a fallback for having failed. If the situation blocks on ambiguity it can't resolve, the agent finishes by asking rather than guessing past it. - Before asking, check
_bookkeeping/questions/— includinganswered,dismissed, andexpiredcards, not justpendingones. An existing answer is auser-statedfact; don't re-ask it. A dismissal or expiry is itself a signal ("the boxholder didn't care to answer that") that raises the bar for asking again. - Always set
directive:— without it the answer's immediate effect goes nowhere (the follow-up job still recordslearning:if declared, even with no directive). Setexpires-after:for a question that should age out sooner (or later) than the defaults.
Figuring it out
A question the person hands off, or one that waited long or was put away, is settled by an agent with two box skills (docs/implemented-plans/questions-later-and-figure-it-out.md):
figure-it-outdecides conservatively (the reversible choice that changes least; never delete, send, spend, publish, or reword the person's words) and records it withbbx question decide, or makes the question simpler and asks it again. It never runsbbx answerfor a question the person did not answer.simplify-questionrewrites a question so it is simpler for the person to decide or understand: concrete, with the box's default, or split into the part the box can decide and the part only the person can.
"Decide for me" on the card puts the question away and starts a background chat (actions.figureOut, through deliverUserMessage); the chat is in the chat list. The daily question-review procedure takes questions put away three or more days ago and questions pending fourteen or more days, five per run, and ends each one decided or asked again. When the person later answers a decided question, the follow-up reconciles: it reverses what it can and says what it could not.
NOT in scope
- An in-chat question tag. Rejected, not deferred (
issues/closed/features/2026-06-09-in-chat-interactive-questions.md): synchronous chat is a different situation from the async queue — the agent just asks in prose, and retrospectives convert chat into learning._bookkeeping/questions/is the only structured-question primitive. A question beside the chat (above) is a pending card in the workspace, answered in words or on the card; it creates no cards from chat and adds no structured in-chat control. - Web-push action buttons. Chromium-only across browsers; revisit with the iOS app's push story.
- Question batching (one card covering several held items at once). Atomic cards keep learning capture and lifecycle per-question; batching, if it comes, belongs in the surfacing layer, not the card shape.
- Automated stop-asking aggregation (detecting that a whole class of question never gets answered, and suppressing it). The durable
expired-at/dismissed-attimestamps created here are the substrate a future detector would read; the detector itself is undesigned.
See docs/implemented-plans/questions-end-to-end.md for the full rationale, prior art, and failure-mode analysis behind these decisions.