1nicorn LearnBuild a company with one person and AI

Tutorial

Build a Needs-you queue: one clarification queue every module can ask through

A shared human-in-the-loop queue so no feature in a modular platform ever guesses or stalls on a missing fact.

Balaji Renukumar
Oct 29, 2026 - 14 min read
Levelintermediate
Time45 min
Steps8
Free toStep 2

What You Will Build

A single, platform-wide "Needs you" queue that every module in a modular product can emit into when it is missing one fact only the customer can give. By the end you will have a question record that every feature fills the same way, a server helper any module calls, one honest count that the whole UI reads from a single source, a guess helper that is bounded and sourced and never fabricates, and an answer path that resolves inline, returns the user to where they were, and remembers the answer so the question is never asked twice.

The point is not the table. The point is the discipline the table enforces: never guess, never stall, never nag, and never show a value you already have as a gap. We learned every one of those rules the hard way, by shipping a version that broke them and fixing it the same day.

Prerequisites

  • A multi-module app where several features each hit "missing information" moments (bookkeeping, email replies, documents, scheduling, posting, and so on).
  • A relational database you can add a table to (the examples use Postgres).
  • A server layer your modules already call (the examples use TypeScript, but the shape is language-agnostic).
  • A cockpit, dashboard, or home screen with somewhere to show a count and open a card.
  • Optional: a desktop agent or worker that runs jobs away from the request cycle, if some of your modules do.

Tip

You do not need every module ready. Build the queue, wire one module end to end, and let the others adopt it one at a time. One good emitter proves the contract.

Mental Model

A module runs until it cannot decide, because it is missing a fact only the user holds. Instead of guessing or stalling, it hands the queue a question and a way to resume. The queue checks whether this exact question was answered before; if so it returns the answer and asks nothing. Otherwise it posts one question, with up to three sourced guesses and a type-in box, into the single cockpit count. The user answers at the next quiet moment, inline, one card at a time. The count clears instantly, the module that asked finishes the job, and the answer is remembered so it is never asked again.

This is a known pattern with a known name. In automation it is a human-in-the-loop work item: a task pauses, routes to a person with the input, the system's guess and its source, the decision required, and a handle to resume, and it keeps an audit trail. Your queue is that work item, made platform-wide and phrased as a question.

The clarification lifecycle: a module gets stuck, the queue checks memory, asks once, the user answers inline, the module finishes and remembers.

The one state that trips people up is the resting state. A question nobody has answered yet is not an error and not a timeout waiting to fire. It simply waits. It never expires into a guess.

Steps

Model the question as one contract

Define the record once, and make every module fill the same fields. The schema below maps field for field onto a human-in-the-loop work item: who asked, what it is about, the input shown, the sourced guess, the decision, the resume handle, and the audit stamp.

create table clarification_queue (
  id            uuid primary key default gen_random_uuid(),
  tenant        text not null,
  emitter       text not null,                 -- the module that asked
  reason_code   text not null,                 -- stable code, e.g. meetings.attendee_unknown
  subject_key   text not null,                 -- the thing it is about (a meeting id, a doc id)
  question      text not null,                 -- one plain sentence
  context       jsonb,                         -- { text, link }, optional
  guesses       jsonb not null default '[]',   -- [{ value, label, source }], 0 to 3
  answer_kind   text not null,                 -- 'email' | 'text' | 'choice'
  blocking      boolean not null default false,
  resume        jsonb not null,                -- opaque handle the emitter uses to finish
  status        text not null default 'open',  -- 'open' | 'answered' | 'withdrawn'
  answer        jsonb,                         -- { value, typed, who, when }
  created_at    timestamptz not null default now(),
  answered_at   timestamptz,
  unique (tenant, reason_code, subject_key)    -- the ask-once key (Step 2)
);

There is deliberately no expired status. You know it worked when a module can write a row with one call and the row carries everything the cockpit needs to render a card without going back to the module.

The question record: one contract, the same fields for every module.

Done

You have one table that holds any module's question, its sourced guesses, and the handle to finish the job, with no expired state.

Make identity reason_code plus subject_key, and ask once

A question's identity is reason_code plus subject_key. Before you ever enqueue, check memory: if this exact thing was answered before, return the answer and enqueue nothing. If an open question with the same identity already exists, fold the new one into it rather than stacking a duplicate. That single unique constraint from Step 1 does the folding for you.

type Guess = { value: string; label: string; source: string };

export async function queueQuestion(q: {
  tenant: string; emitter: string; reasonCode: string; subjectKey: string;
  question: string; context?: { text: string; link?: string };
  guesses: Guess[]; answerKind: "email" | "text" | "choice";
  blocking?: boolean; resume: unknown;
}): Promise<{ status: "answered" | "queued"; answer?: string }> {
  // Ask once: did we answer this exact thing before?
  const known = await recallAnswer(q.tenant, q.reasonCode, q.subjectKey);
  if (known) return { status: "answered", answer: known };

  // Fold a duplicate into the question already open (the unique key no-ops the insert).
  await db.insert("clarification_queue", {
    tenant: q.tenant, emitter: q.emitter, reason_code: q.reasonCode,
    subject_key: q.subjectKey, question: q.question, context: q.context ?? null,
    guesses: q.guesses.slice(0, 3), answer_kind: q.answerKind,
    blocking: q.blocking ?? false, resume: q.resume,
  }).onConflict(["tenant", "reason_code", "subject_key"]).doNothing();

  return { status: "queued" };
}

Done

A module calls one helper; if the answer is already known it comes straight back, and a repeat question never creates a second row.

Sources

  1. Human-in-the-loop work item: input, output, confidence, decision, resume, audit (UiPath Action Center)docs.uipath.com
  2. Mixed-initiative: act when it beats asking, else ask (Horvitz, CHI 1999)dl.acm.org
  3. Alert fatigue: more alerts, lower acceptance (Ancker et al., 2017)ncbi.nlm.nih.gov
  4. Google PAIR: suggest when confident, keep it overridablepair.withgoogle.com
  5. Salesforce duplicate rules: Block versus Allow-with-Alerthelp.salesforce.com
  6. Stripe account requirements: payouts then charges disabled past deadlinedocs.stripe.com
  7. Mailchimp: physical mailing address required in every promotional email (CAN-SPAM)mailchimp.com
  8. Mailchimp send checklist: all items returned togethermailchimp.com
  9. Google Business Profile: a suggested edit can auto-publish after four days of silencesupport.google.com
  10. Meta Opportunity Score: a 0-100 number that reflects adopting recommendations, not performancejonloomer.com
  11. Google Workspace alert center: assign an alert to the admin who owns itknowledge.workspace.google.com
  12. Progressive disclosure: ask just-in-time (Nielsen, NN/g)nngroup.com

More from 1nicorn Learn