unmute_core.call and unmute_core.greeting run what happens around a call. CallHooks runs the call’s hooks. The checks, the failure and the end below are hooks too.
What it gives you
A whole call
Both tabs share this setup. It holds the State, one webhook tool for the call end and one background lookup.setup.py
The order
- The dispatch brings values.
save_call_startsaves them into the state. - The session or pipeline starts.
open_callholds the caller’s audio, then runs thebefore_callhooks in order. A failed check says the failure line and hangs up. No lookup runs.- When the checks pass, it runs the prefetch with
prefetch.run. - Then the background lookups start with
prefetch.run_background. The call’s end, or a failure, stops them. - The greeting plays. A fixed line plays at once. A worded greeting on a realtime model waits for the background lookups and for the instructions they change.
- Every model request waits for the background lookups, up to
first_request_timeout_s(10 seconds). - During the call, tools run on events: the first caller message, the call end, and the success or failure of a tool.
- If STT or the model fails for good, the call says the failure line and hangs up. A TTS failure only logs a warning, and the call stays open. On LiveKit the session still closes at the fourth unrecoverable TTS error. Pipecat has no such limit: a voice that stays broken keeps the line open until the idle hang-up, the call’s limit or the caller ends it.
- When the call ends,
call.endsettles the held saves, runs thecall_endtools, tells thecall.endedobservers and drains the webhooks.
CallHooks
The call’s own object. Build one per call. See Open a call.CallState | None
default:"None"
The call’s state. Tools read it and the end reports its values. Each save reaches the
state.changed observers. None builds CallState(). UnmuteAgent takes call.state, so a call has one state and one session_id.Hooks | None
default:"None"
The call’s hooks, with its tools on events. None runs none.
Prefetch | None
default:"None"
What the call reads as it starts. It needs a state.
float
default:"10.0"
The longest the
before_call hooks may take together.float
default:"10.0"
The longest a model request waits for tools on events and background lookups.
str | None
default:"FAILURE_LINE"
Said before hanging up on a failure. None says nothing.
float
default:"5.0"
The longest the held saves and the
call_end tools may take. It is also the longest the call.ended observers and the webhook drain may take.FAILURE_LINE is I'm sorry, something went wrong on our side. Please call back later. Goodbye.
open_call
open_call differs by framework.
session, greeting and call, then the keyword max_hold_s (default GREETING_HOLD_SECONDS). It returns the greeting’s speech, or None when the caller speaks first or a check failed.
On Pipecat it takes worker, greeting and call, then the keywords context (required), llm, hold and transport. It returns True once the greeting is queued, and False after a failed check.
save_call_start
Saves the values a dispatch sends. See Start values.CallState
required
The call’s state.
Mapping[str, object] | None
required
The dispatch’s
call_start object. None when there is none.bool
default:"True"
Also read
UNMUTE_CALL_START. False reads only dispatched. Pass it by keyword.Start value rules
dispatchedis thecall_startobject of the dispatch. On LiveKit it isjson.loads(ctx.job.metadata or "{}").get("call_start"). On Pipecat it isrunner_args.body.get("call_start").- For a local run, set
UNMUTE_CALL_STARTto a JSON object. The dispatch’s values win over it. Passenv=Falseto read only the dispatch. CallState(call_start=...)names the values and says if each isrequiredoroptional. Every one is a State value.- With a mapping, only those names are taken. A missing required name raises
CallStartError:Missing call_start fields: customer_id. - Without a mapping, any State value may be set and none is required.
- Names the State does not hold are dropped, and the log names them.
- A value that does not fit its type raises
CallStartErrorwithcall_start: <field>: <message>. Nothing is saved. - A value of
Noneis left out. It keeps its default. - The saved values have the origin kind
call_start. - The function returns the values it saved.
- A
callobject indispatchedsets what the call carries. On a web call every field applies. On a phone call the line’s own fields win, and the dispatch fills only the empty ones.agent_idandsession_idalways apply. See State.
Work at call start
call_start event is not possible.
CallHooks(state, prefetch=...)takes the prefetch. A prefetch needs a state.open()runs thebefore_callhooks first, thenprefetch.run. The prefetch holds the greeting until it ends or its budget (2 seconds) runs out.- Then
open()startsprefetch.run_background. It runs eachbackground=Truelookup beside the greeting. - Every model request waits for the background lookups, up to
first_request_timeout_s(10 seconds). So a fixed greeting plays at once. call.resultsholds each lookup’s result as JSON, by entry name.UnmuteAgent(call=call)andUnmuteFlows(call=call)give it to their tasks, so a step with atoolinput reads it. In your own host, pass it toCallTasks(call_start=call.results). See Tasks.EventTool(..., on=["call_start"])raisesValueError. It points you toLookup(..., background=True).
Checks before the greeting
- Each check is a
before_callhook. The checks run in order, beforeprefetch.run. - All checks share
ready_timeout_s, 10 seconds by default. - A check that raises, or runs out of time, is a terminal failure with the component
ready. open_callreturnsNone(LiveKit) orFalse(Pipecat) in that case. The caller’s audio stays off.
Greeting
How the call opens. See Greet the caller.agent | user
default:"agent"
agent opens the call. user waits for the caller.str | None
default:"None"
The line the agent says. None has the model word the greeting. It needs
speaks_first="agent", and it may not be blank.str
default:"GREETING_INSTRUCTION"
What the model is told when it words the greeting.
bool
default:"True"
False keeps the caller from cutting the greeting off. It also holds the caller’s audio until the greeting has played.
Greeting rules
- A fixed line may name State values as
{{name}}or{{name.field}}. - Only values that prefetch or
call_startfills before the greeting can be named. Any other value is still empty when the call opens. greeting.check(prefetch, call_start)raisesValueErrorfor a line that names a value neither fills.call_startis theCallState(call_start=...)mapping, or its names. Background lookups do not count.- If a named value is empty at the moment of greeting, or still waits for the caller to confirm it, the line is not said. The model words the greeting instead. The log says why.
GREETING_INSTRUCTIONis what the model is told when it words the greeting:Greet the caller and offer to help.Greetingis a frozen Pydantic dataclass. A value of the wrong type raises aValidationErrorin Pydantic’s wording, likeInput should be 'agent' or 'user'. A blanktextraises one too.- A
textwithspeaks_first="user"raisesValueError:greeting text requires speaks_first: agent. So does a{{or}}that is not a{{name}}or{{name.field}}placeholder.
Open a call with it
open_call is the usual way. It runs the checks and the prefetch, then greets. To greet by hand, after the session or pipeline runs and prefetch has filled the state:
Holding caller audio
The rule is the same on both frameworks. The caller’s audio stays off until the agent starts to speak, so the greeting is not talked over.open_callturns the caller’s audio off, runs the checks, and greets.- By default, the audio comes back when the agent starts to speak. The caller can cut the greeting off.
- With
Greeting(interruptible=False), the audio comes back when the greeting ends. The caller cannot cut it off. - If the caller speaks first, the audio comes back at once.
- After a failed check, the audio stays off.
- The hold has a limit on both frameworks:
GREETING_HOLD_SECONDS(20 seconds). A greeting that never starts or never ends gives the audio back after that, so the caller is never held for ever. Import it fromunmute_core.greeting.
open_call, and open_call(max_hold_s=...) sets the limit. See LiveKit. Pipecat needs a GreetingHold on the user aggregator, and open_call(hold=hold) sets hold.interruptible from the greeting. GreetingHold(max_hold_s=...) defaults to GREETING_HOLD_SECONDS. See Pipecat.
EventTool
Runs a tool without the model. See Tools on events.hooks.add(EventTool(...)), or pass it to Hooks(...). These are the events:
ToolSpec
required
The tool to run.
Sequence[str]
required
Its events. At least one.
Mapping[str, str]
default:"{}"
State value name to the top-level result key it gets. Saved when the tool succeeds.
bool
default:"False"
True also shows the result to the model as a note.
Saving the result
assign={"state_value": "result_key"}saves a top-level key of the result into the State.- It saves only when the tool succeeds. The origin kind is
event. - A value that does not fit its type is only logged. The call goes on.
- The names are checked against the state when you build
CallHooks. An unknown value raisesValueError. call_endtools save before thecall.endedobservers run.
Rules
- Every required argument must be locked: a constant,
{{value}},{{call.name}}orTranscript(...). The model never fills one. - The tool reads these call values besides the usual ones:
{{call.first_user_message}}and{{call.end_reason}}. The library saves each one just before the tools of that event run. For the transcript, lockTranscript().trigger_eventexists only in the webhook envelope. - A tool on an event has no announce line. Nobody waits for it.
- Tools of one event run in list order.
- A tool that follows another tool starts after it. A chain is at most 8 tools deep. A loop is refused when you build
CallHooks. - Each of
first_user_messageandcall_endfires once a call. - An event tool that is refused starts no tool after it.
tool_succeededandtool_failedfollow the tools of the agent. On a plain agent, passhooks=calltofunction_toolsortools_schema.
Transcript
Transcript is a locked argument that reads the call’s transcript so far.
max_messagesis the newest messages to send. The default is 200. It must be 1 or more. The result is also capped atTRANSCRIPT_MAX_BYTES(32 KiB). Over that, the oldest messages go first, and the newest message is always kept.text=False(default) sends a list of{"role", "content"}messages.text=Truesends one text with arole: contentline per message.- The model never sees a locked argument.
What the model reads
- A note is opt-in. Set
note=Trueon theEventTool. By default the model reads nothing. - With
note=True, every main-agent request ends with one system message that starts withNOTES_HEADER. It holds all notes so far. - It sits just before the values block, the system message that lists the State values. A step’s request gets no notes.
- A tool with
show_result=Falsethat succeeded leaves no note. - A
call_endtool leaves no note. The call is over. - With
note=True, a failure is noted too, even for a tool withshow_result=False. - The tool’s
result_instructionscome first in the note, after “Use the result like this:”. A note cut to its length limit never loses them. A tool that failed gets no instructions. - A note has at most 15,000 characters. Its
{{is broken, so the model call never fills it.
The wait before the first reply
Each model request waits for the tools on events and for the background lookups, up tofirst_request_timeout_s. So a fixed greeting plays at once. The first model request, and a greeting the model words, wait for them.
A plain native LLM on LiveKit with no traced_llm gets no wait and no notes. Wrap the LLM with traced_llm, or use ModelCallLLM(call=call). On a Pipecat pipeline with no UnmuteFlows, pass llm= to open_call. See Add one piece to your agent.
When a part fails
error event, or Pipecat reports a fatal error or an unusable processor. A ModelCall that raises inside UnmuteAgent or ModelCallLLMService before its reply began fails the call too. A short error of a native Pipecat LLM does not.
A model error after the reply began does not fail the call. The caller already heard the start. The reply ends as it stands, the error is logged, and the call goes on. The next turn runs as usual.
ModelCallLLMService.failure_is_final decides this on Pipecat. attach_call(llm=...) and UnmuteFlows(call=...) set it. Without them, a model failure stays recoverable, as Pipecat reports it.
- Only the first failure counts.
- The
call.failedobservers hear it first. The component isstt,llm,pipelineorready. Attsfailure is logged and tells thecall.tts_failedobservers, with{component, error}: the framework’s own retry or fallback may still speak, and a failure line would need the voice that failed. The call stays open and no line plays. An observer may switch voices. - The
wrap_failurehooks run around the failure line.handler()saysfailure_lineand hangs up.handler(line=None)only hangs up, for a hook that plays a recording first. A hook that returns without callinghandlerkeeps the call going. The call is no longer failed, thecall.recoveredobservers hear it with{component}, and a later failure counts again. Use this to switch to a backup service. - The line is
failure_line. The default isFAILURE_LINE. None says nothing. - Then the call hangs up. The line has at most 15 seconds.
- The call’s end reason becomes
error. - A failure before the adapter binds waits. The line plays once it binds.
- A failure after the end started changes nothing.
- A failure after the end was requested changes nothing too. No line plays and no
call.failedfires. See The end.
processor_unusable_policy at CONTINUE, so the line can play.
The end
call.end(reason) runs once. Every caller waits for the same run. It does these steps:
- It settles the held saves. See Tasks.
- It runs the
call_endtools. They save theirassignvalues. - It tells the
call.endedobservers, with aCallEnd:reason,values,confirmed,transcriptandduration_s. It waits for each. - It drains the webhooks of every watched registry.
end_timeout_s, 5 seconds by default. Steps 3 and 4 get their own end_timeout_s. So a slow tool never skips the observers.
Then close the telemetry. See Telemetry. On Pipecat,
call.end replaces flows.settle(). settle() still works.
CallEnd is a frozen Pydantic dataclass. A wrong type in a field raises a ValidationError.
Reading a transcript from a chat
transcript_messages is how both adapters build the call’s transcript from a chat. It keeps only the user and assistant messages with text. It leaves out prompts, tool calls and tool results. It works on an OpenAI-format history from either framework. Pipecat passes drop_empty=True.
Iterable[object]
required
A chat history in OpenAI format. Anything that is not a dict is left out.
bool
default:"False"
True also leaves out a message whose text is empty. Pass it by keyword.
The goodbye line
GOODBYE_LINE is the English line Thank you for your time. Have a great day. Goodbye. Pipecat’s end_call_tool says it when the model said nothing and you gave no line of your own. For another language, pass your own goodbye in that language. See Tools.
End reasons
The reasons and their causes are in Why the call ended.- The words are constants in
unmute_core.call:CALLER_HUNG_UP,AGENT_ENDED,TRANSFERRED,MAX_DURATION,IDLE,ERRORandENDED. call.end_requested(reason)notes why the call is about to end. The first request wins. Every adapter calls it before it ends the call. Call it yourself before you end a call in your own code.call.caller_left()isend_requested("caller_hung_up"). LiveKit reads the caller leaving from the session’s close. On Pipecat, passtransport=toattach_calloropen_call, and the transport’son_client_disconnectedevent notes it. For a Daily dial-out, passcall=todial_out: the callee hanging up readscaller_hung_up. Callcall.caller_left()yourself only for a transport the library does not see. Your own handler still ends the pipeline.- A failure that comes first wins: the reason is
error. A failure after a requested end changes nothing. No failure line plays and nocall.failedfires. - Pass
call=tomax_duration,idle_nudges(LiveKit) orIdleNudgeObserver(Pipecat),end_call_toolandvoicemail_tool(Pipecat) andSipLine(LiveKit). Without it, the call’s end readsended. - Code that read LiveKit’s own words, like
participant_disconnectedoruser_initiated, must read these words instead.
Troubleshooting
CallStartError: Missing call_start fields
The dispatch left out a value thatcall_start marks required.
Fix: send the value in the dispatch’s call_start object, or mark it optional.
ValueError from CallHooks
A tool on an event assigns a value the state lacks. It can also name a loop of tools, or a chain deeper than 8. A prefetch with no state raises it too. Fix: read the message. It names the tool or the value to change.The call hangs up before the greeting
Abefore_call check raised, or ran past ready_timeout_s. The call failed with the component ready.
Fix: find the check in the log. Make it faster, or raise ready_timeout_s.
The failure line never plays on Pipecat
The worker’sprocessor_unusable_policy is not CONTINUE.
Fix: set it to CONTINUE.
Where to go next
The call's life
Open, greet and end a call, the short way.
Prefetch reference
Every entry and argument of the work at call start.
LiveKit
How
open_call and the end work on a LiveKit session.Pipecat
How
open_call, GreetingHold and the end work in a pipeline.