Email & other channels
Connecting a channel that cannot stream
A channel adapter connects your agent to a place where customers already write to you: email, Slack, Discord, or anything else. It receives each message from the channel, posts it into a Parlant conversation, and sends the finished reply back out. The agent itself does not change. You need one when customers reach you outside a chat widget, especially on a channel that cannot show a reply while it is being written.
- The four things every channel adapter does
- Two agent settings that fit a channel that cannot stream
- How to map channel users and threads to customers and sessions
- How to deliver a finished reply with a hook or the event feed
The adapter is a small piece of your application that talks to your agent's session API. Your coding agent writes it. We'll use email to walk through the pattern, since it's the strictest case.
Two terms carry the whole page. A session is one conversation between a customer and an agent. Parlant stores it as an ordered log of events (customer messages, agent messages, tool calls, and status updates). A turn is Parlant's handling of one customer message, from receiving it to posting the reply.
The shape of a channel adapter
Every channel adapter, whatever the channel, does four things:
- Receive a message from the channel: an email arrives, a Slack event fires, a Discord bot sees a message.
- Map it to a Parlant customer and session. The channel's notion of a person becomes a customer id, and its notion of a thread becomes a session.
- Post the message into the session as a customer message.
- Deliver the agent's reply back to the channel once the turn has finished.
Steps one through three are ordinary HTTP calls that you make with the admin key (the server-side credential that lets your backend act on any session). There's nothing channel-specific about them.
Step four is where channels differ from a chat widget. A widget renders events as they stream in, so it can show a reply while Parlant is still writing it. An email can only be sent once, whole. That's what the second half of this page is about.
Two agent settings for non-streaming channels
To make your agent fit a channel like this, Parlant lets you adjust two settings on the agent.
The first is the output mode, which decides whether Parlant writes a reply into the session piece by piece or all at once. Set output_mode=p.OutputMode.BLOCK, so that each message is emitted complete rather than chunk by chunk.
The second is the preamble, a short line like "let me check that" that the model may say before a slow tool call. In a chat widget it reassures the customer; in email it would arrive as a separate message. Set preamble_config=p.PreambleConfiguration.discourage() so the agent doesn't send it.
Streams and blocks explains both settings in more detail.
Mapping people and threads
Once your agent is configured, the adapter needs to know who is talking and which conversation they're in. This is step two of the four.
A customer is Parlant's record of the person the agent is talking to, and every session belongs to one. You choose the customer's id yourself when you create it, which is what makes this mapping easy.
Our recommendation is to use the channel's stable identifiers as Parlant's. For email, the customer id is the sender's address and the thread is the conversation. Create the customer once, before its first session, so that it exists when the first message arrives:
POST /customers
Content-Type: application/json
{"id": "[email protected]", "name": "Dana Cohen"}
Threads need one more piece. Parlant doesn't index sessions by your channel ids, so the thread-to-session mapping is yours to keep. A table in your own database, keyed by the email's thread id and holding the Parlant session id, is enough.
When a message arrives on a thread you haven't seen, create the session and store the pair:
POST /sessions
Content-Type: application/json
{"agent_id": "support", "customer_id": "[email protected]", "title": "Re: Order 5567"}
Note that Slack and Discord work exactly the same way. Only the identifiers change. There, the user id becomes the customer id, and the channel or thread id becomes the session key.
Guests and customers covers the customer side of this mapping.
Posting the inbound message
With the customer and session in hand, you can post the message itself. This step gives the agent what the customer wrote, and nothing more.
That's why you should strip the quoted history and signature from the email body first. Otherwise, the agent reads the whole thread again on every message, even though the session already holds it. Then post it:
POST /sessions/{session_id}/events
Content-Type: application/json
{
"kind": "message",
"source": "customer",
"message": "Hi, my order 5567 still hasn't arrived. Can you check?"
}
This call returns right away with the customer's event, not the reply. Parlant starts the turn in the background.
The returned event carries two fields that you'll need for the delivery step. Its offset is the event's position in the session log. Its trace_id is the id Parlant assigns to the turn and stamps on every event the turn produces. Hold on to both.
Note that you don't need to debounce on your side. If the customer sends two emails a few seconds apart, Parlant waits briefly and answers both in one turn.
Delivering the reply when the turn ends
With messages flowing into the session, the reply has to get out. This is the step that makes a non-streaming channel different.
The difficulty is timing. A turn can produce more than one agent message, and a streaming turn produces partial events on the way. The email must go out once, after everything, with the messages merged. So you need to know exactly when a turn is done, and Parlant marks that moment for you with a hook.
What a hook is
A hook is a function of yours that Parlant calls at a fixed point in a turn. Hooks live on p.CompassHooks, with one list per point.
You register them in an initialize_container function that runs once at server startup. The container it receives holds Parlant's services, including the hooks themselves and the SessionStore, which reads events.
The on_messages_emitted point
The point you want is on_messages_emitted. Parlant fires it once per turn, after the turn has finished and the ready status has been written, and never before. Its payload is the list of messages the turn delivered, in the order the customer would see them.
Inside the hook, you join them and hand the text to your channel. Ask your coding agent for a hook like this:
from collections.abc import Sequence
import parlant.sdk as p
async def initialize_container(container: p.Container) -> None:
hooks = container[p.CompassHooks] # the hook lists, one per point in the turn
sessions = container[p.SessionStore] # reads and writes session events, if you need more than the text
async def deliver_turn(
context: p.EngineContext, # the turn: its session, customer, and tracer
payload: Sequence[str], # the agent messages this turn delivered, in order
exc: Exception | None,
) -> p.CompassHookResult:
messages = [m for m in payload if m]
if not messages:
return p.CompassHookResult.CALL_NEXT
thread = await threads.find_by_session(context.session.id)
await email.send(
to=context.customer.id,
subject=thread.subject,
body="\n\n".join(messages),
in_reply_to=thread.last_message_id,
)
return p.CompassHookResult.CALL_NEXT # let any other hooks at this point run too
hooks.on_messages_emitted.append(deliver_turn)
async with p.Server(initialize_container=initialize_container) as server:
...
Here, threads is your stored thread-to-session mapping and email is your mail client. They're application integrations of yours, not Parlant APIs.
If you need the message events themselves rather than their text (for example, to read their metadata), call sessions.list_events(session_id=context.session.id, source=p.EventSource.AI_AGENT, trace_id=context.tracer.trace_id, kinds=[p.EventKind.MESSAGE]). Filtering on the turn's trace id and on AI_AGENT as the source gives you exactly this turn's replies, and nothing from earlier turns or from an operator.
Record each delivery by session and trace id (context.tracer.trace_id), so that a retry doesn't send the same turn twice.
Why the reply lands on the right turn
Three details keep the reply associated with the right turn.
The payload is this turn's only. Every event a turn produces shares context.tracer.trace_id, and the hook's payload holds only the messages this turn delivered. A turn that calls a tool sometimes delivers two: the preamble and the reply that follows. Joining them is what the example does.
Only completed turns fire. Suppose a second email arrives while a turn is still running. Parlant cancels that run and starts over with both messages. The cancelled run never reaches the hook, so the customer gets one email, not two.
Skip empty turns. A turn that ended in manual mode (the session mode where the agent stays silent so a human can reply), or one that was cancelled explicitly, may emit no message at all. Returning early keeps you from sending a blank email.
Note that the hook runs inside the turn, so a slow mail server delays Parlant's finalization of that session. If sending can take seconds, put the message on a queue from the hook and send it from a worker instead.
Hooking into the engine covers the other hook points and what each can do.
Delivering without a hook
A hook is the simplest route, but it assumes your adapter runs in the same process as the server. If it doesn't, you can watch the session's event feed instead.
The feed is GET /sessions/{id}/events, and it long-polls: min_offset says where to start reading, and wait_for_data says how many seconds to wait for new events before returning.
To know when a turn is done, you rely on status events: events with kind: "status" that report a turn's progress, ending in ready, error, or cancelled. Long-poll from the offset just after the customer message, wait for a status event with status: "ready" on the same trace_id, then read the message events for that trace and send them merged:
GET /sessions/{session_id}/events?min_offset=13&kinds=status&trace_id={trace_id}&wait_for_data=60
GET /sessions/{session_id}/events?kinds=message&source=ai_agent&trace_id={trace_id}&wait_for_data=0
Both the trace id and the offset come from the event Parlant returned when you posted the customer message.
Note that a cancelled status should be treated the same as never having seen ready. The turn will run again with the new message, and that later trace is the one to deliver.
Slack and Discord
The same adapter serves chat channels too, with two differences.
First, their messages can be edited after posting. That means a streaming agent can show a reply growing, by editing one message each time on_message_updated fires (the hook point Parlant calls whenever a streaming message gains text). Block mode with the hook above is simpler, though, and usually fine.
Second, they carry the operator side naturally. A human replying in the thread can be posted into the session as a human_agent message, which is the handoff pattern from Human handoff.