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 --json2. 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_SECRETNever 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:
- backend-verified
preferencesfrom session exchange; - fresh per-turn browser
clientContexthints; server.context.defaults;- platform defaults (
en-USandUTC).
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
moduleResolutionbundlerornode16is recommended; classicnoderesolution also works for the/client,/react, and/serversubpaths.
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 --writeThis 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
allowedOriginscharacter-for-character. - At both the manifest/runtime boundary and TypeScript action helpers, only
{ confirm: true }asks for confirmation; omitted orfalsepreserves direct execution. Action hints alone never enforce approval.noodle check --target embedded-assistantlists 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.