Asynchronous responses
Speaking up when slow work finishes
An asynchronous response is a message your agent sends on its own, later, when slow work finishes. Booking a flight or running a credit check can take half a minute. Instead of leaving the customer with a spinner, the tool starts the work, the agent says it is underway, and the conversation continues. When the result lands, the agent speaks up with it, unprompted.
- How a tool starts slow work and returns at once
- How the agent speaks up on its own when the result arrives
- When to use it, and when a plain tool is better
- What to do instead when an AI agent speaks for the customer
- How an uttered message reaches chat, voice, and email
The follow-up message uses p.Agent.current.utter(...). It asks your agent to generate a message right now, following instructions you give it, with no customer message to prompt it. Call it from a background task when the work finishes, and your agent speaks up mid-conversation.
This page covers the tool side, which your coding agent writes. A tool is a Python function your agent can call during a turn, declared with @p.tool. A turn is Parlant's handling of one customer message, from receiving it to posting the reply. Normally your agent speaks only at the end of a turn. utter is the exception.
The pattern
Your tool needs to say two things at two different times: "it's underway" now, and "it's confirmed" later. The first goes on the tool's result, and the second goes through utter.
A tool returns a p.ToolResult. Its data is what the model reads, and its rules are transient rules: one-off instructions, each a {"content": ...} mapping, that apply to this turn's reply only. p.Agent.current is the agent handling the current turn.
So there are three pieces, all inside the tool:
- Start the slow work as a background task and return immediately.
- In the result, tell your agent what to say now, with a transient rule.
- When the task finishes, save its final tool result with
session.emit_tool_result, then callutterwith what to say then.
Here, airline is your booking-service client, configured with a request timeout. Its confirmation includes a code and a service_fee_usd decimal string for the service charge; replace this with your integration’s actual reported cost. The example handles the booking result and message delivery separately. A service error must never be described as a successful booking, and a failed reply must not cause a second booking request.
import asyncio
from decimal import Decimal
import parlant.sdk as p
background_tasks: set[asyncio.Task[None]] = set()
@p.tool
async def book_flight(flight_number: str, passenger_name: str) -> p.ToolResult:
agent, session = p.Agent.current, p.Session.current
logger = p.Server.current.logger
async def complete_booking() -> None:
try:
confirmation = await airline.book(flight_number, passenger_name)
except Exception as error:
logger.warning(f"Booking confirmation failed: {type(error).__name__}")
completed = p.ToolResult(data={"status": "confirmation unavailable"})
instruction = (
"Tell the customer you couldn't confirm the booking. "
"Ask them to check with the airline before trying again, "
"because the booking's status is unknown."
)
else:
completed = p.ToolResult(
data={"status": "confirmed", "code": confirmation.code},
costs={"Flight booking": Decimal(confirmation.service_fee_usd)},
)
instruction = (
f"Tell the customer their booking on flight {flight_number} is confirmed, "
f"with confirmation number {confirmation.code}."
)
# Completing the booking and delivering its update can fail separately.
try:
await session.emit_tool_result(
book_flight,
completed,
arguments={"flight_number": flight_number, "passenger_name": passenger_name},
)
await agent.utter(session=session, rules=[{"content": instruction}])
except Exception as error:
logger.error(
f"Booking update failed for session {session.id}: {type(error).__name__}"
)
try:
# No second model call, and no second booking request.
await session.emit_message(
"I couldn't send your booking update. Please check with the "
"airline before booking again; the booking may have gone through."
)
except Exception as fallback_error:
logger.error(
f"Booking fallback failed for session {session.id}: "
f"{type(fallback_error).__name__}"
)
# In production, retain this failed delivery in your durable
# job system so it can be recovered or handled by an operator.
task = asyncio.create_task(complete_booking())
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
return p.ToolResult(
data={"status": "booking in progress", "flight": flight_number}, # what the model reads now
rules=[ # shapes this turn's reply
{"content": "Tell the customer you're booking the flight now and will confirm in a moment."},
],
)
What happens at each moment
Because the tool returns without waiting for the airline, the current turn finishes normally. Your agent says the booking is underway, and the customer can ask about baggage or seats while it runs.
When the airline answers, the background task saves the completed tool result and its reported cost, then calls utter, and Parlant posts a new agent message to the session with the confirmation.
The example captures Agent.current and Session.current before starting the task, then passes that session explicitly to utter. A task created within a turn also inherits the turn's context, so the .current accessors still resolve there. (p.Session.current is the session, the stored conversation, that the turn belongs to.)
What utter does
session.emit_tool_result(tool, result, arguments=...) saves completed background
work as a tool event in the session. It uses the usual tool-result tracing and
accounting path without calling the tool or a model. Its data becomes session
context; its USD costs are recorded independently of the follow-up message.
Capture the session before starting the task and pass the original tool function
(or its p.ToolId). No operation identifier is required.
Call emit_tool_result before utter. If generation fails, a budget blocks
it, or you choose not to send a message, the completed work and its incurred cost
still remain recorded. Do not emit the same completion again just because its
message failed; the SDK does not provide durable jobs or deduplicate repeated
application calls. The initial “in progress” result and the completed result are
separate events, so report the incurred cost once, when you know it.
utter runs a generation step for the session. It takes the conversation so far as context, and your rules as turn-scoped instructions. Each rule is a {"content": ...} mapping, phrased like a policy's content, and it applies only to this one message.
The method returns the generated text. By the time it returns, Parlant has already posted the message to the session.
The message is your agent's own writing. It reads the history, so it won't repeat a detail the customer just discussed, and it follows your agent's instructions and matched rules as any reply would.
Note that what utter won't do is call tools. Give it the facts it needs in the rule text, as the example does with the confirmation number.
Finishing reliably
Returning “in progress” from a tool means the job was started. It does not mean the work finished or its answer reached the session. Track those outcomes separately: work in progress, result available, and final message posted or delivery failed.
Keep a failed progress notification separate from final delivery. If a “still checking” message fails, log it and keep working. If utter fails, the example attempts one fixed message with session.emit_message(...), which does not depend on another model call. If that also fails, record the undelivered update for recovery; logging it alone does not notify the customer. Don't expose internal errors, and don't tell the customer the service was unavailable when only message delivery failed.
The booking's outcome and the reply's outcome are different. When you know the booking succeeded, retain that result even if its notification fails. Recover the notification, not the booking. If an exception leaves it unclear whether a message was posted, check stored messages or use a delivery identifier before retrying, so the customer does not get duplicate confirmations.
Keeping replies in order
Starting a task does not guarantee the initial “it's underway” reply will arrive first. If the work can finish quickly, coordinate with the originating turn's message-emitted hook before posting the follow-up, with a bounded wait if that turn fails. A fixed sleep is not a reliable ordering mechanism.
Track pending work per session and keep it pending until its final answer or failure notice has been posted. A customer saying “thanks” or “and?” while waiting is not a new request to start the work again. Prevent duplicate jobs in code, while allowing a genuinely different request to proceed. Use an operation or request identifier for actions such as bookings, and include changed inputs when identifying new work.
Test the whole sequence, including a fast result, a slow result, a customer message during the wait, a failed service call, a failed progress message, and failed final delivery. Check the stored final message or explicit delivery failure, rather than treating a successful tool return as proof. Test shutdown cancellation too: release task references and pending state, cancel owned child tasks, and let cancellation propagate.
Calling it from outside a turn
Not every trigger arrives inside a turn.
Outside a turn there is no current agent or session, so the .current accessors have nothing to resolve to. From a webhook handler, a queue worker, or any code with no active turn, you fetch the session by id and pass it explicitly:
session = await server.get_session(id=session_id) # the conversation to speak into
await agent.utter(
session=session, # required here: there is no Session.current
rules=[{"content": "Tell the customer their package was delivered and ask if everything arrived intact."}],
)
This is the same mechanism a channel adapter or an outreach job uses to have your agent start a conversation.
The equivalent over HTTP is posting a message event with source: "ai_agent" and rules, as Managing chat sessions shows.
When to use it, and when not to
Use utter when your agent should say something the customer didn't ask for at that moment: a long task finished, an external event happened, a scheduled follow-up is due. It keeps the conversation feeling attended to rather than stalled.
Prefer a plain synchronous tool when the work takes a second or two. The round trip through a second message costs more than the wait.
Prefer session.emit_message(...) when the text must be verbatim, such as a legal notice or a system status. It posts a message with exactly the text you give it, whereas utter always generates.
Surviving restarts
The task set in the example only keeps background work referenced while this process runs. It does not persist jobs or recover failed notifications. For work that must survive a server restart, use a durable job queue, store the business result and delivery state, and send the result from your worker with the explicit session, as shown above. Configure service and delivery timeouts so jobs cannot remain pending forever. Record the session or job id and distinguish lookup failures from delivery failures, using structured logs or a session label. Monitor failed deliveries and give them a bounded retry or operator recovery path. Your booking API should also prevent duplicate requests if a turn is retried.
When an AI agent speaks for the customer
Sometimes the other side of the conversation isn't a person typing. It's an AI agent acting for them, such as a personal assistant that books travel on its user's behalf. That caller sends a message and waits for your agent's reply. A message your agent sends later, on its own, doesn't reach it until it writes again, and it may never write again. So for an AI caller, the answer has to come in the same turn.
Whatever creates the session says so, once, when it creates it. Over HTTP, that's a field on POST /sessions:
{ "agent_id": "travel-agent", "customer_represented_by_an_agent": true }
It's fixed for the life of the session, and it defaults to false. It describes this conversation, not the customer: the same person can use their assistant today and your website's chat tomorrow.
In a tool or a hook, p.Customer.current.is_represented_by_an_agent tells you. It reads the current session, so it's only available inside a turn. Outside one, there's no conversation to ask about, and it raises.
The booking tool, adapted. For an AI caller, it waits for the airline and answers in this turn. Everyone else gets the background booking and follow-up from above, unchanged:
IN_TURN_SECONDS = 60 # a safe margin under a 90-second wait for the reply
@p.tool
async def book_flight(flight_number: str, passenger_name: str) -> p.ToolResult:
if p.Customer.current.is_represented_by_an_agent:
try:
confirmation = await asyncio.wait_for(
airline.book(flight_number, passenger_name),
timeout=IN_TURN_SECONDS,
)
except Exception as error:
p.Server.current.logger.warning(
f"In-turn booking failed: {type(error).__name__}"
)
return p.ToolResult(
data={"status": "confirmation unavailable"},
rules=[{"content": (
"Tell the customer you couldn't confirm the booking. Ask them to check "
"with the airline before trying again, because its status is unknown."
)}],
)
return p.ToolResult(
data={"status": "confirmed", "code": confirmation.code},
costs={"Flight booking": Decimal(confirmation.service_fee_usd)},
)
... # the background booking and follow-up shown above
A few things change when an AI agent is on the other end:
- Stay under the caller's timeout. The calling agent waits for your reply only so long, and the proxies in between often cut a request off at 60 to 100 seconds. Your agent also needs time to write the reply after the tool returns, so leave a margin: 60 seconds of in-turn work suits a 90-second wait, the longest Parlant's A2A gateway allows. Treat a timeout like any other failure: its outcome is unknown, so don't report the work as done.
- Skip progress messages. "Still checking…" adds nothing when the reply arrives all at once.
- Use it for timing, not trust. The flag tells your tools how to deliver an answer. It doesn't say who the customer is, or what they've allowed you to do.
Streams, voice, and channels
An uttered message reaches the customer differently on each kind of channel.
It is an ordinary agent message event in the session, so whatever reads the session sees it the usual way. In stream mode, where your agent writes each message into the session piece by piece, the uttered message streams like any other.
Over voice, Parlant speaks it to the caller mid-conversation. This is how a voice agent reports back on a slow search.
For a channel that delivers whole turns, such as email, an uttered message is its own turn with its own trace id (the id Parlant stamps on every event a turn produces). The adapter sends it as a second message.
Voice agents covers speaking mid-call, and Email & other channels shows the adapter that sends an uttered message as its own turn.