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 the model
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 { authenticatedWebsite, embeddedAssistant, noodleManaged, server, tool, 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: noodleManaged(),
access: authenticatedWebsite({ origins: ["http://localhost:3000", "https://app.example.com"] }),
layout: {
mode: "floating",
position: "bottom-right",
panelWidth: 520,
panelMinHeight: 540,
panelMaxHeight: 740,
edgeOffset: 24,
},
behavior: { showTimestamps: true, showConfirmationDetails: false },
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. For a
public surface it prints a process-local Embed ID and script: mount that script on your separately running
loopback web app to test the real anonymous session and interaction flow. The local ID is intentionally
ephemeral; production deploy returns the stable public ID backed by durable admission counters.
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 rounded managed default: frosted glass panel with a 24px radius, soft
elevation, subtle border/motion, medium brand-mark launcher without status or effect, undecorated header,
pill composer and action controls, and rounded user bubbles with plain assistant messages at comfortable
width. A partial presentation overrides only the supplied fields. behavior.showConfirmationDetails defaults to false, so the built-in card keeps connector mechanics out of the review while retaining its title, description, complete schema-projected business review, Confirm, and Don't proceed controls. Set it to true only when the audience needs a collapsed Additional details disclosure. This is presentation only: { confirm: true } still suspends, only acceptance releases the server-held bound action, and decline/cancel still stop. Public embedId scripts and authenticated sessionEndpoint mounts receive the deployed value automatically, while headless/BYO renderers keep the unchanged data-confirmation structure.
suggestedPrompts is the exact initial set only. Pass [] to show no initial chips, or omit it so the active model generates context-aware initial prompts. After the first message, follow-up prompts are always regenerated
from the complete authorized conversation context and are never copied into transcript history.
Validate before using hosted services:
noodle validate --json
noodle check --target embedded-assistant --json2. Choose model ownership and deploy
noodleManaged() is the shortest hosted path: Noodle selects and pays the provider, while the deployed
artifact stays provider-neutral and contains no model endpoint, name, or credential. During the sponsored
beta, every billing-attributed managed-cloud deployment receives a small bounded daily allowance and is
charged nothing; no organization or environment enrollment is required. Noodle may add an expiring bonus
allowance for a trial, while billing-account and fleet-wide ceilings protect the service.
If you need to own the provider instead, replace noodleManaged() with
openAICompatible({ baseUrl: variable("ASSISTANT_MODEL_BASE_URL"), model: variable("ASSISTANT_MODEL"), apiKey: secret("ASSISTANT_MODEL_API_KEY"), transport: "responses" }) when the endpoint implements the
Responses API. Omit transport for Chat Completions. Noodle uses only the selected endpoint and does not
fall back. Deployment configuration resolves those references; the browser never sees them. Then deploy:
noodle deploy --org <org> --app <app> --env <env>Deploy preflights the complete target before upload. For the customer-owned provider path, an interactive
terminal collects missing model variables and secrets; a non-interactive run reports the safe
noodle variables set ... --from-env / noodle secrets set ... --from-env actions. Values never appear in
the preflight report or resume state.
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.
The assistant does not select direct MCP access or protected-resource discovery. If the metadata advertises an unexpected authorization server, inspect the exact active deployment before changing auth; follow Diagnose an unexpected authorization server.
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 delegated credential exchanges that do not require an application-specific customer route. It also
makes one bounded synthetic request through the exact configured model transport without invoking a business
tool or using customer conversation data. A failure reports a redacted category such as authentication,
rate limit, timeout, or invalid response. A WARN guide row means the product guide exceeded its 16 KiB
model-context budget and the listed workflows were reduced to one index line each; purpose, use cases, and
boundaries are always kept. The doctor 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. The doctor cannot choose a customer route; after minting a routed
session, invoke one representative safe read to verify the route-bound exchange and connector together.
4. Exchange your existing user
Install @noodleseed/assistant with the package manager already used by your web application. Generate the
same-origin route, authentication adapter, browser mount and contract tests in that existing application:
noodle assistant embed --framework nextjs --surface authenticated --dry-run --jsonReview files and conflicts, then rerun without --dry-run to install.
The generated route delegates standard infrastructure to the maintained server handler:
import { createAssistantSessionHandler } from "@noodleseed/assistant/server";
import { authenticateAssistantRequest } from "../../../../lib/noodle-assistant-auth";
export const POST = createAssistantSessionHandler({
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,
authenticate: authenticateAssistantRequest,
});Implement authenticateAssistantRequest in the generated lib/noodle-assistant-auth.ts with the application's
existing session and membership lookup. Return null when signed out; otherwise return AssistantSessionIdentity
with verified user, optional claims, preferences and server-owned routing. Roles and scopes govern the same
per-tool authorization rules as verified MCP bearer claims. Do not derive them from browser JSON.
createAssistantSessionHandler validates exact configured Origin and JSON content type before authentication,
returns JSON 401 instead of a redirect, bounds the body to 16 KiB and session context to 32 flat values, and
enforces a 15-second deadline. It returns no-store responses and sanitizes failures without retrying exchanges
or following redirects. Keep any stricter application CSRF middleware. For mixed-mode continuation, your
adapter may return the signInTicket bound to its backend login transaction; browser JSON cannot choose it.
Run vitest run test/noodle-assistant.test.ts using your application's Vitest installation, then add tests
with its real signed-out and cross-tenant session fixtures. The supplied synthetic tests prove route wiring,
not your membership rules. Other Node hosts can use this Web Request/Response handler directly; the lower-level
createAssistantSession remains available when the host owns and tests an equivalent HTTP boundary.
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.
Route customer endpoints from the backend
If a connector uses customerEndpoint("customer_api", ...), resolve that user's API base URL from
server-owned tenancy data and bind it during the same exchange:
const user = await requireCurrentUser(request);
const account = await requireAccountMembership(user.id);
const session = await createAssistantSession({
serviceUrl,
clientId,
clientSecret,
origin,
user: { id: user.id, email: user.email },
routing: {
endpoints: {
// The key must match the customerEndpoint name in server.ts.
customer_api: account.clusterApiBaseUrl,
},
},
});The browser does not send routing. Derive it only after authenticating the user and checking their
membership in the selected account or cluster. Do not copy a route from page context, a request header,
session claims, tool arguments, or model output. Noodle accepts only endpoint names declared by the active
artifact, canonicalizes each HTTPS URL against its customerEndpoint policy, and stores the result only in
the private short-lived assistant session. It never appears in the session response or public caller
identity. The route is immutable for that session; if the user's account or cluster changes, mint a new
assistant session.
Routing is optional. If a declared endpoint is omitted, only tools that need it fail closed with
connector_route_unavailable; static tools continue to work. Confirmed routed actions also bind a URL-blind
route fingerprint at proposal time and reject a missing or changed route before connector egress when the
user accepts. If the backend explicitly supplies an unknown endpoint name or a malformed or policy-disallowed
URL, session exchange rejects it with 400 { "error": "invalid assistant routing" } and does not reflect
the URL.
Using a static Vue frontend with Django or another non-Node backend? Follow Django and Vue for the framework-neutral HTTP exchange, same-origin production routing, CSRF boundary, and Vite configuration.
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",
"apps": "https://<service>/v1/assistant/apps",
"sandbox": "https://<service>/v1/assistant/sandbox"
},
"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 built-in 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,
access: authenticatedWebsite({
origins: allowedOrigins,
sessionClaims: {
accountTier: { exposeToModel: true }, // the assistant may use it directly
region: {}, // tools only
},
}),
behavior: { showConfirmationDetails: false },
});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)}
/>Want complete control over the renderer? Follow Bring your own UI to use the same assistant backend with React components and styles you own.
For a framework-neutral application, import @noodleseed/assistant once and mount:
<noodle-assistant
session-endpoint="/api/assistant/session"
theme="auto"
></noodle-assistant>Mount this backend-exchange flow only inside the authenticated application surface. A public marketing
page is a different surface with a different bootstrap: declare publicWebsite({ origins, capabilities })
alongside (or instead of) authenticatedWebsite(...), and the browser mints its own session from a
non-secret embed id with no backend route at all. One assistant serves both; each surface has its own
origins, capability allowlist, and budget.
The public Web Component automatically attaches a bounded, anonymous same-origin Markdown representation
of the canonical page URL to each turn. It never scrapes DOM text; the page snapshot is untrusted,
nonpersistent model context, and a failed fetch sends the URL alone. A host-supplied pageContext
overrides it. Authenticated and headless integrations stay explicit and do not fetch page content
automatically.
Outside React, use the DOM-free client directly. It keeps the token in memory and exposes React-free AI SDK
UIMessage state over the exact same advertised endpoints and interaction 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 pending:
| { id: string; requestedSchema?: Readonly<Record<string, unknown>> }
| undefined;
assistant.subscribeChat((state) => {
renderUIMessageState(state);
pending = undefined;
for (const message of state.messages) {
for (const part of message.parts) {
if (part.type === "data-confirmation" && part.data.status === "pending") {
pending = { id: part.data.id };
}
if (part.type === "data-input-request" && part.data.status === "pending") {
pending = {
id: part.data.id,
requestedSchema: part.data.requestedSchema,
};
}
if (part.type === "data-view") {
renderRegisteredView(part.data.resourceUri, part.data.result);
}
}
}
});
await assistant.sendMessage("Book next Thursday and Friday off");
if (pending) {
const resolution = pending.requestedSchema
? {
action: "accept" as const,
content: await renderPortableForm(pending.requestedSchema),
}
: { action: "accept" as const };
await assistant.respond(pending.id, resolution);
}
// Either interaction also accepts { action: 'decline' } or { action: 'cancel' }.subscribeChat immediately emits a detached { messages, status, error? } snapshot and then emits as its
AI SDK UIMessage.parts change. Assistant text uses text; confirmations, structured input requests, tool
results, and linked views use data-confirmation, data-input-request, data-tool-result, and data-view.
Interaction data moves through pending, submitting, accepted, declined, or cancelled. The package
does not adopt a second chat transport: React is optional and isolated to the /react and /react/client
entries, and both renderer paths delegate orchestration to the same DOM-free client. Use the lower-level
subscribe(...) event stream only for transport or session lifecycle observations that do not belong in the
transcript.
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 hides
connector mechanics unless showConfirmationDetails is explicitly enabled. 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. A conforming inline App reports its height through
the bridge; the host grows it with the content up to the 16,384px safety bound, leaving the conversation
transcript as the only vertical scroll area and following the latest reply while the App grows:
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
Use the Console Assistant tab to preview and save an environment-wide name, input placeholder, up to three sample prompts, theme, logo, launcher, position, or palette without changing the embed snippet. For repeatable automation, use noodle assistant appearance show|apply|reset; pass the revision reported by show as --revision <n> when an update must remain pinned to the state you reviewed. New sessions pick up the saved revision, while an existing conversation keeps the configuration it started with.
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 the saved environment override, compiled server presentation/branding, and 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.
Content-Security-Policy on your page
If your app sends a Content-Security-Policy header, allow the Noodle service origin in two
directives — everything else can stay as strict as you like:
connect-src https://<service>; /* session turns and event streams */
frame-src https://<service>; /* the hosted widget sandbox document */App widgets render inside a sandboxed iframe that loads a static, secret-free sandbox document from
the service origin (endpoints.sandbox). That document carries its own CSP, so your page keeps a
strict script-src (nonces, no unsafe-inline) and widgets still render. Without the frame-src
entry the widget card shows a "could not be displayed" note instead of its content.
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. Serve public visitors, and let them sign in mid-conversation
Everything above assumes a signed-in user. A publicWebsite(...) surface serves the same assistant to
anonymous visitors (a marketing site, docs, or a landing page) from one script tag, with a required
capability allowlist, per-surface daily budgets, and a kill switch:
assistant: embeddedAssistant({
model,
behavior: { showConfirmationDetails: false },
access: publicWebsite({
origins: ["https://www.example.com"],
capabilities: [answerProductQuestion, requestDemo, myOrders],
instructions:
"Help visitors choose the right workflow before inviting a next step.",
signIn: true, // myOrders needs a signed-in user; visitors sign in to reach it
}),
}),Keep host-neutral product truth in server.instructions; use the surface instructions field for the
voice, goals, boundaries, and invitations of this website. It is trimmed, non-empty, at most 4,000
characters, and injected only after the service binds the exact assistant surface — never in MCP
initialize or another surface. On a public sales surface, give useful guidance before inviting a
contact or demo request.
noodle deploy provisions a stable embed id and prints the snippet; the embed id is not a credential:
<script src="https://cloud.noodleseed.dev/v1/assistant/embed.js"
data-embed-id="pub_7f2q4k9x" async></script>signIn: true makes the surface mixed: identity-dependent capabilities stay visible so the
assistant can offer them, and reaching one shows a "Sign in to continue" card instead of executing.
The widget raises assistant-sign-in-requested with a short-lived, single-use signInTicket. Your page
binds that value to its own login or signup transaction, and your backend spends it after authentication.
Noodle Seed can then atomically adopt opted-in caller state, rotate the session token, and repaint the
bounded conversation in the authenticated embed.
For conversion-focused SaaS onboarding, make the anonymous interaction produce a genuine useful artifact before the account request, then preserve that reviewed work through signup. The dedicated guide below owns the full sequence, completion boundary, claim-safe business framing, and measurement model.
Follow Reduce signup friction with continuous onboarding for the complete redirect, state adoption, failure handling, visual recovery, and privacy-sensitive configuration.
7. Preflight the production host
Install into your existing application
Let your coding agent inspect the application and choose the surface before creating files. Reuse your login, membership checks and business APIs. Public embedding needs no customer backend exchange route.
Preview the supported application-side files without changing the repository:
noodle assistant embed --framework nextjs --surface public --dry-run --jsonUse authenticated for signed-in users, or mixed for visitors who may sign in. The preview includes the
recipe, generated contents and hashes, application-owned seams, file conflicts and next steps. After
reviewing it, run the same command without --dry-run. Modified files are preserved unless you explicitly
choose --force; keep your application code and secrets when reconciling an older installation.
The generated NOODLE-INTEGRATION.md identifies the remaining work and verification. Authenticated mode
names authenticateAssistantRequest; mixed mode also requires connecting the generated sign-in callback
to your existing backend login transaction. These are application-owned integration points, not a new
authentication system. Install a compatible assistant package with your existing package manager. Use
the guide's exact check command: it includes the public environment aliases and additional names for
the selected profile. Export those names from your host environment; the CLI does not load application
dotenv files.
File creation does not prove that the application is integrated. Test the actual identity and tenant boundary, one safe backend workflow, and the browser mount. Missing test identities or a reachable sandbox remain unverified. For other stacks, use the framework-neutral contract in this guide or the Django and Vue walkthrough.
Inspect host configuration
Run the check-only embed mode from the web application before its production build:
noodle assistant embed --check --jsonIt reports the canonical host environment names, missing names, statically detectable CSP readiness,
and recommended post-deploy probes. It never returns environment values and does not scaffold or modify
files. Pass --surface public|mixed|authenticated to match the deployment: public drops the backend
client id/secret requirement. Public script-tag mounts additionally require the service in script-src;
the generated React mount bundles the assistant and needs only connect-src and frame-src for the
service. Unknown public mounts retain the conservative script check. A covering wildcard source
(https://*.example.com) verifies. Add application-owned backend requirements, such as
the customer side of a delegated token exchange, with a repeatable name, and follow a host repository's
own env naming with --env-alias NAME=HOST_NAME:
noodle assistant embed --check --json --require-env EXAMPLE_DELEG_CLIENT_SECRETIf a static CSP is present, both directives must reference the NOODLE_SERVICE_URL origin. A dynamic CSP
that cannot be proven is reported as unverified; verify its generated production header before merge.
No common static CSP source is reported as not-detected; the post-deploy browser probe still verifies
headers injected by hosting infrastructure.
For each deployment environment:
- Provision the backend secret manager.
- Map every required name through the CI environment and any secret allowlist or secrets file.
- Regenerate framework-owned environment binding types when the host repository uses them.
- Run the repository's production-equivalent build before asset upload.
- Run the canonical deploy so its configuration preflight completes before asset upload, then run the JSON contract's post-deploy probes.
- Rotate assistant-client and delegated-exchange credentials independently and rerun the preflight.
Use synthetic or mock connector data in Devtools by default. Before Devtools Chat sends real connector data to an external model, disclose that data flow and obtain approval.
8. 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.
9. 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 the authored
access.originscharacter-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.
- The production-equivalent host build passes after environment binding generation.
- A real browser keyboard submission completes session exchange and a tool turn, produces no unexpected console errors, and renders one linked App.
Use noodle commands --json before automating CLI flags, and rotate or revoke backend clients with
noodle assistant clients rotate|revoke when credentials change.
Noodle Seed for Shopify
Deploy one reusable Shopify search, focused conversational-commerce, store knowledge, and checkout solution for any Shopify store.
Reduce signup friction with continuous onboarding
Give visitors a useful result before account creation, then carry their reviewed work into the authenticated product and complete onboarding there.