August 28, 2026 · The Pengo Team
Designing a chat state machine that agents actually understand
Why we replaced ad-hoc status flags with a explicit pending → open → resolved lifecycle, and what that means for routing, analytics, and the mobile app.
#engineering #product
Live chat products love to accumulate status fields. Over time you end up with waiting, unassigned, active, closed, and archived all meaning slightly different things depending on which screen you’re looking at. Agents learn the quirks. New hires don’t.
When we rebuilt Pengo’s inbox, we decided to collapse the chaos into four lifecycle states that match how support teams actually talk about work:
- Pending - a visitor has sent a message and nobody has claimed the chat yet. In the UI we label this Incoming.
- Open - an agent (or the AI) is actively working the conversation.
- Snoozed - temporarily parked with a reminder to reopen later.
- Resolved - the conversation is closed. Visitors can reopen by sending another message.
That’s it. No parallel boolean flags pretending to be statuses.
Why “pending” and “incoming” are the same thing
Sales and support teams don’t say “this chat is in pending state.” They say “there’s an incoming chat waiting.” We kept pending in the database because it’s precise for engineers and analytics, but every agent-facing surface - web inbox, mobile app, push notifications - says Incoming.
The distinction matters for routing. A chat stays pending until an agent claims it or automation assigns it. Claiming atomically sets assigned_to, flips status to open, and disables the bot so a human owns the thread.
Normalizing legacy values
Real databases have legacy rows. Our normalizer maps waiting → pending and closed → resolved so historical data doesn’t break charts after migration. API responses always emit the canonical enum.
What this unlocks
Inbox sections become queries, not hacks. “Incoming” is status = pending AND last_message_at IS NOT NULL. “Mine” is assigned_to = me AND status IN (open, pending, snoozed). No special-case joins.
Analytics get honest. Active conversation counts use open OR pending OR snoozed. Resolved counts use resolved. Agent productivity attributes resolution to whoever was assigned when the chat closed.
Mobile parity. The Flutter agent app uses the same filter chips: All, Open, Incoming. Swipe-to-assign claims pending chats through the same claim path as the web inbox.
Transitions are validated, not improvised
We maintain an explicit transition table. You can’t jump from resolved to snoozed without reopening first. Server routes call assertValidTransition before writes so a buggy client can’t corrupt state.
Snooze deserves special mention: it’s not “closed.” A snoozed chat still belongs to the agent who snoozed it, and it resurfaces in their queue when the timer fires.
Lessons for anyone building inbox software
- Pick vocabulary agents already use and encode it in the schema, even if engineers prefer different words internally.
- One status column beats three booleans (
is_active,is_waiting,is_closed) that drift out of sync. - Claim/assign must be atomic - use a conditional update (
assigned_to IS NULL) so two agents never think they both own a chat. - Publish state changes over realtime so web, mobile, and reporting see the same timeline.
We’re not claiming this is novel - it’s the boring solution. But boring status models are why agents trust the queue count at the top of the sidebar.
If you’re evaluating Pengo, watch the inbox during a busy hour: incoming counts, assignment, and resolution all follow the lifecycle above. No hidden states, no surprises.