Noodle Seed
Guides

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

NOODLE_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 plane

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

Add 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 Origin to 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/session returns 401, not an HTML login redirect.
  • A wrong Origin or 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_ORIGIN matches allowedOrigins character-for-character.
  • The static host routes /api/assistant/session to 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.

On this page