Skip to main content

Custom Frontend

Using the Official React Widget​

The fastest way to integrate Parlant into your React application is using our official parlant-chat-react widget. This component provides a complete chat interface that connects directly to your Parlant agents.

Building your own frontend?

If you need full control over the UI or you're building in a different framework, skip ahead to Building a Custom Frontend — it covers Parlant's event-based protocol from scratch, with vanilla JavaScript examples.

Installation and Basic Setup​

Install the widget via npm or yarn:

npm install parlant-chat-react
# or
yarn add parlant-chat-react

Then integrate it into your React application:

import React from 'react';
import ParlantChatbox from 'parlant-chat-react';

function App() {
return (
<div>
<h1>My Application</h1>
<ParlantChatbox
server="http://localhost:8800" // Your Parlant server URL
agentId="your-agent-id" // Your agent's ID
/>
</div>
);
}

export default App;

Configuration Options​

The widget supports several configuration props:

<ParlantChatbox
// Required props
server="http://localhost:8800"
agentId="your-agent-id"

// Optional props
sessionId="existing-session-id" // Continue existing session
customerId="customer-123" // Associate with specific customer
float={true} // Display as floating popup
titleFn={(session) => `Chat ${session.id}`} // Dynamic title generation
/>

Common Customizations​

Styling with Custom Classes​

Customize the appearance using CSS class overrides:

<ParlantChatbox
server="http://localhost:8800"
agentId="your-agent-id"
classNames={{
chatboxWrapper: "my-chat-wrapper",
chatbox: "my-chatbox",
messagesArea: "my-messages",
agentMessage: "my-agent-bubble",
customerMessage: "my-customer-bubble",
textarea: "my-input-field",
popupButton: "my-popup-btn"
}}
/>

Custom Component Replacement​

Replace specific components with your own:

<ParlantChatbox
server="http://localhost:8800"
agentId="your-agent-id"
components={{
popupButton: ({ toggleChatOpen }) => (
<button
onClick={toggleChatOpen}
className="custom-chat-button"
>
Chat with us
</button>
),
agentMessage: ({ message }) => (
<div className="custom-agent-message">
<img src="/agent-avatar.png" alt="Agent" />
<p>{message.data.message}</p>
</div>
)
}}
/>

Floating Chat Mode​

Enable popup mode for a floating chat interface:

<ParlantChatbox
server="http://localhost:8800"
agentId="your-agent-id"
float={true}
popupButton={<ChatIcon size={24} color="white" />}
/>
Reference Implementation

The parlant-chat-react widget is open source! You can examine its implementation on GitHub as a reference for creating custom widgets in other UI frameworks like Vue, Angular, or vanilla JavaScript.

Building a Custom Frontend​

If you need more control than the React widget provides, or you're building in a different framework, you can work directly with Parlant's event-based protocol. This section covers the principles that apply regardless of your tech stack.

Agent-friendly docs

This page is written to be easily consumed by coding agents (Claude, Cursor, Copilot, etc.). We recommend pointing your coding agent at this page as a starting point when building a custom Parlant frontend.

How Parlant Delivers Conversations​

Parlant conversations aren't request-response. They're event streams.

When a user sends a message, the agent doesn't return a reply directly. Instead, it emits a sequence of events into the session — status updates as it thinks, tool calls as it gathers information, and finally a message event with the response. Your frontend's job is to listen for these events and render them.

Every event carries:

FieldPurpose
kindWhat type of event: message, status, tool, or custom
sourceWho created it: customer, ai_agent, human_agent, or system
offsetSequential position in the session (0, 1, 2, ...)
trace_idGroups related events from the same processing turn
dataThe event's payload (shape varies by kind)

The offset is your position tracker. Every event gets a sequential number, and you can resume from any point by requesting events starting at a given offset. Reconnect after a dropped connection? Just pass the last offset you saw — you won't miss anything, and you won't see duplicates.

What a Typical Turn Looks Like​

Here's the full sequence of events your frontend will see for a single user message, from start to finish:

1. acknowledged                        — Agent received the message
2. typing — Composing preamble
3. message (block, source: ai_agent) — Preamble: "Let me check that"
4. ready (no stage) — Preamble done, still processing
5. processing (stage: Interpreting) — Matching guidelines, calling tools
6. typing — About to generate response
7. message (streaming, source: ai_agent) — Main response, chunks arriving
8. ready (no stage) — Message delivered, wrapping up
9. ready (stage: completed) — Truly done, agent is idle

A few things to note: your own customer message also appears in the event stream (as source: "customer"), so filter by source when rendering agent responses. The preamble always arrives as a block message — even on streaming agents — because it's a short acknowledgment, not a generated response. And there can be multiple ready events without a stage during a turn; only ready with stage: "completed" means the agent is fully done.

Setting Up​

Initialize the client and create a session to start a conversation:

import { ParlantClient } from "parlant-client";

const client = new ParlantClient({ environment: "http://localhost:8800" });

// Create a session tied to your agent
const session = await client.sessions.create({
agentId: "your-agent-id",
customerId: "customer-123", // optional — omit for guest sessions
});

// Send a customer message
await client.sessions.createEvent(session.id, {
kind: "message",
source: "customer",
message: "Hi, I need help with my order",
});

That's all it takes to trigger the agent. It will start processing and emit events into the session. Now you need to listen for them.

github Need help? Reach out

Listening for Events​

Parlant supports two ways to receive events in real time: Server-Sent Events (SSE) and long polling. Both use the same endpoint — the difference is whether you hold a persistent connection or poll repeatedly.

SSE gives you a persistent connection that pushes events as they happen. Open an EventSource to the session's event endpoint:

let lastOffset = 0;

function connect(sessionId: string) {
const url = `http://localhost:8800/sessions/${sessionId}/events`
+ `?sse=true&min_offset=${lastOffset}&kinds=message,status`;

const source = new EventSource(url);

source.onmessage = (e) => {
const event = JSON.parse(e.data);
lastOffset = Math.max(lastOffset, event.offset + 1);
handleEvent(event);
};

source.onerror = () => {
source.close();
setTimeout(() => connect(sessionId), 1000);
};
}

The kinds parameter filters to just the event types you care about — typically message and status. The min_offset ensures you resume exactly where you left off after any reconnection.

tip

SSE connections close after a period of inactivity (default: 60 seconds, controlled by wait_for_data). This is expected — just reconnect with your current lastOffset. The offset system guarantees you won't miss anything.

Long Polling​

If SSE isn't practical in your environment, you can poll with a long timeout:

let lastOffset = 0;

async function poll(sessionId: string) {
while (true) {
try {
const events = await client.sessions.listEvents(sessionId, {
minOffset: lastOffset,
waitForData: 30, // Server holds the request up to 30 seconds
kinds: "message,status",
});

for (const event of events) {
lastOffset = Math.max(lastOffset, event.offset + 1);
handleEvent(event);
}
} catch {
// 504 = timeout with no new events — just keep polling
await new Promise((r) => setTimeout(r, 1000));
}
}
}

The waitForData parameter makes this efficient: the server holds the connection open until events arrive or the timeout expires, avoiding rapid-fire polling.

info

The server returns as soon as any matching events arrive — not when the agent is "done." Your first poll might return just an acknowledged status. This is why the while (true) loop is essential: you keep polling until you've received everything you need (typically until you see ready with stage: "completed").

Handling Messages​

When you receive a message event from the agent, the first thing to check is whether it's a block message (delivered all at once) or a streaming message (delivered incrementally as the agent generates it).

The rule is simple: if the event's data has a chunks field, it's streaming. Otherwise, it's a block message.

Block Messages​

Block messages arrive complete. Read data.message and render it:

function handleEvent(event) {
if (event.kind === "message" && event.source === "ai_agent") {
if (event.data.chunks === undefined) {
// Block message — full text is ready
renderMessage(event.id, event.data.message);
}
}
}

This is the default behavior. Agents deliver block messages unless explicitly configured for streaming output mode.

tip

Even on streaming agents, preambles always arrive as block messages (no chunks field). A preamble is the quick acknowledgment the agent sends while it's still processing — like "Let me check that for you." Your frontend should handle both block and streaming messages regardless of the agent's output mode.

Streaming Messages​

When an agent is configured with output_mode=STREAM, its message events arrive with a chunks field — a cumulative array of text segments that grows over time:

First update:   chunks: ["Our product"]
Second update: chunks: ["Our product", " is designed"]
Third update: chunks: ["Our product", " is designed", " for teams"]
Final update: chunks: ["Our product", " is designed", " for teams", null]

Two things to note: each update contains all previous chunks plus the new one (not just the delta), and a null at the end signals that the stream is complete. The data.message field is also kept in sync — it always contains the full accumulated text so far.

To follow these updates in real time, you need a per-message SSE connection — a second, dedicated stream that watches a single event for changes:

function handleEvent(event) {
if (event.kind === "message" && event.source === "ai_agent") {
if (event.data.chunks !== undefined) {
followStream(event);
} else {
renderMessage(event.id, event.data.message);
}
}
}

function followStream(message) {
const url = `http://localhost:8800/sessions/${sessionId}/events/${message.id}?sse=true`;
const source = new EventSource(url);

source.onmessage = (e) => {
const updated = JSON.parse(e.data);

// Render the accumulated text so far
updateMessage(message.id, updated.data.message);

// null at end of chunks = this message's stream is done
const chunks = updated.data.chunks;
if (chunks.length > 0 && chunks[chunks.length - 1] === null) {
isStreaming = false;
source.close();
}
};

source.onerror = () => source.close();
}

This creates a two-tier pattern: the session-wide SSE tells you that a streaming message exists; the per-message SSE tells you what's in it as chunks arrive.

Why two connections?

The session-wide stream notifies you about new events — new messages, new status updates. But when a streaming message gets more chunks, that's the same event being updated, not a new event being created. The session-wide stream won't re-send it. The per-message SSE specifically watches for updates to that one event.

Without SSE

If you're using long polling, you can still get the complete streamed message — just without the incremental experience. Fetch the event with waitForCompletion:

const complete = await fetch(
`http://localhost:8800/sessions/${sessionId}/events/${eventId}`
+ `?wait_for_completion=true&wait_for_data=60`
).then(r => r.json());

// complete.data.message contains the full text

The server waits until the stream finishes, then returns the final event with all chunks.

Status Events​

While the agent processes a message, it emits status events that your frontend can use to show activity indicators:

StatusMeaningTypical UI
acknowledgedAgent received the message— (no UI needed)
processingMatching guidelines, calling toolsShow data.stage value (e.g. "Interpreting...", "Thinking...")
typingComposing text (before first token)"Typing..." indicator
readySee belowSee below
errorSomething went wrongError message

The processing event's data.stage field tells you what the agent is doing — values like "Interpreting" or "Thinking". Display this directly rather than a generic label.

Place the indicator inside your message area, styled like an agent message bubble (with animated dots or a spinner). A status bar outside the chat flow is easy for users to miss.

let isStreaming = false;

function handleEvent(event) {
// When streaming chunks start arriving, suppress the typing indicator
if (event.kind === "message" && event.source === "ai_agent" && event.data.chunks !== undefined) {
isStreaming = true;
hideIndicator();
followStream(event);
return;
}

if (event.kind === "status") {
const { status, data } = event.data;

if (status === "processing") {
showIndicator(data?.stage || "Thinking");
} else if (status === "typing") {
// Don't show "Typing..." if streaming text is already visible
if (!isStreaming) {
showIndicator("Typing");
}
} else if (status === "ready") {
// Nothing is happening right now — hide indicator
hideIndicator();
// stage === "completed" means no more messages for this turn
} else if (status === "error") {
showError(data?.exception);
}
}

// ... other message handling
}

The key detail: once streaming chunks start arriving, the streamed text itself acts as the activity indicator. Suppress "Typing..." at that point — but keep showing processing indicators, since the agent may still be running tools while streaming begins.

The Ready Event: Temporary vs. Complete​

Not all ready events mean the same thing. Parlant emits ready at two different moments:

Temporary ready — The agent emits ready with no stage at various points during a turn — after delivering a preamble, after finishing a message, and at other internal boundaries. You may see several of these in a single turn. They mean "nothing is happening right now" — hide your indicator — but the agent isn't done yet. More events will follow.

Completion ready — When the agent is truly finished processing, it emits ready with data.stage = "completed". This means no more messages are expected for the current turn — the agent is idle and waiting for new input.

if (status === "ready") {
// Nothing is happening right now — hide indicator
hideIndicator();

if (data?.stage === "completed") {
// No more messages for this turn — safe to re-enable input
}
}
warning

Don't use status === "ready" alone to detect that the agent is finished. Without checking for stage === "completed", you might re-enable input or stop listening too early — right after the preamble, before the actual response arrives. But you should hide your indicator on every ready event, since nothing is actively happening at that moment.

A note on status event structure

Status events nest their details one level deep: event.data contains { status, data }, where the inner data holds the stage and other metadata. So the stage lives at event.data.data.stage. The code examples above use destructuring (const { status, data } = event.data) to flatten this — if you're accessing it directly, keep the double .data.data in mind.

The completion ready event also includes matched_guidelines, matched_journeys, and matched_journey_states in its data, which can be useful during development for understanding what the agent matched.

API Reference​

Below is every endpoint and SDK method you'll use when building a frontend. The REST paths are relative to your Parlant server URL (e.g. http://localhost:8800).

Sessions​

Create Session​

Creates a new conversation between an agent and a customer. If no customer_id is provided, Parlant creates a guest customer automatically.

RESTPOST /sessions
Pythonclient.sessions.create(agent_id, ...)
TypeScriptclient.sessions.create({ agentId, ... })
ParameterRequiredDescription
agent_idYesThe agent that will handle this conversation
customer_idNoAssociate with a known customer. Omit for guest sessions
allow_greetingNoIf true, the agent may send a message when the session is created (if its guideline prompt it to do so)
titleNoA descriptive label for the session (max 200 characters)
metadataNoArbitrary key-value data attached to the session
labelsNoString tags for filtering sessions later

Returns a Session object with id, agent_id, customer_id, creation_utc, and other fields.

Retrieve Session​

Fetches the current state of a session.

RESTGET /sessions/{session_id}
Pythonclient.sessions.retrieve(session_id)
TypeScriptclient.sessions.retrieve(sessionId)
Update Session​

Modifies session attributes. Only the fields you provide are changed; everything else stays the same.

RESTPATCH /sessions/{session_id}
Pythonclient.sessions.update(session_id, ...)
TypeScriptclient.sessions.update(sessionId, { ... })
ParameterDescription
agent_idTransfer the conversation to a different agent
customer_idReassign to a different customer
titleChange the session title
mode"auto" (agent responds automatically) or "manual" (agent stays silent until explicitly triggered)
consumption_offsetsTrack the last event offset your client has processed
metadataSet or unset metadata keys
labelsAdd or remove labels
Delete Session​

Permanently deletes a session and all its events.

RESTDELETE /sessions/{session_id}
Pythonclient.sessions.delete(session_id)
TypeScriptclient.sessions.delete(sessionId)
List Sessions​

Lists sessions with optional filtering and pagination.

RESTGET /sessions
Pythonclient.sessions.list(...)
TypeScriptclient.sessions.list({ ... })
ParameterDescription
agent_idFilter to sessions for a specific agent
customer_idFilter to sessions for a specific customer
labelsFilter by labels
limitPage size (1-100). Enables cursor-based pagination
cursorPagination cursor from a previous response
sort"asc" or "desc"

Events​

Create Event​

Adds a new event to the session. For frontends, this is how you send customer messages.

RESTPOST /sessions/{session_id}/events
Pythonclient.sessions.create_event(session_id, kind, source, ...)
TypeScriptclient.sessions.createEvent(sessionId, { kind, source, ... })
ParameterRequiredDescription
kindYes"message", "status", "tool", or "custom"
sourceYesWho is creating this event: "customer", "human_agent", "ai_agent", or "system"
messageFor messagesThe message text
dataFor custom/statusArbitrary JSON payload
moderationNoContent safety check: "auto", "paranoid", or "none" (default)
metadataNoArbitrary key-value data attached to the event

When you create a message event with source: "customer", the agent picks it up automatically (in auto mode) and begins processing.

List Events​

The primary endpoint for receiving events. Supports both long polling and SSE.

RESTGET /sessions/{session_id}/events
Pythonclient.sessions.list_events(session_id, ...)
TypeScriptclient.sessions.listEvents(sessionId, { ... })
ParameterDefaultDescription
min_offsetNoneOnly return events with offset >= N. Use this to resume from where you left off
kindsAllComma-separated filter: "message,status", "message,tool", etc.
sourceAllFilter by event source
trace_idNoneFilter to events from a specific processing turn
wait_for_data60Seconds to wait for new events before closing. Set to 0 for an immediate snapshot
ssefalseIf true, returns a text/event-stream instead of JSON. Events are pushed as data: {JSON}\n\n

Long polling (sse=false): returns events matching the filters. If none exist yet and wait_for_data > 0, the server holds the connection open until events arrive or the timeout expires (504).

SSE (sse=true): maintains a persistent connection, pushing events as they occur. Closes after wait_for_data seconds of inactivity.

Read Event​

Fetches a single event by ID. Essential for following streaming messages.

RESTGET /sessions/{session_id}/events/{event_id}
Pythonclient.sessions.read_event(session_id, event_id, ...)
TypeScriptclient.sessions.readEvent(sessionId, eventId, { ... })
ParameterDefaultDescription
wait_for_completionfalseWait until the event is complete (for streaming: until chunks ends with null)
wait_for_data60Timeout in seconds for wait_for_completion
ssefalseIf true, streams the event via SSE each time it's updated. Closes on completion

This is the endpoint that powers per-message streaming. With sse=true, the server pushes the full event (with its latest chunks) every time a new chunk arrives.

Delete Events​

Deletes all events in a session starting from a given offset. Used for features like "regenerate response" — delete from the customer message's offset onward, then re-send it.

RESTDELETE /sessions/{session_id}/events?min_offset=N
Pythonclient.sessions.delete_events(session_id, min_offset=N)
TypeScriptclient.sessions.deleteEvents(sessionId, { minOffset: N })
ParameterRequiredDescription
min_offsetYesDelete all events with offset >= N
warning

This operation is permanent and cannot be undone.

Update Event​

Updates an event's metadata. Other event properties (message text, kind, source) cannot be changed after creation.

RESTPATCH /sessions/{session_id}/events/{event_id}
Pythonclient.sessions.update_event(session_id, event_id, metadata=...)
TypeScriptclient.sessions.updateEvent(sessionId, eventId, { metadata: ... })

Bonus Sections​

Animating Streaming Text​

Once you have streaming working, you'll notice that naively replacing the message text on every SSE update looks mechanical — text just appears. The best chat UIs animate each new word as it arrives with a subtle fade-in, making the experience feel smooth. Here's how to do it well.

The CSS​

A short blur-in with a slight rise. Keep the duration short (250ms) so it doesn't lag behind fast streaming:

@keyframes wordIn {
from { opacity: 0; filter: blur(2px); transform: translateY(3px); }
to { opacity: 1; filter: blur(0); transform: translateY(0); }
}

.message.agent span.word {
display: inline-block; /* required for transform to work */
opacity: 0; /* invisible until animation starts */
animation: wordIn 0.25s ease-out both;
/* "both" fill mode applies the from-state immediately
and holds the to-state after completion */
}
warning

Use display: inline-block, not inline. CSS transforms don't apply to inline elements — your translateY would silently do nothing.

Diff, don't rebuild​

The key mistake is clearing the message element and rebuilding all the text on every SSE update. This destroys in-flight animations before they complete. Instead, track how much text you've already rendered and only append the new part:

let streamedLength = 0;

function updateMessage(id: string, text: string, streaming: boolean) {
const el = getOrCreateMessageEl(id);
const newText = text.slice(streamedLength);

if (newText) {
// Split into words and whitespace, preserving spacing
const tokens = newText.match(/\S+|\s+/g) || [];
for (const token of tokens) {
if (/^\s+$/.test(token)) {
el.appendChild(document.createTextNode(token));
} else {
const span = document.createElement("span");
span.className = "word";
span.textContent = token;
el.appendChild(span);
}
}
streamedLength = text.length;
}

if (!streaming) {
// Stream complete — replace spans with clean text
el.textContent = text;
streamedLength = 0;
}
}

Reset streamedLength to 0 when a new streaming message begins.

Smooth stagger across batches​

The above creates animated spans, but every word in a batch starts its animation at the same time — so a chunk of 20 words fades in all at once. You need staggered animation-delay values.

The naive approach is to number words within each batch (delay = index * 40ms). This breaks when SSE events arrive in bursts: each batch independently cascades instead of one smooth flow. It looks like BitTorrent — words popping in from random starting points.

The fix: maintain a global timeline so each new word schedules its animation relative to the previous word, across batches:

let nextWordTime = 0;
const WORD_INTERVAL = 40; // ms between each word

function updateMessage(id: string, text: string, streaming: boolean) {
const el = getOrCreateMessageEl(id);
const newText = text.slice(streamedLength);

if (newText) {
const now = performance.now();
const tokens = newText.match(/\S+|\s+/g) || [];
let lastScheduled = nextWordTime;

for (const token of tokens) {
if (/^\s+$/.test(token)) {
el.appendChild(document.createTextNode(token));
} else {
const span = document.createElement("span");
span.className = "word";
// Schedule relative to the global timeline
const targetTime = Math.max(lastScheduled, now);
span.style.animationDelay = `${Math.max(0, targetTime - now)}ms`;
span.textContent = token;
el.appendChild(span);
lastScheduled = targetTime + WORD_INTERVAL;
}
}
nextWordTime = lastScheduled;
streamedLength = text.length;
}

if (!streaming) {
// Clean up spans after the last animation finishes
const cleanupDelay = Math.max(0, nextWordTime - performance.now()) + 300;
setTimeout(() => { el.textContent = text; }, cleanupDelay);
streamedLength = 0;
nextWordTime = 0;
}
}

This gives you one continuous cascade regardless of how chunks are batched by the network. If chunks arrive faster than the animation interval, words queue up smoothly. If there's a gap, the next word starts immediately and the cascade continues from there.

Don't render initial text in followStream

When the session SSE fires the streaming message event, event.data.message may already contain text. It's tempting to render it immediately — but then there's a visible gap while the per-message SSE connection establishes, and the next batch of words creates a jarring jump. Instead, just create the empty message bubble in followStream and let the per-message SSE deliver all words from the start. This produces a single smooth cascade with no stall in the middle.

Summary: Key Principles​

  1. Events, not request-response. Send a message, then listen for events. Don't expect a synchronous reply.

  2. Track offsets for reliability. Always reconnect with the last offset you processed. The offset system is your guarantee against missed or duplicate events.

  3. Check chunks per message, not per agent. Even on a streaming agent, some messages (like canned responses) arrive as blocks. Check the chunks field on each message individually.

  4. Two-tier SSE for streaming. Session-wide SSE discovers new events; per-message SSE follows chunk updates. You need both to render streaming text incrementally.

  5. Hide on ready, finish on completed. Every ready event means nothing is happening right now — hide your indicator. But only ready with stage: "completed" means no more messages are expected for this turn. Streaming completion is separate — check for a null terminator in the chunks array.

github Need help? Reach out