A caller says their phone number. The transcriber hears "4 1 5, 5 5 5, 1 0 0 9", or it hears "four one five triple five ten oh nine", or it hears the first six digits and then a pause it interprets as the end of a sentence. Meanwhile a model in the middle of the loop is perfectly capable of emitting a JSON tool call for a customer who is not on the line.

Everything that follows is a corollary of one rule, so it is worth stating before any code:

An agent node is the only place a model runs. A tool node is the only place facts get written. The model proposes; the code disposes.

This post is the build in order. Fourteen steps, each one a file or a function, each one prompted by something that broke on a real call. You can call the finished thing yourself — the number and a test identity are on the front page.

Step 01

Answer the phone by pretending to be OpenAI

Vapi owns the telephony, the speech-to-text and the text-to-speech. What it wants from me is an OpenAI-compatible chat completions endpoint: it POSTs a transcript, I return a message, it speaks it. That means the entire agent hides behind one route, and the graph never knows a phone exists.

The shape of the response matters more than it looks. Vapi retries on malformed bodies, so even a deliberately empty reply has to be a well-formed completion:

app/main.py — an empty reply still has to be a valid completion

def _no_reply(body: dict, call_id: str):
    """An empty, correctly-shaped OpenAI response, in whichever form the request asked for.

    Shape still matters even though Vapi is about to discard this: a malformed body would be
    an error on its side rather than a discarded reply, and errors are retried.
    """
    response_id = f"chatcmpl-{call_id}-superseded"
    model = body.get("model") or "gpt-4o-mini"
    if body.get("stream"):
        return StreamingResponse(
            _sse(response_id, model, ""),
            media_type="text/event-stream",
            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
        )
    return JSONResponse(_full_response(response_id, model, ""))

Hold on to -superseded in that response id. It is a clue about step 04, and it did not exist in the first version of this file.

Step 02

Give the call a notepad

Every turn is a separate HTTP request and the server remembers nothing between them, so whatever identifies the conversation has to arrive inside the request. Vapi's call.id is stable across turns and unique per call, which makes it the thread_id — a key in a dictionary, nothing to do with CPU threads, and the first write creates the entry.

The state object is where the design lives. In LangGraph, the annotation on each field decides how a partial update merges, and getting it backwards produces very specific bugs:

app/state.py — the merge rule is the whole file

"""One shared state object per call. Every node reads it and returns a partial
update; LangGraph merges what comes back.

The annotation on each field decides how that merge happens. `Annotated[...,
operator.add]` appends, so the field accumulates. A plain annotation overwrites,
so the field holds only the latest value. Getting this backwards produces the
classic bugs: a transcript that resets to one message, or a boolean that grows
into a list.
"""

Which cashes out in the fields themselves. Note that the counter is not a reducer, and why:

app/state.py — CallState, abridged to the load-bearing fields

class CallState(TypedDict):
    """The notepad for one phone call. messages appends, everything else overwrites."""

    # The transcript. Needs the reducer: every node adds to it and none may
    # replace it, or the agent forgets the call mid-conversation.
    messages: Annotated[List[AnyMessage], operator.add]

    # This turn's caller utterance. Whichever node consumes it clears it, so a
    # tool loop back into the agent doesn't append the same words twice.
    user_query: str

    # The auth grant. Written only by auth_tool_node, only from a parsed tool
    # result — never from model output. This is the authorization boundary: the
    # model can say "the customer is C001" and it lands in messages, nowhere else.
    authenticated: bool
    customer_id: Optional[str]
    customer_name: Optional[str]

    # Overwrite, not a reducer. The tool node computes old + 1 and returns the
    # total; a reducer would add that total to the old value and double it.
    auth_attempts: int

Two details in the seed function are scars rather than decisions. claims starts as None and not [], because an empty list already means something — "we looked, this account has no claims" — and conflating that with "we have not looked yet" costs a wasted database trip or a wrong answer. And turn_number is an explicit counter set by the HTTP handler rather than something derived by counting messages:

app/state.py — why the turn counter is explicit

    # Which caller turn this is. Set by the HTTP handler, not by a node, because
    # only the handler knows a request arrived.
    #
    # It was derived from counting HumanMessages until a live call proved that
    # wrong: Vapi asks for a model-generated opening line with no user message
    # attached, so that turn appends no HumanMessage and the next real utterance
    # reuses its number. Three snapshot rows came back labelled turn 1. An
    # explicit counter cannot be fooled that way.
    turn_number: int

Step 03

Stop counting one sentence four times

Then a caller paused in the middle of saying their phone number and got hung up on.

Vapi does not send one request per caller turn. It sends one every time endpointing thinks the caller might have stopped, and the transcript grows each time. The docstring on the function that fixed it is the clearest statement of the problem I have:

app/state.py — utterance_continues

def utterance_continues(messages, user_query: str) -> bool:
    """True when this utterance is the previous one with more words on the end.

    Vapi does not send one request per caller turn. It sends one every time its
    endpointing thinks the caller might have stopped, and the transcript so far grows
    each time. A caller saying a phone number with pauses produced four requests:

        "Yeah. My number is"
        "Yeah. My number is 4 1 5"
        "Yeah. My number is 4 1 5 5 5 5"
        "Yeah. My number is 4 1 5 5 5 5 1 0 0 9"

    Vapi speaks only the reply to the last one and discards the rest, so the caller
    hears a single exchange. Our state does not get that luxury: each request was
    counted as a turn that produced no usable number, and the stall ceiling tripped
    before the model ever saw the complete digits.

    The saving grace is that these are cumulative, not disjoint — every request
    contains the previous one verbatim. That is what makes them detectable here rather
    than only fixable in Vapi's endpointing settings.
    """
    previous = next(
        (m.content for m in reversed(messages or []) if isinstance(m, HumanMessage)),
        None,
    )
    if not previous or not user_query:
        return False
    before, now = _squash(previous), _squash(user_query)
    return bool(before) and len(now) > len(before) and now.startswith(before)

A prefix comparison, and _squash lowercases and strips punctuation so the comparison survives the transcriber changing its mind about commas. That keeps the turn counter and the stall count off the re-sends.

Step 04

Stop running on one sentence four times

Not counting them was only half the job. The graph was still executing on every re-send: appending messages the caller never heard, routing on half a sentence, sometimes firing a tool. One utterance, two graph runs, and the caller hearing the two spliced together — "I see 1 claim. You're verified," then "but policy questions aren't answered yet."

The fragment cannot simply be skipped, because it arrives first:

app/main.py — the debounce, and what it buys

# The fragment cannot simply be skipped, because it arrives *first* — the complete utterance
# is the last one, and nothing in the request says which is last. So wait instead: hold each
# request briefly, and if a newer one for the same call shows up meanwhile, this one has been
# superseded and Vapi is going to throw its answer away regardless. Return without touching
# the graph.
#
# The cost is one wait per turn. The saving is the one-to-two wasted graph runs per utterance
# it replaces, each of which was 1-3 model calls — measured at 6.2s and 10.3s of model latency
# on the call above. Raising Vapi's endpointing (`startSpeakingPlan`) reduces how often this
# fires; it does not make it unnecessary, because endpointing is a guess about human speech
# and this is a fact about our own request stream.
DEBOUNCE_SECONDS = 0.4

# Per call, incremented on arrival. A counter rather than the utterance text, so two requests
# carrying identical text are still ordered.
_utterance_seq: dict[str, int] = {}

A counter rather than the text, so two identical transcripts are still ordered. And the wait happens before the per-call lock from the next step — a waiting request holding the lock through its own wait could never be superseded by anything, which defeats the entire mechanism.

Step 05

One lock per call, and no lock across calls

I had written down that two requests for one call could never overlap, reasoning that Vapi waits to hear turn N before sending N+1. It does not. Both requests read state before either wrote, and the caller heard the same question twice in different words.

The fix is an asyncio.Lock keyed by call_id — turns serialize within a call, calls stay concurrent with each other. Which is the entire concurrency model of the service, and worth drawing:

04  Technical discussion System design

A single worker is load-bearing

Two workers means two checkpointers — turn 2 lands in a process that never saw turn 1.

One asyncio.Lock per call_id

Turns queue within a call. Calls run alongside each other.

call A turn 1 turn 2 turn 3 serialised
call B turn 1 turn 2 turn 3 concurrent

Async handler, blocking nodes

An awaited async invoke offloads to a thread executor. The synchronous call would freeze every other caller. Audit writes are never awaited.

What production needs, in order
01 A shared checkpointer

Everything else about scaling is downstream of it

02 Deterministic call ending

Today Vapi hangs up by matching spoken text

03 RLS, and redaction

Two layers where there is one. PII out of the store

04 An offline eval set

Turn measured thresholds into numbers that hold

What I would not add

Reranking for 20 sections. Streaming on a turn with nothing to stream.

Runtime 04 / 04

Step 06

The first tool, and the direction of trust

Now the agent can hear a full sentence exactly once. Time to let it look somebody up — and this is the step where the security property either exists or does not. The docstring says it better than I can paraphrase it:

app/tools/auth_tools.py — the module docstring is the design

"""The one tool the auth agent can reach.

Note the direction of trust: this tool *produces* a customer_id, it never accepts
one. There is no argument a model could fill in to name a customer, which is what
makes the tool layer the authorization boundary rather than the prompt.

Every return is a typed status string. The prompt maps each status to what the
agent should say, so the model reads a fact and picks a script instead of
inventing a sentence about the database.
"""

One database round trip produces four different answers, and only one of them discloses anything about a real person:

app/tools/auth_tools.py — lookup_customer, body

    norm = normalize_phone(phone)
    if not norm["e164"]:
        # Unparseable speech, not a failed identity check. The graph does not
        # spend one of the caller's three attempts on this.
        return json.dumps({"status": "invalid_phone"})

    # phone is unique in the schema, so this is at most one row.
    res = sb.table("customers").select("customer_id, name, dob").eq("phone", norm["e164"]).execute()
    if not res.data:
        # `spoken` rides along so the agent can read back what we heard. Without
        # it the caller has no idea which digit was misheard and just repeats
        # the same thing at the same speed.
        return json.dumps({"status": "not_found", "spoken": norm["spoken"]})

    record = res.data[0]

    if dob is None:
        # The account exists but nothing about it is disclosed yet: no name, no
        # id. Only the readback goes back, so the caller can confirm the key.
        return json.dumps({"status": "needs_second_factor", "spoken": norm["spoken"]})

    # DOB is compared as the model extracted it, deliberately. Extraction is the
    # LLM's job; normalization is only for lookup keys, and DOB is not one. A
    # wrong DOB fails a comparison, it never matches the wrong row.
    if dob.strip() != record["dob"]:
        return json.dumps({
            "status": "factor_mismatch",
            "heard": spoken_date(dob),
        })

    # Identity leaves this function on exactly one code path.
    return json.dumps({
        "status": "verified",
        "customer_id": record["customer_id"],
        "name": record["name"],
    })

Identity leaves on exactly one code path. That is the sentence the rest of the auth design protects. And the heard field on a mismatch is not a leak: it is the caller's own words spoken back, never the date on file. It exists because on a live call the transcriber turned "nineteen ninety" into "19 19" and the caller had no way to know why they kept failing.

Which raises the question of why a phone number needs normalizing at all, when a model is right there. Because the model measurably cannot do it:

app/utils/phone.py — why this is not the model's job

"""Turn a spoken phone number into a database key and a readback string.

Two jobs are deliberately split in this system. Pulling structured arguments out
of messy speech is the model's job — it handles self-corrections and filler that
no regex can. Turning a value into a *lookup key* is this module's job, because
a key has to be exact and the model measurably is not: asked to convert spoken
digits itself, gpt-4o-mini produced 41555551001 and 4155555100 for the same
number. A wrong key doesn't error, it confidently finds the wrong row or none.

So the tool schema asks the model to pass the caller's words through untouched,
and the conversion happens here where it is deterministic and testable.
"""

The parsing itself has one nice property worth stealing. A non-digit token breaks the digit run, so conversational filler cannot leak into the number:

app/utils/phone.py — normalize_phone

    # findall drops punctuation for free, so "four one five, five five five"
    # tokenizes the same as the version without commas.
    tokens = re.findall(r"[a-z0-9]+", raw.lower())
    mapped = [WORD_DIGITS.get(tok, tok) for tok in tokens]

    # A non-digit word breaks the run, so filler like "give me two seconds"
    # can't leak its digits into the number.
    runs = ["".join(g) for is_digit, g in groupby(mapped, key=str.isdigit) if is_digit]
    if not runs:
        return {"e164": None, "spoken": None}

    digits = max(runs, key=len)

"oh" is in the digit map, because people say it, and it is also the most common interjection in English — which is exactly why the run logic matters more than the mapping.

Step 07

Split the node that speaks from the node that writes

A tool that only produces identity is not enough on its own. Something still has to decide that a verified status becomes a grant in state — and that decision must not live anywhere a model can reach. So auth is two nodes: an agent node that is the only place a model runs, and a tool node that runs no model and is the only place facts get written.

app/graph/auth.py — auth_tool_node, docstring

def auth_tool_node(state: CallState) -> dict:
    """Run the tool calls the model requested, and write everything they earn. Says nothing.

    No model runs here, and this is the only place the auth grant, the attempt counter
    and the terminal reasons are written. That is what keeps the model's words out of
    authorization: it asks, this decides.

    **It never writes `final_answer` and never appends an AIMessage.** What the caller
    hears is decided in one place, `auth_agent_node`, which the graph returns to on a
    plain edge straight after this. Writing the state and choosing the sentence used to be
    tangled together here, and it made the flow much harder to follow than it needed to be.
    """

That last paragraph is a refactor I got wrong the first time, and step 08 is where it bit. First, the write itself — every branch is a different thing the caller earned:

app/graph/auth.py — what each status earns

            # `update`, never reassign: `grant` may already hold last_lookup_status.
            if payload["status"] == "verified":
                grant.update({
                    "authenticated": True,
                    "customer_id": payload["customer_id"],
                    "customer_name": payload["name"],
                })
            elif payload["status"] in FAILED_ATTEMPT_STATUSES:
                # Reached the database and was told no. That is a real attempt, and
                # it clears the stall count — this caller is engaging, just wrong.
                grant.update({
                    "auth_attempts": state.get("auth_attempts", 0) + 1,
                    "stall_count": 0,
                })
            elif payload["status"] == "needs_second_factor":
                # Reached the database. Engaging, so the stall count resets.
                grant.update({"stall_count": 0})
            elif payload["status"] == "invalid_phone":
                # The tool ran but nothing was looked up, so the agent's post-tool
                # reply will not count this turn. Count it here or a caller feeding
                # unparseable noise forever would never trip the stall cap.
                grant.update({"stall_count": state.get("stall_count", 0) + 1})

Two counters, not one, and that split is the interesting part. auth_attempts only moves on a real failed lookup. A caller who simply refuses never fails anything, so eight turns of "I'm not giving you my number" sat at attempts=0 with no exit at all. A cap on failures cannot bound someone who never gives you anything to fail on — hence stall_count alongside it.

The node also enforces one tool call per turn, and is honest in the comment about what that costs:

app/graph/auth.py — one call per turn, and the accepted cost

    # `honored` enforces one tool call per turn, whichever the model listed first.
    #
    # The accepted cost: first does not mean best. If the model emits a wrong lookup and
    # a right one, the wrong one burns the attempt and the right one is dropped, so a
    # caller who gave correct details is asked again next turn. Worth it — the
    # alternative lets one turn spend several attempts, or land a match the caller never
    # confirmed.
    for tool_call in tool_calls:
        tool = auth_tools_by_name.get(tool_call["name"])

        if tool is None:
            payload = {"status": "unknown_tool"}
        elif honored:
            payload = {"status": "ignored_extra_call"}
        else:
            honored = True
            observation = tool.invoke(tool_call["args"])
            payload = json.loads(observation)

Step 08

Every limit is code, and order is the guard

My absolute ceiling was 8 turns. A messy but entirely cooperative verification is 8 turns, so it fired on someone who had done nothing wrong. Raised to 15: it is the last resort, not the defence.

All four ceilings live in one function, gated on one condition, for a reason the docstring is blunt about:

app/graph/auth.py — _ended_reason

def _ended_reason(state: CallState) -> Optional[str]:
    """Decide whether this call has run out of road, and why.

    Called from one place: `auth_agent_node`, before it spends a model call. It used to be
    called from the tool node too, which had to hand-merge its own pending update to guess
    what state was about to become. Now the tool node writes, the graph merges, and this
    reads the real thing on the way back through the agent.

    The `authenticated` check gates every branch from one place. A real conversation
    runs well past all of these limits, so a ceiling that forgot to check would hang up
    on a verified caller mid-sentence.

    Returns:
        A REASON_* code from state.py, or None to carry on. Branch order affects only
        which reason gets recorded when two apply at once: `auth_ended` wins because it
        names something a counter cannot see.
    """
    if state.get("authenticated"):
        return None
    if state.get("auth_ended"):
        return state["auth_ended"]
    if state.get("auth_attempts", 0) >= MAX_AUTH_ATTEMPTS:
        return REASON_ATTEMPTS
    if state.get("stall_count", 0) >= MAX_STALLED_TURNS:
        return REASON_STALLED
    if state.get("turn_number", 0) >= MAX_PRE_AUTH_TURNS:
        return REASON_TOO_LONG
    return None

"It used to be called from the tool node too, which had to hand-merge its own pending update to guess what state was about to become" is the refactor from step 07, seen from the other side. Putting the scripted reply in the tool node meant two nodes could answer the caller, which needed a conditional edge to work out which one had, which forced the tool node to fake a state merge to guess whether a ceiling was about to fire. The behaviour was right and the seams were wrong — and I could tell, because explaining who spoke when took several attempts.

Moving the decision into the agent node made it two early returns before any model call. The ordering between them is itself the guard:

app/graph/auth.py — auth_agent_node, the second early return

    # Second early return, and the same trade as the first: a sentence we already know
    # costs nothing to say, so don't pay a model call to say it.
    #
    # Reached only on the second pass through this node in one turn — the tool ran, the
    # graph came straight back here. Deliberately *after* the ceiling check above, which
    # is what stops a third wrong date of birth being answered with "could you repeat
    # that": by now the tool node's counter updates are merged into `state` for real, so
    # the check above already saw them and returned the lockout instead.
    scripted = _pending_scripted_reply(state.get("messages"))
    if scripted:
        return {
            "messages": turn_messages + [AIMessage(content=scripted)],
            "user_query": "",
            "final_answer": scripted,
        }

Two payoffs from those early returns. Security-relevant wording cannot drift, because it comes from a constant rather than a model that had started dropping the commas out of phone numbers. And a terminal turn costs zero model calls — the ceiling short-circuits before .invoke() ever runs.

Step 09

Police the claims that are about the conversation

Two things cannot be decided by arithmetic. "I will never give you that" and "hang on, let me find my phone" are the same event to a counter — a turn where no number arrived — and only one of them deserves a hang-up. So the model gets tools to propose those readings, and the tool node decides whether to honour them:

app/graph/auth.py — a proposal, not a decision

            elif payload["status"] == "verification_ended":
                # A proposal until here. Dropped if it arrives before the caller has had
                # a chance, or from someone already verified — a hang-up two seconds in
                # costs more than three wasted turns, and "the model decided" doesn't
                # make it right.
                if (
                    state.get("authenticated")
                    or state.get("turn_number", 0) < MIN_TURNS_BEFORE_REFUSAL
                ):

And when a proposal is declined, the model has to be told. This is the bug that taught me the rule. Declining silently meant the ToolMessage still said verification_ended, the model read that as fact, and it said goodbye on a call that was still open. A declined request now comes back as verification_declined, not as the success status the tool returned.

The no_account_found tool gets the same treatment for a different reason: it claims the caller just confirmed a readback we already failed to find. That is checkable, so it gets checked — last_lookup_status exists in state purely so the tool node can verify a claim the model makes about the conversation instead of taking it on trust.

Step 10

The second specialist, and the argument the model cannot see

Auth got its boundary for free by having no customer_id parameter at all. Claims cannot do that — the query needs the id. So the parameter exists, and is hidden from the model instead:

app/tools/claim_tools.py — the boundary is a shape, not a policy

"""The two tools the claim agent can reach.

Same direction of trust as auth: nothing the model writes can name a customer or reach a
claim that isn't theirs. Auth got that by having no `customer_id` parameter to fill in at
all. Here a parameter has to exist — the query needs the id, and the whole point of the
claim agent is that it operates on the verified caller's account — so it exists and is
marked `InjectedToolArg`, which **removes it from the schema sent to the model**:

    fetch_claims  ->  {"name": "fetch_claims", "parameters": {"properties": {}}}
    select_claim  ->  {"name": "select_claim", "parameters": {"properties": {"claim_id": …}}}

So the boundary is still a shape rather than a policy. There is no argument the model could
emit to look up somebody else, and no override in the node to remember. `claim_tool_node`
fills the injected arguments from state, spread **after** the model's, so state wins even if
a model somehow emitted a key it can't see.
"""

fetch_claims ships to the model as a tool with an empty properties object. There is no string it could emit that names an account. The signature is the whole trick:

app/tools/claim_tools.py — the signature, and one subtle Optional

# `Optional`, and not because a verified caller can lack an id. Typed `str`, Pydantic
# rejects None during argument validation and raises *before* the body runs — out of
# `tool.invoke`, out of the node, and the caller's turn dies with a 500 instead of a
# sentence. Optional moves that case into the `no_customer` branch below, where it is a
# status the model can be told about. Caught by a test asserting the branch, which failed
# with a ValidationError until this changed.
@tool
def fetch_claims(customer_id: Annotated[Optional[str], InjectedToolArg]) -> str:

That comment is my favourite one in the repo. The obvious type is str, the obvious type is wrong, and the reason is that Pydantic validates arguments before the function body exists to handle anything — so a broken invariant becomes a 500 on a live phone call instead of a sentence the model can say out loud.

select_claim takes the same treatment for a different reason. The caller's own claims are injected, so a claim_id resolves against that list only:

app/tools/claim_tools.py — select_claim, and the injection table

    # Resolved against the caller's own claims, which arrive injected — so this cannot
    # select a claim the caller does not own, whether the id was invented or borrowed.
    for claim in claims or []:
        if claim.get("claim_id") == claim_id:
            return json.dumps({"status": "claim_selected", "claim": claim})

    return json.dumps({
        "status": "unknown_claim",
        "claim_ids": [c.get("claim_id") for c in claims or []],
    })


# What each tool gets from state rather than from the model. Keyed by tool name, so a tool
# with nothing to inject simply isn't in here. This table *is* the authorization boundary:
# `claim_tool_node` spreads these over the model's arguments, last, so they win.
INJECTED_FROM_STATE = {
    "fetch_claims": lambda state: {"customer_id": state.get("customer_id")},
    "select_claim": lambda state: {"claims": state.get("claims") or []},
}

A borrowed claim id and a hallucinated one are indistinguishable, and both fall out as unknown_claim. None of this is worth much as a claim in a blog post, which is why there is a test that fabricates a tool call naming another account and asserts the answer is still the caller's: test_fetch_claims_ignores_a_model_supplied_customer_id.

The FAQ specialist is the same two-node loop with retrieval instead of a database, and gets no injected arg — worth saying out loud rather than leaving as an omission. search_knowledge_base takes a query and nothing else, and its corpus is identical for every caller, so every argument it accepts is one the model is supposed to choose. The absence of a boundary there is a consequence of the data, not an oversight.

Which is the whole integration story, in one picture:

03  The integrations Supabase · Airtable · Vapi
Retrieval · Supabase

One query, four answers

absentnot_found
no dob givenneeds_second_factor
dob mismatchfactor_mismatch
matchverified
  • Only verified writes an identity into state
  • Claims in one embedded select, not three trips
  • Six tools. All read-only.
Write-back · Airtable

Fires when the call ends — not when the graph ends

  • The graph hits END every turn. A record there is a record per turn
  • Source is the last Postgres snapshot, not live state
  • One PATCH, upsert on call_id — Vapi retries, so it must be idempotent
  • Six fields. outcome computed, never inferred
Authentication · Four things

Easy to conflate, so kept apart

  • Caller → us. Two factors, enforced in the tool node
  • Us → Supabase. Service key, RLS bypassed. Demo scope, stated plainly
  • Us → Airtable. Write scope, no read scope
  • Vapi → us. A shared secret, and only on the endpoint that receives it
Integrations 03 / 04

One note on the claims query, since it is the only place a join shows up. The documents are embedded rather than fetched separately, and deliberately not as an inner join: a claim with no outstanding documents has to come back with an empty list rather than vanish, because "nothing outstanding" is the answer for most claims.

Step 11

Routing: rules first, model on a tie

Three specialists need something to choose between them. A model call per turn is the obvious answer and the wrong one — a clean keyword hit on one side and nothing on the other is a decision, and it costs nothing:

app/graph/router.py — the classifier

    claim_hits = _hits(lowered, CLAIM_KEYWORDS)
    faq_hits = _hits(lowered, FAQ_KEYWORDS)

    # One side matched cleanly, so there is nothing for a model to add.
    if claim_hits and not faq_hits:
        return CLAIM
    if faq_hits and not claim_hits:
        return FAQ

    # Contested (both lists hit) or silent (neither did). One structured model
    # call, constrained to the four labels so it cannot invent a destination.
    prompt = "Route this caller utterance on an insurance claims line."
    if recent:
        prompt += f"\n\nThe conversation so far:\n{recent}"
    prompt += f"\n\nUtterance to route: {text}"

    try:
        decision = intent_model.invoke(prompt)
    except Exception as exc:
        # A dead model must not drop the call. Fail toward a human.
        log.warning(f"intent fallback failed, escalating: {exc}")
        return ESCALATE

    # Already the label vocabulary, constrained by Literal.
    return decision.intent

Two things I would defend there. The fallback is structured — a Literal-constrained field, so the model cannot invent a destination that has no node. And a dead model escalates to a human rather than dropping the call, because on a phone line the failure mode of last resort should still be a person.

This layer is also where a routing bug turned out to belong. "Where do I mail documents" went to the claim agent, because "document" is on the claim keyword list and nothing on the FAQ list matched — a clean single-sided match, no model call, wrong node. The fix was not to remove "document" from the claim list, since "did you get my documents" is genuinely a claim question. It was to make that utterance contested, so it reaches the fallback with the last five messages in hand. Same words, different things; the decision belongs to the layer that can see the conversation.

Step 12

Where a turn starts depends on the caller

With auth, a router and three specialists in hand, the last structural question is which node a turn begins at. The obvious answer — always start at auth — is wrong twice over: a verified caller would re-verify every turn, and somebody describing an active emergency would be asked for a date of birth first.

So START is a conditional edge, not a fixed node:

app/graph/build.py — the entry decision

    # Where a turn starts depends on the caller, not on a fixed first node:
    # an emergency skips auth entirely, a verified caller skips it too.
    builder.add_conditional_edges(
        START,
        route_entry,
        ["auth_agent_node", "router_node", ESCALATE_NODE],
    )

And the emergency check that jumps the queue is a regex, never a model, for a stated reason — it decides whether a turn skips authentication, so the answer has to be identical every time and cost nothing. The keyword list is also notable for what it leaves out:

app/state.py — active danger only

# Active danger only. "Accident" and "damage" are absent on purpose: on a claims line they
# describe the past event every caller is phoning about, so matching them would route the whole
# queue to a human.
EMERGENCY_PATTERNS = [
    r"\b911\b",
    r"\bambulance\b",
    r"\bparamedics?\b",
    r"\bunconscious\b",
    r"\bbleeding\b",
    r"\btrapped\b",
    r"\bon fire\b",
    r"\bemergency\b",
    r"\b(someone|somebody|he|she|they|i|we)('s| is| are|s)? (hurt|injured|dying|dead)\b",
    r"\bcall (the )?(police|fire|cops|ambulance|medics|doctor)\b",
    r"\bneed help (right )?now\b",
]

On a claims line, "accident" and "damage" are what everyone is calling about. A naive emergency list routes the entire queue to a human.

Which completes the shape. Nine nodes, one two-node loop repeated three times, and four possible places for a turn to start:

02  The finished graph LangGraph · FastAPI · Vapi custom LLM
route_entry — four checks, in order · where a turn starts depends on the caller
caller turn POST /chat/completions route_entry
1 · emergency
escalate_node
→ END
2 · authenticated
router_node
claim_agentclaim_tool_node faq_agentfaq_tool_node escalate_node goodbye_node
→ END
3 · asks for a person
escalate_node
→ END
4 · otherwise
auth_agent_nodeauth_tool_node
→ END

Agents propose. Tools dispose.

A model can request. Only Python writes state.

Rules first. Model on a tie.

A clean keyword hit is a decision with no model call.

One state object. Two tiers.

An in-process checkpointer on the hot path. Postgres for the record.

The graph 02 / 04

Step 13

Wire it together once, and only once

Every tool loop closes with a plain edge back to its agent, not a conditional one, and the comment explains why that is not a wasted model call:

app/graph/build.py — the loops

    # A plain edge, because the tool node never answers the caller — it runs the tool and
    # writes what the result earned, nothing more. Coming back here is not a second model
    # call: the agent node checks for an ending, then for a scripted line, and only asks
    # the model if neither applies.
    builder.add_edge("auth_tool_node", "auth_agent_node")

    builder.add_conditional_edges(
        "router_node",
        route_from_router,
        [CLAIM_NODE, FAQ_NODE, ESCALATE_NODE, GOODBYE_NODE],
    )

    # The claim specialist is a loop, not a single node — same two-node shape as auth, and
    # the same plain edge back, because its tool node never answers the caller either.
    builder.add_conditional_edges(
        CLAIM_NODE, should_continue_for_claim_agent, ["claim_tool_node", END]
    )
    builder.add_edge("claim_tool_node", CLAIM_NODE)

There is one architectural rule in this file that made the whole thing pleasant to extend, and it is stated at the top:

app/graph/build.py — the dependency rule

Keeping it separate matters for one reason beyond tidiness: **no node module
imports another module's nodes**, so adding a specialist never means editing
auth.py. Everything points inward at build.py; build.py points at nothing.

Shared values live in state.py rather than being passed between node modules —
the intent labels, `is_emergency`, `asks_for_a_person`. There is one deliberate
exception: escalate.py imports END_CALL_PHRASE from auth.py, because duplicating
the string Vapi hangs up on would let a typo promise a caller a callback and then
strand them on an open line. A constant, never a node.

That single exception is a scar too, and the nastiest one in the build. Vapi hangs up by matching an end-call phrase as a substring of what the assistant says. I first picked a sign-off that opened with the greeting's words, configured as two comma-split entries — so every call hung up 8.7 seconds in, before the caller had said anything. I fixed the code and added an import-time guard. Then fixing the dashboard merged the two broken halves back into the old phrase, so the list held something nothing says any more: the sign-off played and the line stayed open.

Both halves looked fine from where I was standing — the first from the logs, because the greeting went out and the next request simply never came; the second from the code, because the string was right in the repo. The fix that matters is neither patch. It is the ends_call field now on every turn log, which says which of our phrases we actually spoke.

Finally, the checkpointer, and the honest limitation of the whole build:

app/graph/build.py — one graph, one shelf

    # In-process, one thread per call_id, no network cost on the hot path. It
    # holds the state between turns, which is why a caller turn passes only what
    # changed rather than the whole state (see state.initial_call_state).
    #
    # The state lives in THIS InMemorySaver instance, not in the thread_id. A new
    # graph gets a new empty one, so rebuilding per request would silently make
    # every turn look like a new call — which is why nothing calls this directly.
    # main.py's lifespan builds it once at startup; every request path goes through
    # get_claims_agent() and receives that same one.
    checkpointer = InMemorySaver()
    return builder.compile(checkpointer=checkpointer)

The notepad lives in that instance, not in the thread_id. Rebuild the graph and you get an empty shelf with the same labels — every turn looks like a new call. Two worker processes are two shelves, and turn 2 cannot find turn 1. Hence --workers 1, and hence Redis as the production answer. Worth being precise about what Redis changes and what it does not: it moves where the dictionary lives, not why it needs keys. You would still have to find this call among thousands.

It is also why the deploy target is Railway rather than anything serverless. Cloudflare Workers, Lambda and Cloud Run scaled to zero are stateless per-request isolates — turn 2 lands somewhere with no memory of turn 1, which is the same failure as --workers 2. Render's free tier spins down after 15 minutes and cold-starts in about 50 seconds, and Vapi's server timeout is 20, so the first call after a quiet period fails outright. Railway does not sleep services. That is the one property that matters.

Step 14

After they hang up

Two tiers of state, because the fast one dies with the process. A snapshot goes to Postgres after every turn, and it is explicitly forensics rather than recovery:

app/snapshot.py — forensics, not recovery

"""Tier 2 state: the durable record of a call.

Tier 1 is the in-process checkpointer, which is fast and dies with the process.
This writes the same state to Postgres after a turn so there is something to
query afterwards.

It is forensics, not recovery. Nothing rebuilds a live thread from a snapshot, so
a crash mid-call still drops the call — the snapshot just means the record shows
the call reached turn 8, authenticated, and was asking about CLM-1005, instead of
showing nothing.

**The last row for a call is also the input to the post-call record.** `synthesis.py` reads
it back rather than live state, because a caller who hangs up mid-turn never reaches graph
END and the checkpointer dies with the process either way.
"""

The transcript is flattened to "role: content" rather than serialized message objects, for a reason that surprised me:

app/snapshot.py — why the transcript is flattened

        # It records **who spoke**. The repr does not: only model-*generated* AIMessages carry
        # `response_metadata={'token_usage': …}`, and every scripted line in this build is a
        # hand-built AIMessage, so a caller turn and an agent reply serialize identically.
        # Anything reading the transcript back — a person, or the synthesis model — was left
        # guessing from content alone, and a call summary that swaps the speakers is worse than
        # no summary.
        #
        # And it drops the metadata nobody reads. A real 9-turn call went from 13.3k characters
        # to about 3k, which is the difference between a transcript and a token-usage dump with
        # a transcript in it.

A side effect of scripting the security-relevant replies: every one of them is a hand-built AIMessage with no token-usage metadata, so the naive serialization loses the distinction between a caller and the agent. A summary that swaps the speakers is worse than no summary.

And the write itself swallows its own failures on purpose — "an audit gap is recoverable; a dropped call is not. The caller must never hear silence because Postgres was slow."

The customer-facing record is a separate concern with a timing trap in it. The graph reaches END on every single turn — that is what a turn is — so an Airtable write triggered on graph completion produces a row per turn, not per call. The record is driven by Vapi's end-of-call webhook instead, reads the last Postgres snapshot rather than live state, and lands as one PATCH upserted on call_id. Vapi retries webhooks; idempotency is not optional.

One last security note, and it is the one I would defend hardest. VAPI_SECRET originally guarded /chat/completions — but Vapi only sends its server secret to the webhook URL. It had never been set, which is the only reason nothing broke; setting it would have 401'd every caller turn. A control that is inert when unset and catastrophic when set is worse than no control at all. It moved to /webhook, which is the endpoint that matters anyway, since that payload is what writes the record.

Does it work?

Fourteen steps in, the question is whether any of it holds up when somebody actually talks. smoke_test.py drives five scenarios through the real graph with the phone removed: real model calls, real Supabase reads, a real Airtable write. The only thing stubbed is the transport, because Vapi is the one dependency whose behaviour I want to reproduce rather than trust.

01  The working agent Five scenarios, end to end
Happy path
resolved
Authentication failure
auth_failed · attempts_exhausted
Customer not found
auth_failed · no_account
Representative escalation
escalated
Knowledge base extra
answered, then refused
Assertions are on state, not on wording. A reworded prompt should not break a test.
33 / 33
5 scenarios, end to end
92 offline tests
Nothing mocked
real OpenAI
real Supabase
real Airtable
the phone removed
Verification 01 / 04

The choice that makes this suite worth keeping is what it asserts on. Every check reads state, not prose: authenticated, intent, ended_reason, the claim id that got selected. A test that asserts the agent said "I can see one claim on your account" is a test that fails the next time I improve a prompt, and passes the next time the model says the right sentence about the wrong account. Both failure modes are worse than useless.

The one bug this style of testing did not catch is instructive. The FAQ agent abstained on "what's my deductible" — a topic the corpus answers at 0.64 similarity — with no retrieval call logged at all. One earlier empty search sitting in the transcript had taught it that questions like this come back empty, so it stopped searching. The rule that fixed it is worth stating on its own: an abstention has to be grounded in a search too. Refusing without looking is a guess in the other direction, and it is the guess that loses answerable questions.

Numbers

Measured against the running system, not estimated. The latency budget on a phone call is brutal and unforgiving, and most of it is not yours to spend.

Tool turn, our server
0.75–1.4s, one model call. Was 1.6–2.4s with two
Non-tool turn
~0.7s
Terminal turn, counter ceiling
0s — no model call at all; the reason short-circuits before the graph runs
Vapi end-to-end turn
1.5–3.2s, including transcriber, endpointing and TTS
Worst turn seen
7.2s — of which 4.9s was Deepgram, not us
Cost per call
$0.046–$0.15 for 0.7–2.2 minutes. Vapi ~70%, STT ~15%, TTS ~15%
Auth prompt
451 tokens, down from 1,166 — and it is resent every turn
FAQ turn, our server
2.4–2.8s — two model calls plus an embedding call. The slowest turn in the build, and the price of having no scripted answers over 20 topics
Embedding the corpus
0.73s, once per process, lazily. Only the first caller to ask a question pays it
Retrieval corpus
20 sections, 184–412 chars each, ~6KB total
A 9-turn state blob
2,462 chars. Was 13,334 before messages were flattened
Post-call record
~10s, one gpt-5 call over the whole state blob. Off the caller path entirely — the webhook is answered first
Tests
92 fast (~7s, no model calls), 17 live

Gaps, owned first

A build is defined as much by what it refuses to claim. These are the ones I would raise before anyone found them.

Multi-intent is dropped, not parked
"Is my claim approved, and what's my deductible" gets the first ask answered and the second forgotten. I built a followup field to detect and store it, then deleted it because nothing ever read it — better an honest gap than a field that looks implemented.
The handoff keywords over-match
"Is this a robot or a real person?" matches real person and escalates, though it is a question about me. The shortcut trades that false-positive tail for skipping a model call on every genuine handoff.
The attempt budget is per call
A redial is a new call_id, a new empty notepad, and three more attempts. Cross-call rate limiting is production work.
Needing a second factor confirms an account exists
Account enumeration, unavoidable alongside readback-and-confirm. Accepted knowingly.
The readback confirmation costs a stall point
By turn 2 the last message is the tool node's own trailing reply, not a tool result, so a perfectly on-script "yes" reads as "nothing usable arrived" and stall goes 0→1. Harmless — the budget is 5 and a normal run never passes 2 — but real. Found in a live trace, not in the code.
A crash loses in-flight calls
Snapshots are forensics. Rehydration is not built.
"Do you tow cars" abstains rather than answering
The roadside section says this line does not dispatch roadside, which is the answer — but the query scores 0.28, under the floor, so the caller gets the generic refusal. Correct, not ideal. Lowering the floor to catch it also catches "hello how are you today" at 0.24.

What I would defend

Voice is the format that punishes an architecture fastest. There is no loading spinner to hide behind, no form validation to lean on, no second chance to re-read the sentence. Every decision either survives contact with somebody talking or it does not — and you can see that in the shape of this post, where almost every step exists because a specific call went wrong.

What survived was the boring rule. The model is very good at understanding that a caller pausing mid-sentence is still mid-sentence, and very good at working out that "the one from last month" means a specific claim. It is not the thing that should decide whose claim it is allowed to read. Give it the first job, take the second away structurally, and the failure modes left over are the ordinary kind — the ones you can measure, log, and fix on a Tuesday.

One last thing that is not in any of the code above. The transcript is not evidence. I diagnosed the very first bug from Vapi's word-level confidence scores and got it wrong, because they are truncated in every payload. Logging what we actually fed the graph was worth more than all of it.

Call the agent yourself →
← Back to all writing