Delivery Service¶
kit.deliver() brings content into a room from outside it — a background
task's result, an external event, a scheduled nudge, something another room
produced — at a moment the room's channels allow: voice playback, user speech,
idle detection. The room's agents read it and act on it.
Quick start¶
from roomkit import RoomKit, WaitForIdle
kit = RoomKit(delivery_strategy=WaitForIdle(buffer=3.0))
# A background search finished: the room's agent reads it and tells the user
await kit.deliver("room-id", content="Flight search finished: 3 options found.")
Not a way to text the room's correspondent
On a text transport (SMS, email, WhatsApp, WebSocket...), the content
enters the room as a message received on that channel, from the
framework's own sender (system). It is stored, the room's agents read it
and answer it, and the correspondent receives their answer, never the
content itself; with no agent in the room, nothing is sent. A
deliver("room", "Your payment was confirmed.") on an SMS room reports
sent and texts nothing. To send the correspondent a message, see
Sending a message to the correspondent.
Use cases¶
- Delegation results — workers finish in the background, results delivered to user
- External events — webhook arrives, voice agent mentions it
- Scheduled notifications — timer fires, agent speaks
- Cross-room results — something in room B relevant to room A
Strategies¶
Strategies control when content is delivered:
from roomkit import Immediate, WaitForIdle, Queued
# Send now; on voice, it plays once the playback under way is sent whole
kit = RoomKit(delivery_strategy=Immediate())
# Wait for AI + user silence, then deliver after buffer
kit = RoomKit(delivery_strategy=WaitForIdle(buffer=3.0))
# Batch multiple deliveries into one message at next idle window
kit = RoomKit(delivery_strategy=Queued(buffer=2.0, separator="\n\n"))
| Strategy | When it delivers | Best for |
|---|---|---|
Immediate() |
Now; on voice, once the playback under way is sent whole (call interrupt() first to cut it) |
Urgent alerts, text channels |
WaitForIdle(buffer) |
After AI stops speaking + user stops talking + buffer | Voice conversations |
Queued(buffer, separator) |
Batches multiple items, delivers at next idle | High-frequency results |
String shorthand:
await kit.deliver("room", content="hello", strategy="immediate")
await kit.deliver("room", content="hello", strategy="wait_for_idle")
await kit.deliver("room", content="hello", strategy="queued")
WaitForIdle details¶
WaitForIdle is voice-aware:
- VoiceChannel: waits for
wait_playback_done()(TTS finished) + buffer - A channel hosting a realtime model (
RealtimeVoiceChannel,RealtimeAudioVideoChannel, aConferenceChannelwith a realtime model plugged in): waits forwait_idle()(the model's answer ended and reached the transport, nobody heard speaking) + buffer - Text channels: delivers immediately (no playback to wait for)
WaitForIdle(
buffer=3.0, # seconds to wait after idle detected
playback_timeout=15.0, # max seconds to wait for playback
)
Channel-aware delivery¶
kit.deliver() auto-detects the best transport channel in the room. It
picks, among the transports bound to the room:
- A voice channel first (most latency-sensitive): a voice channel type, or any channel whose realtime model is plugged in at delivery time (a conference with one included), whatever order it was attached in;
- otherwise the first other transport bound (WebSocket, SMS, etc.).
Each kind then receives the content its own way:
- A channel hosting a realtime model — injected via
inject_text()into the model's session (systemintent withinstruction=True) - VoiceChannel — synthetic inbound message → TTS
- Other transports — synthetic inbound message
An intelligence channel attached as a transport is never picked: an instruction delivered through it would re-enter the agent it is for.
Override with channel_id:
Naming a ConferenceChannel with a realtime model plugged in injects into the
model's room session, and is unavailable (voice_session_unavailable) until
that session connects. A strategy that waits pins the session first: if the
model is unplugged during the wait, the delivery is refused
(voice_session_replaced), never published to the room instead.
A channel of your own takes part by inheriting RealtimeModelHost
(get_room_sessions(), inject_text(), wait_idle()); hosts_realtime_model(channel)
is the predicate the delivery paths read.
Sending a message to the correspondent¶
A message the host itself sends to the room's customer (a confirmation, a
verification link) is published from a channel of the host's own with
send_event. The room's transports deliver it like any other message:
from roomkit import TextContent
from roomkit.channels import WebSocketChannel
kit.register_channel(WebSocketChannel("bank")) # the host's own channel
await kit.attach_channel(room_id, "bank")
await kit.send_event(
room_id, "bank", TextContent(body="Your payment was confirmed."),
addressed_to=[], # no agent is asked to answer it
)
The message stays in the room's history, where the agents read it on their
next turn; restrict its visibility to the transports that should carry it to
keep it from them.
Framework default¶
Set the default strategy on RoomKit:
kit = RoomKit(delivery_strategy=WaitForIdle(buffer=3.0))
# All deliver() calls use WaitForIdle unless overridden
await kit.deliver("room", content="result")
# Override per call
await kit.deliver("room", content="urgent!", strategy=Immediate())
Observable outcomes¶
Every call returns a DeliveryOutcome:
| Status | Meaning |
|---|---|
queued |
The delivery backend accepted the item; no transmission is established yet. |
sent |
The text event was published, or the realtime provider accepted the injection. |
blocked |
A hook, room state or permission refused the request. |
unavailable |
A channel, addressed agent or voice session could not be reached. |
failed |
Enqueue, strategy, provider or delivery execution failed. |
unknown |
Voice submission is uncertain, or a custom strategy returned no outcome. |
Read reason for a machine-readable explanation, error for structured failure
information and unavailable_targets for unresolved addresses. event_id can be
present on an unavailable/failed result: publication may succeed while agent
solicitation or delivery fails. A failure after publication is not retried by
the worker as a new text event.
Text destination availability comes from the committed delivery plan and its
execution, so an agent leaving after processing does not turn a successful
delivery into a failure. inbound.unavailable_targets is updated when its
deferred handle finishes; replays do not re-evaluate the original destinations.
sent does not establish that the agent completed its turn. Direct Immediate
and WaitForIdle calls wait for the existing text delivery handle when doing so
cannot deadlock the room. Queued returns at publication, so its drain can accept
requests from tools in an ongoing turn. Its inbound.delivery handle can be
awaited separately. turn_complete reports known text completion, including
failed completion; it is false for a replay or realtime injection.
result = await kit.deliver(
"room", "Background result ready", addressed_to=["agent-a"],
idempotency_key="external-event:42",
)
if result.inbound is not None and result.inbound.delivery is not None:
turn = await result.inbound.delivery.wait()
# Inspect turn.error, turn.response_events and turn.response_metadata.
The live inbound object is excluded from JSON serialization. Persisted queue
items and hook metadata contain the serializable outcome snapshot. A queued
call's delivery_item_id links it to the later AFTER_DELIVER observation;
there is no promise that the initial queued result updates itself.
Addressing an external event¶
result = await kit.deliver(
"conversation", "The background task finished.",
channel_id="websocket", # Transport/source attribution
addressed_to=["agent-a"], # Intelligence channel asked to act
idempotency_key="task-result:42",
metadata={"event_type": "task.finished", "task_id": "42"},
)
addressed_to=None retains the room's routing policy. [] stores and broadcasts
the event without soliciting an intelligence channel. Unknown or unreadable
addresses are reported as unavailable; another agent is never selected in their
place. Valid addresses in a mixed list can still act. The stored event retains
the whole address, and addressing does not change visibility, permissions or
muting. A configured supervisor retains the RFC's supervisory exception.
Naming an intelligence channel in channel_id selects its room's transport for
compatibility. Use addressed_to to choose which intelligence channel acts.
Delivering an instruction¶
Content the application produces for an agent, such as a background task's
result or a scheduled nudge, is not something a participant said. Pass
instruction=True to deliver it as the application's direction:
result = await kit.deliver(
"conversation", "Tell the caller their order shipped.",
addressed_to=["agent-a"], # Required through the text pipeline
instruction=True,
)
Through the text pipeline it is an INSTRUCTION event (see
Directing an agent): the
addressed agent takes it as its input for one turn and answers, and nothing is
stored. It needs addressed_to and no idempotency_key; otherwise the outcome
is blocked (instruction_unaddressed, instruction_not_idempotent). Sent to a
realtime channel (channel_id= without addressed_to), it is injected with the
system intent instead of user. The strategy, the delivery hooks and a delivery
backend apply unchanged. The hooks see an event of type INSTRUCTION.
Text idempotency and retries¶
A key identifies one publication within a room, even if a later call supplies
a different body or address. Concurrent calls with that key return the original
event_id; replays set duplicate=True and do not run the agent again. The
existing locked inbound pipeline owns this decision. Different keys, rooms, or
calls without a key remain distinct. Empty keys are refused.
Keys last as long as the configured ConversationStore retains them. The memory
store loses them on restart; SQLite and Postgres preserve committed keys across
restart until the corresponding event/key is removed. Distributed deployments
need a shared store and an appropriate shared lock manager. Custom stores must
implement the same idempotency contract; an older store that cannot resolve a
seen key may return a duplicate refusal without an event id.
A stored event does not prove its turn completed. If a process crashes after commit, replaying the key avoids a second trigger but does not resume the lost turn. Durable delivery-lane outboxes and crash recovery are separate capabilities.
Selecting a realtime session¶
result = await kit.deliver(
"call-room", "Your result is ready.", channel_id="voice-main",
session_id=session.id, strategy=WaitForIdle(buffer=1.0),
)
session_id requires an explicit realtime channel_id and cannot be combined
with addressed_to. Without a session id, an explicitly selected realtime
channel needs exactly one active session; otherwise the result is unavailable.
Idle strategies wait only for the selected realtime sessions. A busy session outside that selection does not delay delivery. Each pinned session is checked again immediately before injection; any sessions already reached remain listed if a later injection fails or becomes unavailable. Idle strategies pin that session before waiting and verify it again at injection. An ended/replaced session never selects a replacement. For deferred delivery, provide the original session id: resolution otherwise happens when the worker executes, not when the producer enqueues.
Unaddressed room-only calls retain realtime fanout. Supplying a non-null
addressed_to uses the text pipeline even on a realtime transport; it never
turns that intelligence address into direct session injection. Transport delivery
and visibility still follow the room's ordinary broadcast rules.
Muted/read-only bindings receive silent context injection; unreadable bindings
are refused. Anam cannot inject context silently and explicitly refuses these
injections (voice_silent_injection_unsupported) instead of triggering speech.
Voice idempotency and uncertainty¶
Supply an idempotency_key to deduplicate proactive realtime injection:
result = await kit.deliver(
"call-room", "Your result is ready.", channel_id="voice-main",
session_id=session.id, idempotency_key="task-result:42",
)
session_result = result.session_outcomes.get(session.id)
The identity is (room_id, channel_id, session_id, idempotency_key). Concurrent
calls and later replays reuse the recorded result with duplicate=True. The
store atomically reserves the identity before calling the provider; a unique
attempt token prevents a former owner from changing another attempt's result.
Reusing the same identity for different effective text (after BEFORE_DELIVER)
returns blocked with voice_idempotency_conflict. Use a stable key and body
for each external event.
sent means the adapter's send operation completed; it does not prove audio was
heard or a turn finished. When an exception, cancellation, abandoned reservation
or lost persistence confirmation makes submission uncertain, the outcome is
unknown. Replaying that key does not inject again. This can leave an
announcement undelivered when a crash occurred before submission; the remote
service and the store do not share an atomic transaction. Inspect an uncertain
result instead of automatically issuing a new key, which would authorize a new
delivery.
Only an explicit provider VoiceInjectionResult(status="not_sent", retryable=True)
permits a retry after calling the provider: it guarantees no submission or pending
submission exists. The worker dead-letters uncertain injections without retrying
them. Gemini input queued locally during a tool call is reported as unknown
(voice_provider_queued); its later flush does not update the receipt.
session_outcomes retains each selected session's outcome, including successes,
duplicates, failures and uncertainty. session_ids lists successful submissions.
A retry can submit to a session that safely refused while skipping successful
or uncertain sessions. The aggregate status prioritizes uncertainty, failure,
unavailability and refusal over success; inspect individual outcomes for partial
delivery. Room-only fanout resolves active sessions on each execution. Supply
both channel_id and the original session_id to keep retries tied to one call;
its known receipt remains replayable after that session ends.
Receipts last until their room is deleted. The memory store loses them when its
process ends. SQLite and Postgres retain them across restart; a Redis delivery
queue alone does not make an in-memory ConversationStore durable. Multi-worker
deployments need a shared durable store. Shared application locks allow callers
to wait for an active attempt; the store's atomic claim prevents duplicate
submission even without those locks. Reservations have no automatic expiry or
reclamation that could reauthorize an uncertain submission.
Custom stores must implement get_voice_delivery, claim_voice_delivery and
complete_voice_delivery with the same atomic semantics. Unsupported stores
refuse keyed voice delivery before sending (voice_idempotency_unsupported).
Custom realtime providers may still return None, but this reports unknown
(voice_acceptance_unreported); return VoiceInjectionResult to report a known
submission boundary. ON_REALTIME_TEXT_INJECTED fires only for a provider's
explicit sent result. AFTER_DELIVER exposes all ordinary attempt outcomes.
Calls without a key and direct channel.inject_text() calls do not use these
reservations. The feature does not resume interrupted agent turns.
Run the voice idempotency example
with uv run python examples/voice_delivery_idempotency.py. It demonstrates
concurrent delivery, a lost confirmation, a safe retry and a SQLite restart using
mock audio providers, with no credentials or network service.
Queued grouping¶
Reuse a Queued instance to batch compatible requests. Rooms, transports,
intelligence addresses, session targets, intents (message or instruction) and
metadata must match. Requests with
idempotency keys publish individually, retaining each key and outcome attribution.
A request arriving during transmission is drained too. Cancelling the drain
wakes waiting callers; it does not roll back committed events.
Hooks¶
from roomkit import HookTrigger, HookExecution, HookResult
@kit.hook(HookTrigger.BEFORE_DELIVER)
async def before_deliver(event, ctx):
# This synchronous gate may return HookResult.block("policy") or modify text.
return HookResult.allow()
@kit.hook(HookTrigger.AFTER_DELIVER, execution=HookExecution.ASYNC)
async def after_deliver(event, ctx):
outcome = event.metadata["delivery_outcome"]
logger.info("Delivery %s: %s", outcome["status"], outcome["reason"])
BEFORE_DELIVER runs synchronously; an explicit refusal stops delivery, while
hook infrastructure failures follow the RFC's fail-open rule. AFTER_DELIVER
observes refusals as well as successful and failed attempts, with effective
rewritten content. Hook observation is best-effort. Neither hook changes the
inbound pipeline ordering or its own broadcast hooks.
| Hook | When | Payload |
|---|---|---|
BEFORE_DELIVER |
Before strategy executes | Address and key on the event, whose type is INSTRUCTION for an instruction delivery; channel_id, strategy, delivery_item_id, session_id in metadata |
AFTER_DELIVER |
After an execution attempt | Same fields, plus delivery_outcome and error (null on success/replay) |
Integration with orchestration¶
The Supervisor strategy uses kit.deliver() internally:
- Sync mode (
async_delivery=False): results returned inline, no delivery needed - Async mode (
async_delivery=True): workers run in background, and their results are handed back to the supervisor withkit.deliver(..., instruction=True), each worker's output bounded, when the strategy allows
from roomkit import RoomKit, Supervisor, WaitForIdle
kit = RoomKit(
delivery_strategy=WaitForIdle(buffer=3.0),
orchestration=Supervisor(
supervisor=coordinator,
workers=[analyst_1, analyst_2],
strategy="parallel",
auto_delegate=True,
async_delivery=True,
),
)
See the Orchestration guide for full Supervisor documentation.
Persistent delivery backends¶
By default, kit.deliver() executes in-process — if the process crashes, pending deliveries are lost. For production deployments, configure a delivery backend to decouple enqueue from execution:
from roomkit import RoomKit, InMemoryDeliveryBackend, WaitForIdle
# In-memory backend (single process, no persistence)
kit = RoomKit(
delivery_strategy=WaitForIdle(buffer=3.0),
delivery_backend=InMemoryDeliveryBackend(),
)
async with kit:
await kit.deliver("room", content="Background result ready.")
# Item is enqueued → worker loop executes delivery asynchronously
How it works¶
When a delivery_backend is configured:
kit.deliver()serializes the request into aDeliveryItemand callsbackend.enqueue()- A background worker loop calls
backend.dequeue()to claim items - The worker deserializes the strategy and executes
strategy.deliver() - Consume sent/refused items with
ack(); retry transient unavailable/failed outcomes withnack(); dead-letter non-retryable failures and uncertain voice submissions.
kit.deliver()
→ serialize strategy + content → DeliveryItem
→ backend.enqueue(item)
→ return (non-blocking)
Worker loop (background):
→ backend.dequeue() → claim items
→ BEFORE_DELIVER hook
→ strategy.deliver(ctx)
→ AFTER_DELIVER hook
→ backend.ack() or backend.nack()
Redis backend¶
For multi-worker deployments, use RedisDeliveryBackend with Redis Streams:
from roomkit import RoomKit, WaitForIdle
from roomkit.delivery import RedisDeliveryBackend
kit = RoomKit(
delivery_strategy=WaitForIdle(buffer=3.0),
delivery_backend=RedisDeliveryBackend("redis://localhost:6379"),
)
Requires pip install roomkit[redis].
Features:
- Consumer groups distribute items across workers automatically
- At-least-once delivery via Redis Streams PEL (Pending Entries List)
- Bounded dead-letter stream for items that exhaust retries
- Delayed retries held in a sorted set on the server's clock
- Injected client support for connection pooling
import redis.asyncio as redis
pool = redis.ConnectionPool.from_url("redis://localhost:6379")
client = redis.Redis(connection_pool=pool)
backend = RedisDeliveryBackend(
client=client,
stream_prefix="myapp:delivery",
group_name="myapp-workers",
max_dead_letter_size=10_000,
)
Available backends¶
| Backend | Persistence | Multi-worker | Install |
|---|---|---|---|
InMemoryDeliveryBackend |
No | No | Built-in |
RedisDeliveryBackend |
Yes | Yes | roomkit[redis] |
Retry and dead-letter¶
A retryable failure is retried up to max_retries times after the first attempt (default 3, so four attempts at most), as a binding's RetryPolicy counts them. Each retry waits first, so a destination that is briefly unavailable (a voice session reconnecting) has time to come back: min(retry_delay * 2 ** (n - 1), max_retry_delay) seconds before retry n, 1 s and 60 s by default. Both are set on the backend; retry_delay=0 retries at once:
RedisDeliveryBackend keeps a waiting retry in a sorted set ({stream_prefix}:delayed), timed by the Redis server's clock, and the next worker to dequeue puts it back on the stream once due; no two workers take the same retry. get_queue_depth() counts the retries waiting out their delay.
Exhausted or non-retryable failures move to the dead-letter queue with item.outcome, address, key and session target preserved. Explicit hook refusals are consumed without retry and retain a blocked outcome:
# Inspect dead-lettered items, the most recent first in every backend
dead = await backend.get_dead_letter_items(limit=50)
for item in dead:
print(f"{item.id}: {item.error}")
# Check queue depth
depth = await backend.get_queue_depth()
Backward compatibility¶
Calls without the new options remain valid. Without a backend, execution stays in-process; callers that ignore the returned outcome can continue doing so. Custom strategies returning None execute but report unknown.
Run uv run python examples/external_event_delivery.py for a network-free example with two agents and a replayed external event.