Bring your own UI
Connect your own React, Vue, Angular or DOM interface to the Noodle Seed assistant backend for session exchange, streaming and tool interactions.
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 MCP Apps without React
NoodleAppView is a React adapter over the canonical framework-neutral <noodle-app-view> host. Vue,
Angular, and plain DOM applications import the dedicated entry, then pass the existing client and typed
data-view payload as object properties.
For Vue 3:
<script setup lang="ts">
import "@noodleseed/assistant/app-view";
import type { AssistantClient, AssistantViewData } from "@noodleseed/assistant/client";
defineProps<{
assistant: AssistantClient;
part: { type: "data-view"; data: AssistantViewData };
resolvedTheme: "light" | "dark";
}>();
</script>
<template>
<noodle-app-view
v-if="part.type === 'data-view'"
:client.prop="assistant"
:view.prop="part.data"
:theme="resolvedTheme"
@assistant-error="(event) => reportAssistantError(event.detail)"
/>
</template>Tell Vue's compiler that noodle-app-view is a custom element:
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === "noodle-app-view",
},
},
});Angular uses the same element with [client]="assistant", [view]="part.data", and
[theme]="resolvedTheme" after enabling its custom-elements schema. Plain DOM code assigns
element.client, element.view, and element.theme. client and view are object properties; never
serialize them into HTML attributes.
The App stays inline by default, even if its document requests fullscreen. This protects the surrounding
customer-owned conversation from stale or untrusted widget code. If fullscreen is a deliberate part of your
host experience, set the element's allowFullscreen property (or add allow-fullscreen) explicitly. React
hosts use <NoodleAppView allowFullscreen />. The App request remains host-mediated; never opt in only
because the App asks. If the host accepts fullscreen, it adds an accessible exit control in the top-right
corner that returns the same mounted App to inline mode without losing widget state.
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. Render it with
<noodle-app-view> or, in React, NoodleAppView; do not recreate its iframe or AppBridge. The host
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 element 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, element disconnect, or App teardown request 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. <noodle-app-view> is the supported sandbox
boundary; NoodleAppView delegates to it.
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.
Use the DOM-free client directly
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();This client path stays React-free. Import @noodleseed/assistant/app-view separately only when your renderer
needs the linked App host described above.
Embed an assistant with Django and Vue
Generate a tested Django session boundary and static Vue mount, preserving your existing login, CSRF protection, and package managers.
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.