Noodle Seed
Guides

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:

PartNoodle data typeWhat your UI should render
textAI SDK text partStreaming assistant text from part.text.
data-confirmationAssistantConfirmationDataThe complete proposed action and accept/decline controls.
data-input-requestAssistantInputRequestDataA form built from requestedSchema.
data-tool-resultAssistantToolResultDataStructured output in a generic or tool-specific component.
data-viewAssistantViewDataThe 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 pending interactions are actionable. Disable controls during submitting; terminal states are accepted, declined, and cancelled.
  • Confirmations review server-held arguments. Clients send only the interaction id and decision; they cannot replace tool arguments.
  • Input requests accept { action: "accept", content }, where content matches requestedSchema. Invalid input returns arg_invalid and leaves the request pending for correction.
  • Await or catch every sendMessage and respond promise. Chat status is ready, submitted, streaming, or error; error.detail may include code, status, and retryable.
  • 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();

On this page