USER_FLOWS.md — human.work
End-to-end user flows with decision points and error paths. Cross-reference: INFORMATION_ARCHITECTURE.md, VIEWS_BY_ROLE.md, USER_STORIES.md, EXAMPLE-FLOW.md, GLOSSARY.md. Last updated: 2026-05-04.
Symbols:
- → transitions to the next step
- ◇ decision point
- ✗ error path
- ★ key affordance / moment of truth
Flows below are the canonical happy paths. Edge cases that aren't covered here are tracked in USER_STORIES.md gaps.
Flow 1 — New Client Onboarding (AM → Client → first message)
Actors: AM (e.g. Diana), client admin (e.g. Amy), platform (Phase-0 gate, invite system, OTP service). Goal: Acme Financial, a new customer, completes onboarding and lands in the client portal ready to message Bob. User stories: SA-C1, SA-C4, C-O1, C-O2 / O3 (channel setup), C-O4.
1.1 — AM creates the org
- Diana logs in → lands on
/ops/clients. - Click "+ New Client" →
/ops/clients/new(wizard step 1). - Fills Step 1 (Company Info): name "Acme Financial", slug "acme" (
SlugAvailabilityInputconfirms availability live), industry "Finance", company domainsacme.co, primary contactamy@acme.co, country, timezone.- ◇ Slug taken → red error inline → user picks alternative.
- ◇ Email domain not in company domains → warning blocks Continue until either (a) email matches a domain or (b) domain added.
- Continue → POST
createAmOrg→ org created inpending_am_setupstatus → redirect to step 2.
1.2 — AM assigns Specialist
- Step 2 (
/ops/clients/[id]/setup/step2) shows specialist pool. - Diana searches "Bob", selects Bob's persona, marks Primary, sets confidence threshold (default 101 — always HITL — KYC is high-stakes).
- ◇ Bob is at capacity (e.g. already serving N orgs) → soft warning "Bob serves 6 orgs already; consider an alternative" — not a block.
- Continue → assignment persisted → step 3.
1.3 — AM saves profile (one-time)
- Step 3: if Diana hasn't filled her global AM profile before, she fills name + avatar + signature now. If she has, fields are pre-filled and she clicks Continue.
1.4 — Phase-0 gate + send invite
- Step 4 shows Phase-0 checklist (5 items, fed by
getAmOrgPhase0):- ✓ Company info confirmed
- ✓ Slug reserved
- ✓ Specialist assigned
- ✓ AM profile complete
- ✓ Trial end date set (default +30 days)
- Diana reviews the invite preview email (substituted with Acme's name + Bob's persona).
- ★ Click "Send invite" →
sendOrgInvite()→ invite token generated (7-day expiry), email sent to Amy from the platform.- ✗ Phase-0 fails → "Send" disabled with red dot on missing item; Diana fixes upstream and returns.
- Step 4 now shows "Invite sent · expires in 7 days" with Resend / Revoke buttons.
1.5 — Client accepts the invite
- Amy receives email "You're invited to your human.work workspace". Clicks the link →
/accept-invite?token=…→ validates token → redirects to/onboarding. - Onboarding wizard (
/onboarding/step/[step]):- Phase 2.1: OTP verification (email-delivered 6-digit code) + set password + name/title/phone.
- Phase 2.2: Confirm company info + slug. ★ Slug becomes immutable on confirm.
- Phase 2.3: Meet your Specialist (Bob avatar + name; team composition hidden — never show Felix/David).
- Phase 2.4: Channels setup. Email is on by default. WhatsApp toggle (skippable). Slack OAuth optional.
- Phase 2.5: Optional — invite teammates, billing, customize Specialist.
- Phase 2.6: Completion screen → "You're all set" → org status flips to
active→ trial timer starts → invite token invalidated → welcome email sent.
- Amy is redirected to
/client/chat.
1.6 — First contact
- Amy lands on
/client/chatempty state. ★ The Specialist card shows Bob with "Online now" green dot. CTA: claret "Message" button. - Click → new thread created → composer focused.
Decision points
- If Amy doesn't accept within 7 days → token expires. Diana receives a notification and can resend (extends window).
- If onboarding is abandoned mid-flow, server-side state preserves progress. Stale orgs auto-archive after 90 days (
StaleOrgService).
Error paths
- ✗ Slug confirmation fails (race — taken between AM input and confirm) → block with "Slug no longer available, please pick another"; AM is paged.
- ✗ OTP email not received → Resend button + fallback to AM ("contact your account manager Diana for a new link").
Flow 2 — Client Sends First Message
Actors: Amy (client admin), Felix (AI agent on Bob's team), David (expert), platform. Goal: Amy types a request, sees a response in the same thread, with the right "Human review" badge if HITL applied. User stories: C-W1, C-W2, C-W4, C-W5, C-CH1.
2.1 — Send
- Amy lands on
/client/chatempty state. - ★ Click "Message" → POST
/conversations→ new thread → composer focused. - Types: "Hi Bob, I have a customer escalation — John Smith (ID: JS-4821) failed KYC verification…"
- Press Enter → optimistic message appended (immediately renders in the stream as outgoing claret bubble).
- POST
/conversations/:id/messages→ real message replaces optimistic.
2.2 — Agent processes
- Backend dispatches to the agent service (
agent.client.ts→ Python FastAPI). - Felix retrieves Jumio data, computes a draft + confidence score (e.g. 82).
- ◇ Confidence ≥ workspace threshold → message is auto-sent (badge "AI" / blue).
- ◇ Confidence < threshold → ExpertQueueItem created → routed to expert queue.
2.3 — Amy waits
- While HITL: Amy sees a typing indicator (or just no new message). Real-time push via Socket.io if connected; polling fallback every 15s.
- The Specialist online dot remains green (don't expose the queue).
- ★ Once the expert sends → message arrives in Amy's stream as Bob with the "Human review" purple badge inline above the bubble.
2.4 — Amy reads
- Amy reads, replies "CONFIRM" → loop continues.
Decision points
- ◇ Amy switches to email mid-thread → next message arrives at
bob@h.work→ routed byIn-Reply-Toheader → same conversation.ChannelBadgeupdates to "Email" for that message. - ◇ Confidence is high → "AI" blue badge.
- ◇ Confidence is low + expert edits → "Human review" purple badge.
Error paths
- ✗ Send fails (network) → optimistic bubble shows red retry. Toast "Failed to send — tap to retry".
- ✗ Backend agent crashes → fail-open: a polite "I need a moment to look this up — coming right back" auto-message + queue item escalation. (See
CLAUDE.mdDesign Decisions: "Fail-open on agent errors".) - ✗ Whitelist mismatch on inbound email → bounce with a polite reply; not surfaced in Amy's portal.
Flow 3 — Expert HITL Review
Actors: David (expert), Felix (AI agent), Amy (client, indirectly). Goal: David converts a queue item into a sent response in under a minute. User stories: E-R1 through E-R7, E-R13, E-R14, E-L1.
3.1 — Notification
- David is on
/workspace/queue. New item arrives → Socket.ioexpert_queue_item_added→ "1 new" pill increments and the row animates in at the top of the list.- ◇ Push notification (P0 gap E-P3) — when implemented, fires for risk=critical items.
3.2 — Triage
- David scans the list (always sorted newest-first — no sort toggle). Risk pills color-code each row; the right pane header reads "Client Conversation with
{customer}". - ★ Click row →
TicketDetailloads on the right. - David reads the customer thread (Amy's messages + any prior context).
3.3 — Review the AI suggestion
- AI suggestion panel shows:
- Felix's draft (editable textarea).
ConfidenceBar82/100 (color: warning).- Tools used:
jumio.lookup(JS-4821). - Data retrieved: collapsible card with the Jumio response.
- ◇ David agrees with the draft → click "Accept & Send" →
respond()→ message sent as Bob → ticket marked resolved → next ticket auto-selected. - ◇ David wants a small edit → edit textarea inline → click "Send as Bob" → same flow but with edited content. Edit is captured as a Correction (E-L1) for the learning pipeline.
- ◇ David disagrees entirely → click "Ignore & Write" → empty composer → David writes from scratch (the "No Felix" path from
EXAMPLE-FLOW.md).
3.4 — Side actions
- Before sending, David may:
- Click "Assign to me" → ticket marked "You" — colleagues won't pick it up.
- Click "Defer 24h" → ticket disappears from Pending; returns at
deferred_until. - Click "Reassign" → pool dropdown → ticket leaves David's queue.
- Toggle "+ Internal note" → dashed-amber composer → POST with
is_internal=true→ visible only to other experts (E-R9). - Open status dropdown → flip to snoozed / pending / resolved (E-R13).
3.5 — Resolution
- After send, ticket auto-marked resolved. Status dropdown moves to "Resolved". The conversation thread is preserved.
- David moves to the next ticket. Queue auto-refreshes (E-R12).
Decision points
- ◇ Risk level is critical → row has a red bar; SLA strip surfaces it.
- ◇ Multi-step agentic task involved → "Open task" deep-link to
/workspace/tasks/[id]for step approval (E-A1–A3).
Error paths
- ✗ Send fails → toast "Failed to send — try again". Ticket remains pending.
- ✗ Conflicting edit (someone else assigned it mid-edit) → toast "Reassigned to [other expert]"; David's ticket is closed and the next one loads.
Flow 4 — Specialist Serves Multiple Orgs Simultaneously
Actors: Bob (Specialist persona), Amy (Acme Financial), Carlos (Beta Corp), David & Charlie (experts on Bob's team).
Goal: Show how one Specialist persona is shared across multiple orgs without leaking data.
User stories: foundational — supports the Specialist Team model in GLOSSARY.md.
4.1 — Two AMs both choose Bob
- AM Diana assigns Bob to Acme Financial (Specialist Team = [Charlie, David] + [Felix]).
- AM Eve (different AM) assigns Bob to Beta Corp (Specialist Team = [Charlie, David] + [Felix]) — same humans, same AI, same persona.
- ★ Bob is one entity in the database (a Specialist persona row); the assignment is
OrgSpecialistAssignment(Bob ↔ Org).
4.2 — Two clients message Bob in parallel
- Amy (Acme) opens
acme.human.work/client/chat→ sidebar header reads "acme.human.work" → Specialist row "Bob". - Carlos (Beta) opens
beta.human.work/client/chat→ sidebar header reads "beta.human.work" → also "Bob". - ★ Each client thinks Bob is their specialist; neither sees the other org. RLS at the DB level enforces this.
4.3 — Inbound on shared channels
- Both Amy and Carlos use the WhatsApp number assigned to Bob (one number from the WhatsApp Number Pool).
- Amy sends a WhatsApp from
+1-555-AMY→ matches Acme's whitelist → routed to Acme conversation. - Carlos sends a WhatsApp from
+1-555-CAR→ matches Beta's whitelist → routed to Beta conversation. - ◇ Unknown sender → polite auto-reply, no conversation created.
- ✗ Sender on neither whitelist → silently dropped after the auto-reply (no expert queue load).
4.4 — David handles both
- David logs in once (
david@ex.human.work). His queue includes tickets from both Acme and Beta because he's on Bob's Specialist Team. - The org-tabs bar in
/workspace/queueshows "All Orgs · Acme · Beta". - ★ Each ticket is isolated — when David opens Amy's ticket, the customer thread is entirely Acme's data; when he opens Carlos's, entirely Beta's. No cross-contamination.
- David replies in both threads. Each response goes out as Bob to that org's customer.
4.5 — Identity from the client side
- Amy receives "Reply from Bob" via WhatsApp / portal — she sees Bob.
- Carlos receives "Reply from Bob" — he sees Bob.
- ★ Neither client knows the other exists, that David handled both, or that Felix drafted them.
Decision points
- ◇ AM wants to retire Bob →
SpecialistChangeRequestflow → Bob's pool slot returns to available, name reassigned via the pool. Existing org assignments require explicit replacement before tear-down. - ◇ Bob's WhatsApp number is reused after retirement → number returns to pool with status
available(slugs are forever, numbers are recycled — see GLOSSARY.md).
Error paths
- ✗ Whitelist drift (Amy's number changes but the whitelist isn't updated) → polite bounce. AM gets notified to update the whitelist.
- ✗ Specialist email collision at creation (
bob@h.workalready exists as a routing alias) → AM is prompted with alternative names (bob2@h.work,bob.k@h.work).
Flow 5 — Expert Pool Reassignment (bonus flow)
Actors: David (expert in KYC pool), pool admin (superadmin). Goal: Move a ticket from the wrong pool to the right one without losing context.
- David picks up a ticket but realizes it's a billing question, not KYC.
- ★ Click "Reassign" in the ticket detail action bar → dropdown of pools.
- Pick "Billing" → confirm.
reassign()API → ticket leaves David's queue → appears in the Billing pool's queue with all history intact.- David's queue auto-advances to the next item.
- The customer experiences no delay — they still see Bob's online dot.
Flow 6 — Internal Collaboration on a Ticket
Actors: David (handling), Charlie (asked to weigh in). Goal: David asks Charlie about an ambiguous ticket without copying context elsewhere. User stories: E-R9 (internal notes — implemented), E-C3 (inline ticket discussion — gap).
Today (implemented via internal notes)
- David clicks "+ Internal note" → composer flips to dashed-amber mode.
- Types "@charlie what do you think about the override window here?" + sends.
- The note appears in the conversation pane with dashed-amber border + 🔒 icon. Visible only to experts.
- Charlie sees an in-app notification → opens the ticket → reads the note → replies (also internal).
- Once aligned, David switches off internal mode → composes the customer-facing response → sends as Bob.
Future (E-C3 gap — proposed)
- A dedicated "Discussion" tab anchored to the ticket, separate from internal notes (which are part of the message stream). Allows multi-expert threads without polluting the conversation.
Owner: Design + Platform team