lucid.page A Minimal State Machine for Missed-Call Recovery
Text size
Read time3 min

# 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.

text
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.

End