Skip to content

Reading a call back

A call is over and something was wrong with it. The reads below are different kinds of thing, and the order matters.

get_call_record is the contract. Versioned, additive-only, tenant-scoped, safe to assert on in a test. It answers what happened: what was said, what the user actually heard, and where the wire was quiet.

get_session_logs is the evidence. Voqalize’s own lines, written in our vocabulary and free to change whenever our internals do. It answers why.

get_session_events is the milestones. Created, connected, ended, written while the call runs. It answers how far the call got, and it is the one read that answers for a call still in progress.

Read the record first. Interleaving it with the logs would make the weaker half look exactly as reliable as the stronger one, which is why they stay separate reads.

All of them are on the MCP server, and each takes the session_id you already have — the same string that was in your connect params, in {brain_url}?session_id={session_id}, and in every line your own brain logged. It is the join key across both sides of the call.

A turn is one question handed to your brain and everything your brain sent back. Each carries:

  • asked and asked_at — the question, verbatim, and when it was handed over. Empty on a greeting, or any turn your brain opened on its own; that is a real answer and is not filled in from the transcript.

  • units — each stretch of speech your brain opened, with what it generated, what the user actually heard, and an outcome:

    OutcomeMeaning
    spokenThey heard all of it.
    cut_shortThey heard the start and cut in.
    never_spokenYour brain answered a turn they had already talked past, so not one word was said.
    unknownThe session ended before the unit settled.

    cut_short and never_spoken are barge-in working, not faults. never_spoken is the wasted work, and the usual explanation for “my brain replied and nothing happened on screen.” It used to be invisible — the frame simply did not appear. It appears now, marked as discarded. See interruption and heard truth.

  • gaps — stretches where your brain sent nothing. Named for what a record can see: a gap may be a model thinking, a tool running or a socket stalled, and nothing here tells them apart. It is the wire was quiet, never the brain was slow.

  • marks — published measurements, each placed independently on the turn’s own zero. acked_at is a protocol reflex and is not a response time.

A turn’s steps — each model request with its tokens by kind, each tool, and anything else the brain reported, on one timeline — come from the brain itself, as TurnStep. The SDK’s model-backed brains send them by default; build one with report_steps=False to keep the numbers in your own logs. A brain reports its own work, such as a database query, with session.report_step.

pace is each measurement’s spread across the session, with the number of turns it was computed over. meta is what is true of the file itself, including closed_by: recovery means the record was salvaged after the session’s voice died and may be incomplete.

include_events=True adds the raw records underneath the turns — every message in both directions, which is a lot of them. limit bounds those and never the turns. The wire names every message.

get_session_events is Voqalize’s own account of what it did: created, something connected, it ended. None is ever dropped. The payloads on session.created answer most of “the call connected and nothing I expected happened”:

  • recording_enabled and recording_source — whether this call was recorded and which rule decided. See recordings.

On calls placed before 2026-09-01 you may also see brain_url_defaulted. When true, the agent had no brain_url, so a hosted welcome brain answered instead of yours: the call worked, it greeted, it answered, and it was not your agent — a working call in the wrong voice. No call since carries it, because an agent with no brain is refused at sessions.connect rather than answered by us.

Each event carries actor_id — the raw id of what caused it — and actor, that person as {id, email, name} when a person did it (an archive_session, say) and null when Voqalize itself did.

Because they are written during the call, the milestones are the cheap answer to “how far did this call get” — and the only answer while it is still running.

Check availability before you conclude anything

Section titled “Check availability before you conclude anything”

The record and the logs are each uploaded when the call ends. There is no tail of a live call. So a read can come back empty for more than one reason, and the response says which — record on get_call_record, logs_availability on get_session_logs:

ValueWhat it means
foundThe file was read.
missingNo file at all — the call is still running, the upload has not finished, or it failed. files on get_session says which.
unavailableThe store itself could not be read — or, for the record, the file is a version this control plane does not implement, which detail names.

An empty list is not the same fact as any of those, and treating it as “the call was silent” is the mistake this field exists to prevent. When the record is missing, read the milestones: they say whether anything ever connected.

get_session carries files once the session ends — one entry each for call_record, logs and recording:

statusWhat it means
expectedVoqalize owes this file and has not yet said what became of it.
uploadedThe file is in storage; size_bytes is its size. A recording rebuilt after the session was interrupted carries a reason: it may end early.
failedThere is no usable file: the upload did not succeed, or nothing recorded could be read. reason says which.
skippedThere was never going to be a file — recording was off, or nothing connected; reason says which.
lostAn hour after the session ended, nothing had reported the file and storage did not hold it.

The overall status is pending while any file is expected, complete when every file is uploaded or skipped, and incomplete when one is failed or lost. deadline_at is when an expected file is looked up in storage; settled_at is when the last file settled. A report that arrives after that lookup replaces what it inferred.

get_session_logs returns Voqalize’s timeline for the call: WebRTC and ICE, speech in and out, the brain WebSocket, teardown.

level is a floor — start at INFO, drop to DEBUG once you know roughly where the problem is. service narrows to one surface. The lines written by the process that holds the call are the ones worth reading first; their service value is an internal name — a log field, not vocabulary — and it is changing as that process is replaced, so take it from a line you have read rather than hard-coding it.

Do not write assertions against their wording. That is what the record is for.

There is no general log search, on purpose

Section titled “There is no general log search, on purpose”

No free-text query, no arbitrary time range, no label explorer. Every read is derived from a session you have already been authorized to see, and the object is fetched by name under your own tenant’s prefix. There is no user-supplied predicate to get wrong, which is the failure mode a tenant-facing log API with a query string eventually has.

These are Voqalize’s records of the call. Your brain runs in your environment and logs where you put it. session.id is the same string on both sides, so joining them is a grep — and your side is where on_finalize recorded what the user actually heard.

The join only works if your lines carry the id, and the SDK has already put it there: both entry points wrap every session in a logging context, so a bare from loguru import logger anywhere in your brain — and in any task it spawns — logs with the call attached. You thread nothing through.

What is left to you is one line at your own entrypoint, because a library has no business replacing your process’s log handlers:

from voqalize.sdk import configure_logging
configure_logging(json_logs=True) # only if you have no loguru setup of your own

Without a sink that prints those fields, they are computed and thrown away, and it looks exactly like working. If you already configure loguru, add {extra} to your format instead. Signatures and the rest are in The Brain API.

Ids are carried whole. A truncated id reads better in a terminal and matches nothing on our side of the query, which is the entire purpose of writing it down.

  • Recordings — when the events and the logs both look right and the call still sounded wrong.
  • MCP server — every tool’s signature.