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 rounded Halo-inspired 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.

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. Start with the canonical deploy:

noodle deploy --org <org> --app <app> --env <env>

Deploy preflights the complete target before upload. In an interactive terminal it collects all missing model variables and secrets, then continues. In a non-interactive run it reports every missing name and safe noodle variables set ... --from-env / noodle secrets set ... --from-env action; run those actions and repeat the same deploy command. 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_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 delegated credential exchanges that do not require an application-specific customer route, 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. 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. 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,
      scopes: user.scopes,
    },
    context,
    // Saved, backend-verified choices outrank browser presentation hints.
    preferences: { locale: user.locale, timeZone: user.timeZone },
  });
  return Response.json(session);
}

Backend-verified roles and OAuth-style scopes govern the same per-tool authorization rules as verified MCP bearer claims. 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.

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 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)}
/>

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.

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

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 moduleResolution bundler or node16 is recommended; classic node resolution also works for the /client, /react, and /server subpaths.

6. Preflight the production host

Run the check-only embed mode from the web application before its production build:

noodle assistant embed --check --json

It reports the canonical host environment names, missing names, statically detectable connect-src/frame-src readiness, and recommended post-deploy probes. It never returns environment values and does not scaffold or modify files. Add application-owned backend requirements, such as the customer side of a delegated token exchange, with a repeatable name:

noodle assistant embed --check --json --require-env EXAMPLE_DELEG_CLIENT_SECRET

If 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:

  1. Provision the backend secret manager.
  2. Map every required name through the CI environment and any secret allowlist or secrets file.
  3. Regenerate framework-owned environment binding types when the host repository uses them.
  4. Run the repository's production-equivalent build before asset upload.
  5. Run the canonical deploy so its configuration preflight completes before asset upload, then run the JSON contract's post-deploy probes.
  6. 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.

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

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

On this page