Noodle Seed
Guides

Embed an assistant in your SaaS

Deploy a Noodle Seed assistant, exchange your existing signed-in user on the backend, and mount the published browser SDK without exposing credentials.

The embedded assistant puts the same tools from your Noodle Seed server inside your authenticated web application. Your application keeps its existing identity system. Its backend exchanges the verified user for a short-lived assistant session; the browser never receives a model key or assistant client secret.

1. Declare branding, presentation, origins, and model references

Add assistant: embeddedAssistant(...) to the same server.ts that owns your tools. This complete example keeps identity and colors in top-level branding, separate from the assistant's semantic presentation:

import {
  embeddedAssistant,
  openAICompatible,
  secret,
  server,
  tool,
  variable,
  z,
} from "@noodleseed/one";

export default server(
  "acme_support",
  {
    title: "Acme Support",
    version: "1.0.0",
    branding: {
      name: "Acme Assistant",
      accent: "#5B4CF0",
      surface: "#FFFFFF",
      surfaceDark: "#15131A",
      mark: { uri: "https://assets.example.com/acme-mark.svg", alt: "Acme" },
      colorScheme: "auto",
    },
    assistant: embeddedAssistant({
      model: openAICompatible({
        baseUrl: variable("ASSISTANT_MODEL_BASE_URL"),
        model: variable("ASSISTANT_MODEL"),
        apiKey: secret("ASSISTANT_MODEL_API_KEY"),
      }),
      allowedOrigins: ["http://localhost:3000", "https://app.example.com"],
      layout: {
        mode: "floating",
        position: "bottom-right",
        panelWidth: 520,
        panelMinHeight: 540,
        panelMaxHeight: 740,
        edgeOffset: 24,
      },
      behavior: { showTimestamps: true },
      labels: {
        welcomeHeading: "How can Acme help?",
        welcomeMessage: "Fast answers from the tools your team already uses.",
        composerPlaceholder: "Message Acme Support…",
        sessionReady: "Acme support is online",
      },
      presentation: {
        panel: {
          surface: "solid",
          elevation: "dramatic",
          border: "strong",
          radius: 20,
        },
        launcher: {
          icon: "chat",
          size: "lg",
          status: "session",
          effect: "pulse",
        },
        header: {
          mark: "status",
          badge: { text: "Support online", tone: "success", indicator: true },
        },
        composer: {
          leadingIcon: "brand-mark",
          sendIcon: "paper-plane",
          shape: "rounded",
        },
        messages: { userStyle: "accent", assistantStyle: "bubble" },
      },
    }),
  },
  [
    tool("support_status", {
      title: "Check support status",
      description: "Read the support service status.",
      input: z.object({}),
      output: z.object({ status: z.string() }),
      fulfil: () => ({ status: "operational" }),
    }),
  ],
);

Origins are exact. Do not include a path, trailing slash, or wildcard. Production origins must use HTTPS; plain HTTP is accepted only for loopback development origins such as http://localhost:3000 or http://127.0.0.1:4002, so you can exercise the real chat against your local dev server before you have HTTPS. noodle dev runs the MCP project; it does not serve the embedding web application.

Presentation is semantic and bounded

presentation selects renderer-owned primitives for five regions. The Atlas-style product treatment in the complete example is the supported ceiling:

  • panel: solid/glass surface, soft/dramatic elevation, subtle/strong border, and optional 0-64 pixel radius.
  • launcher: built-in brand-mark/chat/none icon, medium/large size, optional session status, and none/pulse effect.
  • header: built-in none/brand-mark/status mark and an optional semantic-tone status badge.
  • composer: built-in leading/send icons and rounded/pill shape.
  • messages: bubble/accent user treatment and plain/bubble assistant treatment.

The top-level branding option is the portable deployment source for the customer name, colors, themed logo/mark/avatar assets, typography, density, and color scheme. Semantic tones and accent treatments reuse that palette; presentation.panel.radius is only a bounded panel-specific geometry override. presentation has no raw HTML, CSS, inline SVG, class-name, or callback field; markup-looking strings stay text. An HTTPS or packaged SVG referenced as a branding asset is allowed because it is not injected as inline markup.

Omit presentation to keep the quiet premium default: solid panel, soft elevation, subtle border/motion, medium brand-mark launcher without status or effect, undecorated header, pill composer with no leading icon and an arrow-up send icon, and bubble user/plain assistant messages at comfortable width. A partial presentation overrides only the supplied fields.

Validate before using hosted services:

noodle validate --json
noodle check --target embedded-assistant --json

2. Configure the model and deploy

Model configuration belongs to the Noodle deployment, not your web application's environment:

noodle variables set ASSISTANT_MODEL_BASE_URL --scope env --org <org> --app <app> --env <env> --value <https-model-base-url>
noodle variables set ASSISTANT_MODEL --scope env --org <org> --app <app> --env <env> --value <model>
noodle secrets set ASSISTANT_MODEL_API_KEY --scope env --org <org> --app <app> --env <env> --from-env ASSISTANT_MODEL_API_KEY
noodle deploy --org <org> --app <app> --env <env>

Local MCP authoring does not require an account. The external browser embed does require an active assistant-enabled deployment because its backend client is deployment-bound.

Access modes and customer auth

The embedded assistant does not require a particular --access mode: session exchange is authenticated by your backend client credentials, independent of who may call the MCP endpoint. Add --access customers only when verified end customers should also reach the MCP endpoint directly. That mode requires server.auth in server.ts, and noodle deploy now enforces it before contacting the service:

import { customerAuth } from "@noodleseed/one";

// in server options:
auth: customerAuth.federatedOidc({
  issuers: [
    { issuer: "https://id.example.com", audience: "https://api.example.com" },
  ],
});
// or a built-in adapter such as customerAuth.firebase({ projectId, apiKey })

noodle check --target embedded-assistant reports whether the manifest is ready for a customers-access deploy.

3. Create the backend client

After deployment:

noodle assistant clients create --name web --org <org> --app <app> --env <env>

The CLI saves { clientId, clientSecret } to a mode-0600 file and prints only its path. Move those values to your backend secret manager without printing or committing them.

Your web backend needs only:

NOODLE_SERVICE_URL
NOODLE_ASSISTANT_CLIENT_ID
NOODLE_ASSISTANT_CLIENT_SECRET

Never expose the secret through a browser-prefixed environment variable.

Before the demo or launch, validate the complete boundary from the customer backend environment:

noodle assistant doctor --origin "$PUBLIC_APP_ORIGIN" --org <org> --app <app> --env <env>

The doctor verifies the active assistant deployment, backend client credential, exact allowlisted origin, and each delegated credential exchange without invoking a business tool. It reads the client secret from NOODLE_ASSISTANT_CLIENT_SECRET or the saved mode-0600 client file and never prints it. Pass --user-id <real-test-user> when a downstream exchange requires an existing application user.

4. Exchange your existing user

Install @noodleseed/assistant with the package manager already used by your web application. Create an authenticated, same-origin backend route:

import { createAssistantSession } from "@noodleseed/assistant/server";

export async function POST(request: Request) {
  const user = await requireCurrentUser(request);
  const { context } = await request.json();
  const session = await createAssistantSession({
    serviceUrl: process.env.NOODLE_SERVICE_URL!,
    clientId: process.env.NOODLE_ASSISTANT_CLIENT_ID!,
    clientSecret: process.env.NOODLE_ASSISTANT_CLIENT_SECRET!,
    origin: process.env.PUBLIC_APP_ORIGIN!,
    user: { id: user.id, email: user.email, roles: user.roles },
    context,
    // Saved, backend-verified choices outrank browser presentation hints.
    preferences: { locale: user.locale, timeZone: user.timeZone },
  });
  return Response.json(session);
}

Source origin from trusted server configuration, or compare the request origin against the exact allowlist before exchange. Page context is untrusted model context, not authorization input. Forward the helper result unchanged.

serviceUrl is the Noodle Seed control plane base URL (the value noodle assistant clients create prints, also stored as serviceUrl in deployment.json). It is not your deployment's MCP endpoint URL; the deployment url ends in /v1/mcp and rejects session exchange.

The session response

The exchange returns the versioned session contract that every published widget consumes:

{
  "token": "<short-lived session token>",
  "expiresAt": "2026-07-13T12:34:56.000Z",
  "endpoints": {
    "turns": "https://<service>/v1/assistant/turns",
    "toolConfirmations": "https://<service>/v1/assistant/tool-confirmations",
    "interactions": "https://<service>/v1/assistant/interactions"
  },
  "configuration": { "branding": {}, "assistant": {} }
}

token, expiresAt, and endpoints are always present; configuration optionally carries the resolved top-level branding and assistant-only UI data, and the widget renders Halo defaults without it. Forward the body unchanged and let the widget read it; do not rebuild or filter it.

Verified session context (identity and claims)

Pass the signed-in identity and any verified facts your backend owns at exchange time, and declare what the assistant may receive in server.ts:

// backend route
const session = await createAssistantSession({
  serviceUrl,
  clientId,
  clientSecret,
  origin,
  user: { id: user.id, email: user.email, name: user.name },
  claims: { accountTier: account.tier, region: account.region },
});
// server.ts
assistant: embeddedAssistant({
  model,
  allowedOrigins,
  sessionClaims: {
    accountTier: { exposeToModel: true }, // the assistant may use it directly
    region: {}, // tools only
  },
});

Tools read user.subject, user.name, user.email, user.locale, user.timeZone, and user.claims.<key>; the assistant automatically knows the signed-in user's name and any exposeToModel claims, so it greets the actual user. Undeclared claims are dropped at exchange. Keep untrusted page state in context; verified facts belong in claims.

Time, locale, and ambient application facts

The service adds a server-authoritative instant and user-local date/time to every assistant turn. Locale and IANA time zone resolve in this order:

  1. backend-verified preferences from session exchange;
  2. fresh per-turn browser clientContext hints;
  3. server.context.defaults;
  4. platform defaults (en-US and UTC).

Backend-verified preferences are also exposed to fulfilments and ambient providers as user.locale and user.timeZone. This gives embedded sessions and authenticated MCP callers one application-level place to attach a user's durable preferences; a browser hint remains per-turn presentation context and is never promoted into verified identity.

The ambient provider above is recorded as declarative fulfilment, may call read-only connector operations only, and has a validated output schema. The runtime resolves it once for the invocation. Tools read context.temporal, context.ambient, and context.ambientStatus; the assistant sees the same data. To give every host model-visible application context, mark one normal zero-input tool with contextProvider: true. Claude, ChatGPT, and other MCP hosts can call it normally; the embedded assistant preloads it once per turn. Core-v1 manifests retain the legacy server.context-activated noodle_context adapter, but canonical TypeScript authoring emits Core v2 and does not create or reserve that tool.

Ambient output is deliberately compact: serialized JSON is limited to 16 KiB, nesting depth 8, and 128 entries per object or array. Credential-shaped keys are rejected. Put large documents in resources and keep authorization decisions in policy rather than treating ambient context as a data dump.

Ask for structured missing input

Use ctx.elicit when a tool needs one specific value before it can continue:

tool("prepare_time_off", {
  description: "Resolve a time-off request before proposing the write.",
  input: z.object({ start: z.string(), end: z.string() }),
  output: z.object({ start: z.string(), end: z.string(), teamId: z.string() }),
  fulfil: ({ input, elicit }) => {
    const answer = elicit({
      id: "choose_team",
      message: "Which team should receive this request?",
      input: z.object({ teamId: z.string().describe("Team") }),
    });
    return { start: input.start, end: input.end, teamId: answer.teamId };
  },
});

The input must be a flat, non-credential form of primitive fields, choices, or supported date/contact formats. Noodle renders it as an input_requested form in the embedded assistant and maps it to standard MCP form elicitation on bidirectional MCP transports. On stateless hosts, a linked MCP App presents the same business-user form and resumes through standard tools/call; without Apps, the model receives the exact schema and can collect and retry those fields conversationally. The adapter safely replays only the compiler-guaranteed operation-free input prefix; runtime continuation and environment state remain private. Accept validates the structured answer and resumes after the step without rerunning completed steps; decline/cancel stop the flow. An invalid answer returns arg_invalid and leaves the same request pending for correction. Every interactive flow must collect all elicited input before its first connector operation. This gathers missing data but does not approve a later write—keep action confirmation separate. For a flow marked confirm: true, every eligible input request happens before tool_proposed; the final card reviews the original tool input, collected answers, and sole exact connector version/operation/resolved arguments. Accepting it is the first point at which that operation may run. If a flow asks again, the next input_requested has a fresh id; tool_completed arrives only after the final answer.

The same confirmation policy applies outside the embed. A capable bidirectional MCP client receives a final standard form-elicitation request and the connector runs only after both protocol acceptance and an explicit affirmative boolean. A transport that cannot elicit fails closed before execution. At the manifest/runtime boundary and in TypeScript action helpers, only { confirm: true } enables the gate; omitted or false preserves direct execution. annotations.action() still supplies standard action hints, but those hints alone never enforce approval. A confirmable flow may contain at most one connector operation. Policy and authorization still apply.

5. Mount the browser SDK

For React:

import { NoodleAssistant } from "@noodleseed/assistant/react";

<NoodleAssistant sessionEndpoint="/api/assistant/session" theme="auto" />;

For exact application-owned colors, pass the typed light/dark appearance prop. Each major region has surface, text, and border roles; exact values are preserved, and low contrast is reported rather than silently corrected:

<NoodleAssistant
  sessionEndpoint="/api/assistant/session"
  appearance={{
    light: {
      panel: { surface: "#FFFFFF", text: "#101828", border: "#E4E7EC" },
      composer: { surface: "#F9FAFB", text: "#101828", border: "#D0D5DD" },
      confirmation: { surface: "#F8FAFC", text: "#101828", border: "#CBD5E1" },
      primaryButton: { surface: "#635BFF", text: "#FFFFFF" },
    },
  }}
  onAppearanceWarning={(warning) => reportThemeWarning(warning)}
/>

For a framework-neutral application, import @noodleseed/assistant once and mount:

<noodle-assistant
  session-endpoint="/api/assistant/session"
  theme="auto"
></noodle-assistant>

Mount the assistant only inside the authenticated application surface.

For a customer-owned renderer, use the DOM-free client. It keeps the token in memory and consumes the exact same advertised endpoints and interaction event protocol as the built-in element, without registering a custom element:

import { createAssistantClient } from "@noodleseed/assistant/client";

const assistant = createAssistantClient({
  sessionEndpoint: "/api/assistant/session",
  clientContext: () => ({
    locale: navigator.language,
    timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  }),
});

// Reporting surface state does not start a model turn. The latest update replaces the prior one.
assistant.updateModelContext({
  content: [{ type: "text", text: "The time-off form is mounted." }],
  structuredContent: {
    widget: { name: "time-off-request-form", lifecycle: "mounted" },
  },
});

let pendingId: string | undefined;
let pendingSchema: unknown;
assistant.subscribe((event) => {
  renderAssistantEvent(event);
  if (event.event === "view_available") {
    renderRegisteredView(event.data.resourceUri, event.data.result);
  }
  if (
    (event.event === "tool_proposed" || event.event === "input_requested") &&
    typeof event.data.id === "string"
  ) {
    pendingId = event.data.id;
    pendingSchema =
      event.event === "input_requested"
        ? event.data.requestedSchema
        : undefined;
  }
});
await assistant.sendMessage("Book next Thursday and Friday off");

if (pendingId) {
  const resolution = pendingSchema
    ? {
        action: "accept" as const,
        content: await renderPortableForm(pendingSchema),
      }
    : { action: "accept" as const };
  await assistant.respond(pendingId, resolution);
}
// Either interaction also accepts { action: 'decline' } or { action: 'cancel' }.

tool_proposed.arguments is a complete, schema-aware review projection of the server-held tool input and any elicited values. For a connector-backed tool it also identifies the exact connector version, operation, and resolved arguments; confirmable flows contain at most that one connector operation. Sensitive/write-only fields are redacted; if any non-sensitive field cannot be presented without truncation or omission, the platform fails closed instead of offering an incomplete approval. Accept is bound to that server-held action, which is re-verified before one execution attempt; a client cannot replace it in the resolution request. Decline and cancel resolve without executing. Normal terminal outcomes scrub private arguments and continuations immediately. Only an accepted action still in executing retains them during a one-hour unknown-outcome recovery window; expiry records a bounded interaction_outcome_unknown result and scrubs the payload. Without downstream idempotency this is not an exactly-once business-effect guarantee. The client never auto-retries a decision; to reconcile a lost response, explicitly repeat the same id and decision and the service returns its durable stored outcome without re-execution. Messages may re-exchange and retry once after a pre-execution 401. clientContext is evaluated for each turn and is an untrusted presentation hint, never authorization. Optional tool_proposed.title, description, and reviewSchema fields let renderers present the same business-readable confirmation. Author the tool title and use Zod .meta({ title, description, format }) for fields; the standard card shows Confirm and Don't proceed, formats nested values without JSON, and keeps connector mechanics collapsed under Additional details. Programmatic renderers still support cancel.

When a completed tool has an MCP App link, view_available provides a typed ui:// identity and the same bounded, schema-redacted public result the assistant may use. It says a view is available; it does not say the view rendered. Map resourceUri or tool to a component registered in your own application. Do not fetch() the ui:// URI, inject its HTML into the page, or treat widget-only metadata as renderer input. The standard element hosts the compiled MCP App in its sandboxed Apps bridge and also dispatches the same detail so an application may replace that presentation:

assistantElement.addEventListener("assistant-view-available", (event) => {
  const { resourceUri, result } = event.detail;
  renderRegisteredView(resourceUri, result);
});

updateModelContext({ content, structuredContent }) is also available on <noodle-assistant>. Publish one compact, cohesive author-selected snapshot of everything the model should currently know about the surface, such as its lifecycle and safe form summary. Each call replaces the prior snapshot rather than merging with it, so include every still-relevant field when the surface changes. It performs no network request by itself. Each later sendMessage carries the latest value as untrusted per-turn data, and the service does not retain it in conversation history. Both client and service reject credential-shaped data, values over 16 KiB, nesting deeper than 8, and containers over 128 entries. Keep private fields private and use backend data or policy for authorization.

Inside a React MCP App, useWidgetLifecycle(name) uses that same model-context channel. Calling the hook automatically publishes mounted, then host cancellation and teardown publish cancelled and dismissed; use the returned function for submitted or app-specific milestones. Each lifecycle publication is also a complete replacement snapshot. mounted means the widget code mounted, not that the host displayed pixels.

NoodleAssistant renders a custom element and must mount client-side. In a Next.js App Router page, mark the wrapper component with 'use client'; from a server component or the Pages Router, load it with next/dynamic and ssr: false:

import dynamic from "next/dynamic";

const AssistantWidget = dynamic(
  () =>
    import("@noodleseed/assistant/react").then((mod) => mod.NoodleAssistant),
  { ssr: false },
);

Host integration overrides

For each matching region or token, precedence is the typed host appearance object, then trusted host slot content or a public --ns-assistant-* CSS custom property, then compiled server presentation/branding, then built-in defaults. The same object may be assigned to assistantElement.appearance; the Web Component emits assistant-appearance-warning for low contrast. Use custom properties for application-level token integration:

noodle-assistant {
  --ns-assistant-accent: var(--app-primary);
  --ns-assistant-font-family: var(--app-font);
  --ns-assistant-panel-width: 440px;
}

The public slots are launcher-icon, header-leading, header-actions, empty-state, composer-leading, composer-trailing, and conversation-footer. Host DOM assigned to a slot replaces the renderer fallback for that region. This is a trusted embedding-page integration API, not a deployment escape hatch. Do not target internal shadow-DOM classes or selectors.

Toolchain requirements

  • Node.js 20 or newer for the backend helper (@noodleseed/assistant/server).
  • The package ships both ES modules and CommonJS with full export conditions, so Next.js, webpack, Vite, and plain Node all resolve it without transpilePackages, aliases, or type shims.
  • TypeScript moduleResolution bundler or node16 is recommended; classic node resolution also works for the /client, /react, and /server subpaths.

6. Brief your coding agent

If a coding agent (Claude Code, Codex, or another assistant) does the integration, install the Noodle Seed skills into the embedding web application's repository first:

npx @noodleseed/one@latest agents setup --write

This writes an AGENTS.md context block, a CLAUDE.md counterpart, and the noodle-seed skill tree (including the embedded-assistant runbook with a symptom-to-diagnosis troubleshooting table) so the agent can complete this guide without reverse-engineering installed packages. Re-run the command after CLI updates; noodle agents doctor reports staleness.

7. Verify the boundary

  • Signed-out session exchange returns 401.
  • Browser network, DOM, and storage contain no client secret or model key.
  • Local and production origins match allowedOrigins character-for-character.
  • At both the manifest/runtime boundary and TypeScript action helpers, only { confirm: true } asks for confirmation; omitted or false preserves direct execution. Action hints alone never enforce approval. noodle check --target embedded-assistant lists the tools that will confirm-gate.
  • Expired turns re-exchange once; interaction decisions never auto-retry. An explicit same-decision repeat returns the stored outcome without executing again.
  • Accept/decline/cancel are single-use, and only accept releases the exact bound connector action.
  • Wrong-origin requests fail closed.

Use noodle commands --json before automating CLI flags, and rotate or revoke backend clients with noodle assistant clients rotate|revoke when credentials change.

On this page