# A Minimal State Machine for Missed-Call Recovery
A missed phone call looks like one event, but reliable recovery needs more than a callback button. The system must know whether the request is new, whether a person owns it, whether an external action is still uncertain, and why the work closed. A small state machine makes those rules visible.
Contents
# Start with six states
Use NEW, TRIAGED, OWNED, WAITING, CLOSED, and IGNORED.
NEW means the call ended without a completed conversation and nobody has
reviewed it. TRIAGED means the caller and probable intent are known. OWNED
means one worker or person has accepted the next action. WAITING means the
team acted and now waits for the caller or another system. CLOSED requires a
real outcome. IGNORED is reserved for spam, duplicates, or events that do not
represent customer work.
Do not use a generic DONE flag. It hides whether a customer received an
answer, booked a service, declined an offer, or never responded.
# Make transitions explicit
Each transition should record the request ID, prior state, next state, actor, timestamp, and reason. That gives the queue enough information to reject stale updates.
transition(request_id, expected_state, next_state, actor, reason)
The expected_state check matters. If a human claims a request while a retrying
worker still believes it is TRIAGED, the worker must not overwrite the newer
OWNED state. A compare-and-set update or database transaction is usually
enough. A distributed lock is not the first tool to reach for.
# Separate intent from provider outcome
Before sending a text, creating a CRM task, or placing a callback, persist an
action record with a stable idempotency key. Mark it PLANNED, then call the
provider. Store the provider result as CONFIRMED, FAILED, or UNKNOWN.
UNKNOWN is important. A timeout does not prove failure. Retrying without the
same idempotency key can produce duplicate messages or tasks. The recovery job
should first query the provider when possible, then retry only when the original
outcome is known to be absent.
# Treat human takeover as suppression
When a person takes ownership, automatic actions for that request should pause. The pause belongs to the request, not the whole workflow. Global disable switches turn one exceptional caller into missed follow-up for everybody else.
Record which scheduled actions were suppressed and why. If the person later releases the request, the system can calculate the next valid action from the current state instead of replaying an obsolete queue job.
# Close with outcome codes
Useful outcomes include BOOKED, ANSWERED, ROUTED, DECLINED, DUPLICATE,
SPAM, and NO_RESPONSE_AFTER_POLICY. These codes support operational review
without requiring somebody to reread every transcript.
An AI receptionist such as SkipCalls can capture the initial intent and urgency. The downstream state machine still owns delivery, human handoff, retries, and closure. That separation keeps conversational logic from becoming the system of record.
# Test the races
The minimum useful test set includes two workers claiming one request, a provider timeout after success, a human takeover during a scheduled retry, a duplicate inbound event after closure, and a late provider webhook. Every test should assert the final state and the number of external actions.
The state machine is intentionally small. Its value comes from making ownership and uncertainty explicit, not from modeling every sentence in a phone call.