Bring your own UI
Use the Noodle assistant backend with React components and styles you own.
Noodle owns session exchange, streaming, tool execution, and interaction continuations. Your application owns the chat layout, components, and styles. Complete the backend session setup in Embed an assistant in your SaaS first.
Use the React bridge
"use client";
import type { AssistantInteractionResponse } from "@noodleseed/assistant/client";
import { useNoodleAssistant } from "@noodleseed/assistant/react/client";
import { YourChatUI } from "./your-chat-ui";
export function CustomAssistant({ principalKey }: { principalKey: string }) {
const { client, messages, status, error } = useNoodleAssistant({
sessionEndpoint: "/api/assistant/session",
principalKey,
});
const respond = (id: string, response: AssistantInteractionResponse) => {
void client.respond(id, response).catch(() => {
// The same structured failure is exposed through `error`.
});
};
return (
<YourChatUI
messages={messages}
status={status}
error={error}
onSend={(text) => void client.sendMessage(text).catch(() => {})}
onStop={() => client.abort()}
onRespond={respond}
/>
);
}The hook renders no Noodle markup. messages is readonly AssistantUIMessage[], Noodle's typed
specialization of the AI SDK UIMessage. client is the command surface for sending, stopping, and
responding. Change principalKey when the signed-in user or tenant changes; the hook then clears the old
in-memory session and transcript.
Render Noodle message parts
Noodle adds four typed data parts to the AI SDK message:
| Part | Noodle data type | What your UI should render |
|---|---|---|
text | AI SDK text part | Streaming assistant text from part.text. |
data-confirmation | AssistantConfirmationData | The complete proposed action and accept/decline controls. |
data-input-request | AssistantInputRequestData | A form built from requestedSchema. |
data-tool-result | AssistantToolResultData | Structured output in a generic or tool-specific component. |
data-view | AssistantViewData | The supported MCP App host, or a deliberate trusted native component selected by resourceUri/tool. |
These types, AssistantUIMessage, and AssistantInteractionResponse are public exports from
@noodleseed/assistant/client. Use them so TypeScript narrows part.data for each case:
import type { ReactNode } from "react";
import type {
AssistantClient,
AssistantInteractionResponse,
AssistantJsonValue,
AssistantUIMessage,
} from "@noodleseed/assistant/client";
import { NoodleAppView } from "@noodleseed/assistant/react";
type MessagePart = AssistantUIMessage["parts"][number];
type Respond = (id: string, response: AssistantInteractionResponse) => void;
export function renderPart(
part: MessagePart,
respond: Respond,
client: AssistantClient,
): ReactNode {
switch (part.type) {
case "text":
return <p>{part.text}</p>;
case "data-confirmation": {
const review = part.data;
const disabled = review.status !== "pending";
return (
<section>
<h3>{review.title ?? "Review proposed action"}</h3>
{review.description ? <p>{review.description}</p> : null}
<pre>{JSON.stringify(review.arguments ?? {}, null, 2)}</pre>
<button disabled={disabled} onClick={() => respond(review.id, { action: "accept" })}>
Confirm
</button>
<button disabled={disabled} onClick={() => respond(review.id, { action: "decline" })}>
Don't proceed
</button>
</section>
);
}
case "data-input-request": {
const request = part.data;
return (
<SchemaForm
disabled={request.status !== "pending"}
schema={request.requestedSchema}
onSubmit={(content: AssistantJsonValue) =>
respond(request.id, { action: "accept", content })
}
onCancel={() => respond(request.id, { action: "cancel" })}
/>
);
}
case "data-tool-result":
return <ToolResult tool={part.data.tool} result={part.data.result} />;
case "data-view":
return <NoodleAppView client={client} view={part.data} />;
default:
return <p>Unsupported assistant content.</p>;
}
}SchemaForm and ToolResult are application components. Keep the unknown-part fallback so a future message
type does not disappear silently.
Render tools and linked views
data-tool-result contains { id, tool, result }. The result is JSON data, not executable UI. Render a
generic escaped representation or validate it and select a component by tool name:
function ToolResult({ tool, result }: { tool: string; result: AssistantJsonValue }) {
if (tool === "lookup_order") {
const order = parseOrderResult(result);
return <OrderCard order={order} />;
}
return <pre>{JSON.stringify(result, null, 2)}</pre>;
}data-view means that the completed tool also has a linked MCP App view. To render that App inside a
customer-owned React transcript, use NoodleAppView; do not recreate its iframe or AppBridge. The component
uses the service-advertised sandbox URL, routes App tool/resource/message/model-context operations through
the supplied client, and keeps undeclared links closed. Include the Noodle service origin in both
connect-src and frame-src when your page sends a Content-Security-Policy.
The component holds one bridge for the semantic view identity: client + view.id + view.resourceUri.
Fresh view objects, payloads, or callbacks from a parent rerender do not replace the iframe. Only a different
semantic view or component unmount performs standard ui/resource-teardown and closes the bridge. Do not key
an ancestor by the whole view or a callback.
If the product intentionally substitutes a native application component instead of rendering the linked
App, map resourceUri to a component already trusted by your application and pass only the bounded,
redacted result:
function RegisteredView({
resourceUri,
result,
}: {
resourceUri: string;
result: AssistantJsonValue;
}) {
if (resourceUri === "ui://orders/order_card") {
return <OrderCard order={parseOrderResult(result)} />;
}
return <p>This view is not supported here.</p>;
}A linked tool may emit both data-tool-result and data-view with the same id. If you have a registered
view, collapse the generic result so the user does not see two cards. The JSON result is data, not the
linked App UI. Never fetch a ui:// URI, inject part.data.html, assign it to srcdoc, or add
@modelcontextprotocol/ext-apps to reproduce the bridge. NoodleAppView is the supported sandbox boundary.
Handle interactions and errors
- Only
pendinginteractions are actionable. Disable controls duringsubmitting; terminal states areaccepted,declined, andcancelled. - Confirmations review server-held arguments. Clients send only the interaction id and decision; they cannot replace tool arguments.
- Input requests accept
{ action: "accept", content }, wherecontentmatchesrequestedSchema. Invalid input returnsarg_invalidand leaves the request pending for correction. - Await or catch every
sendMessageandrespondpromise. Chat status isready,submitted,streaming, orerror;error.detailmay includecode,status, andretryable. - Interaction continuations add another assistant message. Do not invent a matching user message.
Without React
Use the DOM-free client directly:
import { createAssistantClient } from "@noodleseed/assistant/client";
const assistant = createAssistantClient({
sessionEndpoint: "/api/assistant/session",
});
const unsubscribe = assistant.subscribeChat((state) => renderChat(state));
await assistant.sendMessage("How can you help?");
// On logout, tenant change, or teardown:
unsubscribe();
assistant.abort();
assistant.resetSession();Embed an assistant with Django and Vue
Serve a static Vue assistant through a Django-owned session exchange without adding a Node.js production runtime or exposing backend credentials.
Customer auth for remote MCP clients
Connect one customer-protected MCP server to standards-based clients with OAuth discovery, DCR, PKCE, refresh tokens, and resource-bound access tokens.