Custom Chat UI Guide¶
Use this guide when you want to build your own chat surface on top of Orcheo instead of embedding the stock ChatKit widget. The integration pattern uses the same raw /api/chatkit contract that Orcheo's hosted ChatKit surfaces use underneath.
When to use this guide¶
- You are building a native-feeling chat UI in React, React Native, WeChat Mini Program, Flutter, Swift, or another frontend stack.
- You want Orcheo to manage workflow execution, thread history, and assistant responses, but you want full control over rendering.
- You do not want to ship
<openai-chatkit>or the hosted ChatKit web bundle.
If you want the stock ChatKit UI instead, use Webpage Embedding or Canvas Chat Bubble.
Integration model¶
Your UI owns the presentation layer:
- local message list
- composer state
- typing/loading indicators
- scroll behavior
- mobile layout quirks
Orcheo owns the conversation backend:
- thread creation and storage
- workflow dispatch
- assistant response generation
- SSE event stream
- optional attachment storage
The core flow is:
- Keep a local
threadIdin UI state. - On the first user turn,
POST /api/chatkitwithtype: "threads.create". - On later turns,
POST /api/chatkitwithtype: "threads.add_user_message"and the existingthread_id. - Parse the response, save the returned thread ID, and update the last assistant message from SSE events.
Authentication options¶
Pick one of these before wiring the UI:
| Mode | When to use it | What the client sends |
|---|---|---|
| Published workflow | Public or semi-public chat surface | workflow_id in the JSON payload |
| Published + login required | Same-origin web UI with Orcheo OAuth cookies | workflow_id plus browser cookies |
| Session JWT | Private or third-party custom UI | Authorization: Bearer <client_secret> plus workflow_id |
Notes:
/api/chatkitalways requires a top-levelworkflow_id.- For published access, Orcheo checks that the workflow is public and optionally that an OAuth session exists.
- For JWT access, mint a short-lived token through
POST /api/chatkit/sessionon your server side. See the JWT section in Webpage Embedding. - The workflow-scoped endpoint
POST /api/workflows/{workflow_id}/chatkit/sessionis mainly for authenticated first-party Canvas flows.
Request shape¶
Custom implementations send ChatKit-style request bodies directly to /api/chatkit.
First turn: create a thread¶
{
"type": "threads.create",
"params": {
"input": {
"content": [
{
"type": "input_text",
"text": "Hello"
}
],
"attachments": [],
"quoted_text": null,
"inference_options": {}
}
},
"metadata": {
"workflow_id": "workflow_uuid",
"workflow_name": "Orcheo Bot"
},
"workflow_id": "workflow_uuid"
}
Later turns: append to the existing thread¶
{
"type": "threads.add_user_message",
"params": {
"thread_id": "thr_123",
"input": {
"content": [
{
"type": "input_text",
"text": "Can you summarize that?"
}
],
"attachments": [],
"quoted_text": null,
"inference_options": {}
}
},
"metadata": {
"workflow_id": "workflow_uuid",
"workflow_name": "Orcheo Bot"
},
"workflow_id": "workflow_uuid"
}
Recommendations:
- Keep
workflow_idat the top level. Orcheo validates that field before processing the request. - Reuse the same
thread_idfor the conversation. Do not replay your full message history on every turn. - Keep
attachments,quoted_text, andinference_optionsin the payload shape even if they are empty. It makes upgrades easier. - Include
workflow_namein metadata for easier debugging and thread inspection.
Minimal client implementation¶
This TypeScript helper shows the smallest useful client abstraction for a custom UI: build the request payload, post it to /api/chatkit, then parse the returned events. The code stays UI-framework-agnostic so you can drop it into React, React Native, Flutter, or another frontend stack.
type ChatRole = "user" | "assistant";
type ChatMessage = {
id: string;
role: ChatRole;
content: string;
};
type ChatConfig = {
baseUrl: string;
workflowId: string;
workflowName: string;
bearerToken?: string;
};
function buildUserInput(text: string) {
return {
content: [{ type: "input_text", text }],
attachments: [],
quoted_text: null,
inference_options: {},
};
}
function buildChatKitPayload(
config: ChatConfig,
userMessage: string,
threadId?: string,
) {
const metadata = {
workflow_id: config.workflowId,
workflow_name: config.workflowName,
};
if (threadId) {
return {
type: "threads.add_user_message",
params: {
thread_id: threadId,
input: buildUserInput(userMessage),
},
metadata,
workflow_id: config.workflowId,
};
}
return {
type: "threads.create",
params: {
input: buildUserInput(userMessage),
},
metadata,
workflow_id: config.workflowId,
};
}
async function sendChatTurn(
config: ChatConfig,
userMessage: string,
threadId?: string,
) {
const response = await fetch(`${config.baseUrl}/api/chatkit`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(config.bearerToken
? { Authorization: `Bearer ${config.bearerToken}` }
: {}),
},
body: JSON.stringify(buildChatKitPayload(config, userMessage, threadId)),
});
const rawBody = await response.text();
if (!response.ok) {
throw new Error(rawBody || `Chat request failed with ${response.status}`);
}
return parseChatKitSse(rawBody, threadId);
}
Parsing the SSE response¶
/api/chatkit may return text/event-stream. If your runtime gives you the full response body at once, you can parse it as a string, split on lines, and extract only the event types your UI cares about.
These are the most important event types for a custom UI:
thread.created: saveevent.thread.idas your localthreadIdthread.item.updated: append streaming assistant text as it arrivesthread.item.done: capture the final assistant message content and useevent.item.thread_idas a fallback thread identifier
Example parser:
function parseChatKitSse(
data: string,
previousThreadId = "",
): {
threadId: string;
responseText: string;
} {
let threadId = previousThreadId;
let streamedText = "";
let finalText = "";
for (const line of data.split("\n")) {
if (!line.startsWith("data: ")) {
continue;
}
try {
const event = JSON.parse(line.slice(6));
if (event.type === "thread.created" && event.thread?.id) {
threadId = event.thread.id;
}
if (!threadId && event.item?.thread_id) {
threadId = event.item.thread_id;
}
if (
event.type === "thread.item.updated" &&
event.update?.type === "content_part_added" &&
event.update.part?.text
) {
streamedText += event.update.part.text;
}
if (
event.type === "thread.item.done" &&
event.item?.type === "assistant_message"
) {
let completedText = "";
for (const part of event.item.content ?? []) {
if (part.type === "output_text" && part.text) {
completedText += part.text;
}
}
if (completedText) {
finalText = completedText;
}
}
} catch {
// Ignore malformed or forward-incompatible event lines.
}
}
return { threadId, responseText: finalText || streamedText };
}
Implementation notes:
- Some environments buffer the whole SSE response before returning it. That still works; you just lose token-by-token rendering.
- In browsers that support streaming
fetch(), you can read the response incrementally and apply the same event parsing logic chunk by chunk. - Preserve the prior
threadIdwhen a follow-up turn does not emitthread.created. Some responses only expose the thread onevent.item.thread_id. - Ignore event types you do not recognize. Orcheo may emit progress or tool-related events that your UI does not need to render.
Suggested UI state model¶
A simple and reliable state update pattern is:
- Push the user message into local state immediately.
- Push an empty assistant placeholder message before the request starts.
- Disable the composer while the request is in flight.
- Replace the last assistant message as SSE text arrives or when the final event lands.
- Store the returned
threadIdfor the next turn.
That pattern avoids message duplication and makes retries straightforward.
Attachments¶
If your custom UI needs uploads, use POST /api/chatkit/upload with multipart form data:
- field name:
file - response: attachment metadata including
id,name,mime_type,type, andstorage_path
Attach the returned object to input.attachments in your next /api/chatkit request. Orcheo stores the file and lets downstream workflow nodes consume it later.
Troubleshooting¶
400 workflow_id is required.: include a top-levelworkflow_idin every/api/chatkitrequest.400 workflow_id must be a valid UUID.: use the workflow UUID, not the public/chat/...URL segment unless it is already the UUID.403 Publish authentication failed: workflow is not published.: publish the workflow first or switch to JWT-backed sessions.401 Publish authentication failed: OAuth login is required to access this workflow.: your workflow was published withrequire_login=true, but the UI is not sending the expected session cookies.401 ChatKit session token authentication failed: the bearer token is missing, expired, or for a different workflow.- Empty assistant replies: make sure your parser handles both
thread.item.updatedandthread.item.done. - Duplicated assistant text: do not blindly append both streaming deltas and the final message unless your parser deduplicates.
References¶
- Hosted ChatKit request wrapper:
apps/canvas/src/features/chatkit/lib/chatkit-client.ts - Hosted ChatKit widget example:
examples/chatkit-embedding.html - Publish flow: Workflow Publishing
- Hosted widget flow: Webpage Embedding
- Canvas-authenticated flow: Canvas Chat Bubble