Noodle Seed
Guides

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:

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 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 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.

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.

On this page