Embed an assistant with Django and Vue
Serve a static Vue assistant through a Django-owned session exchange without adding a Node.js production runtime or exposing backend credentials.
A Vue production build can stay fully static. Django owns the authenticated assistant-session route and exchanges the existing user for a short-lived Noodle Seed session. No Node.js server is required in production.
Complete the deployment and backend-client steps in Embed an assistant in your SaaS first. The deployed assistant must allow the application's exact HTTPS origin, and Django must have these backend-only settings:
NOODLE_SERVICE_URL
NOODLE_ASSISTANT_CLIENT_ID
NOODLE_ASSISTANT_CLIENT_SECRET
PUBLIC_APP_ORIGIN=https://app.example.comNOODLE_SERVICE_URL is the Noodle Seed control-plane URL recorded in deployment.json and printed when the
assistant client is created. It is not the deployment MCP URL ending in /v1/mcp.
Route the production request
Keep the session endpoint on the application's public origin:
Vue -> POST /api/assistant/session -> Django -> Noodle Seed control planeIf Vue's static files and Django run on different infrastructure, route /api/assistant/session through the
Vue application's origin to Django. The browser SDK includes application cookies only for a same-origin
session endpoint.
Exchange the Django user
createAssistantSession from the JavaScript package is a convenience helper. A non-Node backend can make
the same server-to-server HTTP exchange. Install requests with the Python package manager the Django
application already uses, then add an authenticated route:
import json
import requests
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from requests.auth import HTTPBasicAuth
from requests.exceptions import RequestException
def valid_context(value):
if not isinstance(value, dict) or len(value) > 32:
return False
return all(
isinstance(key, str)
and len(key) <= 80
and (
item is None
or isinstance(item, (bool, int, float))
or (isinstance(item, str) and len(item) <= 2_000)
)
for key, item in value.items()
)
@csrf_exempt
@require_POST
def assistant_session(request):
if request.content_type != "application/json":
return JsonResponse({"error": "application/json required"}, status=415)
if request.headers.get("Origin") != settings.PUBLIC_APP_ORIGIN:
return JsonResponse({"error": "origin is not allowed"}, status=403)
if not request.user.is_authenticated:
return JsonResponse({"error": "authentication required"}, status=401)
try:
browser_body = json.loads(request.body or b"{}")
except json.JSONDecodeError:
return JsonResponse({"error": "invalid JSON"}, status=400)
if not isinstance(browser_body, dict):
return JsonResponse({"error": "JSON object required"}, status=400)
payload = {
"origin": settings.PUBLIC_APP_ORIGIN,
"user": {
"id": str(request.user.pk),
"email": request.user.email,
"name": request.user.get_full_name() or request.user.get_username(),
},
}
# App-specific: resolve this only from authenticated server-side membership data.
customer_api_base_url = resolve_customer_api_base_url(request.user)
payload["routing"] = {
"endpoints": {"customer_api": customer_api_base_url},
}
context = browser_body.get("context")
if context is not None:
if not valid_context(context):
return JsonResponse({"error": "invalid context"}, status=400)
payload["context"] = context
try:
upstream = requests.post(
f"{settings.NOODLE_SERVICE_URL.rstrip('/')}/v1/assistant/sessions",
auth=HTTPBasicAuth(
settings.NOODLE_ASSISTANT_CLIENT_ID,
settings.NOODLE_ASSISTANT_CLIENT_SECRET,
),
headers={"Accept": "application/json"},
json=payload,
timeout=10,
)
except RequestException:
return JsonResponse({"error": "assistant service unavailable"}, status=502)
response = HttpResponse(
upstream.content,
status=upstream.status_code,
content_type=upstream.headers.get("Content-Type", "application/json"),
)
response["Cache-Control"] = "no-store"
return responseAdd the route to the Django URL configuration:
from django.urls import path
from .views import assistant_session
urlpatterns = [path("api/assistant/session", assistant_session)]The example forwards the Noodle Seed response body and status unchanged. Do not log the upstream body or assistant client credentials. Add backend-verified roles, scopes, claims, or locale and time-zone preferences to the exchange payload when the application uses them. Browser page context remains untrusted model context and must never grant authorization.
If the deployed server has no customerEndpoint, omit routing. Otherwise, implement
resolve_customer_api_base_url with the application's tenant-membership lookup: authenticate the Django
user, resolve their permitted account or cluster, and return that record's canonical HTTPS API base URL. The
endpoint-map key must match the name authored in customerEndpoint. Never read the route from
browser_body, Origin, a query parameter, page context, or another browser-controlled value. Noodle
validates the URL against the active artifact policy and keeps it out of the assistant session response,
caller identity, model context, and logs. An omitted route leaves only its dependent tools unavailable. If
the user's cluster changes, mint a new assistant session rather than trying to update the current one.
Preserve the CSRF boundary
The managed Web Component posts JSON with same-origin cookies but does not add Django's X-CSRFToken header.
The example replaces Django's token-based CSRF check for this one endpoint with two explicit controls:
- require
application/json, which prevents a cross-site form from reaching the exchange; - require
Originto equal the configured public application origin exactly.
Do not use csrf_exempt without equivalent checks. If the application keeps Django's normal CSRF middleware
for this route, use the DOM-free browser client with an injected fetch
that adds X-CSRFToken instead of the managed element.
Mount the Vue client
Install @noodleseed/assistant with the frontend's existing package manager and import it once in the Vue
entrypoint:
// main.ts
import "@noodleseed/assistant";Mount the framework-neutral element inside the authenticated application:
<template>
<noodle-assistant
session-endpoint="/api/assistant/session"
theme="auto"
/>
</template>Tell the Vue compiler that noodle-assistant is a Web Component:
// vite.config.ts
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag === "noodle-assistant",
},
},
}),
],
});vite build may still produce only static files. At runtime, the element posts to the Django route, and
Django performs the credentialed exchange.
Verify production
- A signed-out request to
/api/assistant/sessionreturns401, not an HTML login redirect. - A wrong
Originor non-JSON request fails before Django contacts Noodle Seed. - The browser receives only the short-lived session response, never the assistant client secret or model key.
- A customer-routed connector reaches the API base URL resolved from the authenticated Django user's server-side membership, and changing a browser field cannot change that route.
PUBLIC_APP_ORIGINmatchesallowedOriginscharacter-for-character.- The static host routes
/api/assistant/sessionto Django on the same public origin. - One production browser session completes exchange, sends a message, and renders a response without CORS, CSRF, or HTML-response errors.