AgentGraft

Operations

Embedding the widget

Everything that breaks between dropping the template tag and seeing the chat bubble — CSP, CORS, async views, identity — with the exact rules for fixing each one.

8 min read

Dropping {% agentgraft_widget %} into a template is one line. Making the browser actually load the resulting script is the part that bites people, because the widget is *cross-origin by design* — it lives on AgentGraft's host (agentgraft.com in production, localhost:8000 in local dev) and renders inside the host app's page on a different origin. Cross-origin means CORS, CSP, async-context safety, and a couple of subtle gotchas that every host app hits exactly once.

This page is the complete checklist. Read it once before you embed; come back when something doesn't render and the browser console is yelling at you.

#The five things the widget actually does at runtime

Before fixing browser errors, it helps to know exactly what the widget does between the moment the template tag renders and the first chat message round-trip. Every step is a separate browser network operation, and any one of them can be the thing your CSP or CORS is blocking.

  1. 1**Loads /embed.js** from your api_base (http://localhost:8000/embed.js in dev, https://agentgraft.com/embed.js in prod). This is a cross-origin module-script load, so the browser checks script-src AND requires CORS headers on every response in the chain (including redirects).
  2. 2**(Dev only)** Follows a 301 redirect from /embed.js to AgentGraft's widget Vite dev server at http://127.0.0.1:3895/src/embed/index.js. AgentGraft does this so the widget bundle gets HMR during development; it's completely invisible in production where the bundle is served straight from disk. The redirect target needs its own CSP allowlist and CORS headers.
  3. 3**Loads woff2 fonts** (Funnel Display, Funnel Sans, Geist Mono) from <api_base>/static/fonts/*.woff2. These are also cross-origin and need to be in font-src. The widget self-hosts them so it never depends on a third-party CDN.
  4. 4**Opens a conversation** by POSTing to <api_base>/api/v1/widget/conversations/. The request carries the three identity headers X-AgentGraft-Public-Key, X-AgentGraft-User-Id, X-AgentGraft-User-HMAC and goes through the browser's CORS preflight machinery.
  5. 5**Streams chat turns** by POSTing to <api_base>/api/v1/widget/conversations/<id>/stream/ with Accept: text/event-stream. This is a long-lived Server-Sent Events connection. The browser's connect-src directive applies.

#What AgentGraft sets up for you

You don't have to configure CORS on the AgentGraft side — it's already permissive for every path the widget touches. The relevant pieces, all baked into agentgraft_server/settings/base.py:

  • **/api/v1/widget/* and /api/v1/policies/*** go through django-cors-headers with CORS_URLS_REGEX = r"^/api/v1/(widget|policies)/.*$" and CORS_ALLOW_ALL_ORIGINS = True. Every browser origin can call them. The admin API at /api/v1/admin/* deliberately stays same-origin only because it's called server-to-server with sk_live_* tokens.
  • **Custom widget headers** (X-AgentGraft-Public-Key, X-AgentGraft-User-Id, X-AgentGraft-User-HMAC) are in CORS_ALLOW_HEADERS so the browser's preflight lets them through.
  • **Static fonts** under /static/fonts/*.woff2 get Access-Control-Allow-Origin: * from WhiteNoise automatically — its WHITENOISE_ALLOW_ALL_ORIGINS defaults to True. No extra config.
  • **/embed.js** has its own custom view that sets Access-Control-Allow-Origin: * on BOTH the production response (file from disk) AND the dev redirect (the 301 that points at the Vite HMR server). Module-script loads CORS-check every step in the redirect chain, so the header has to be on the 301 itself, not just the final 200.

#What YOUR host app's CSP must allow

This is the part you DO have to configure. The host app needs to whitelist AgentGraft's origin in several CSP directives. Every directive corresponds to one of the five runtime operations from the section above.

python
# yourapp/settings.py — production CSP example.
# Replace https://agentgraft.com with your hosted instance if
# you self-host. Add https://yourdomain.com if your AgentGraft
# is on the same root domain as your host app.

_AGENTGRAFT = "https://agentgraft.com"

SECURE_CSP = {
    "default-src": ["'self'"],
    "script-src": [
        "'self'",
        "'unsafe-inline'",         # Django CSRF token meta tag
        _AGENTGRAFT,               # /embed.js
        "blob:",                   # workers some bundles spawn
        "'wasm-unsafe-eval'",      # Safari's WebP encoder (image uploads)
    ],
    "script-src-elem": [
        # Firefox enforces script-src-elem separately when it's
        # set; mirror script-src so a single allowlist covers
        # both check points.
        "'self'",
        "'unsafe-inline'",
        _AGENTGRAFT,
        "blob:",
    ],
    "connect-src": [
        "'self'",
        _AGENTGRAFT,               # widget API + SSE stream
        "wss://api.deepgram.com",  # voice: cloud speech-to-text
    ],
    "font-src": [
        "'self'",
        _AGENTGRAFT,               # woff2 self-hosted variable fonts
    ],
    "img-src": [
        "'self'",
        "data:",                   # image attachment thumbnails
        _AGENTGRAFT,               # defensive — future-proofs the
                                   # widget if it grows image assets
    ],
    "worker-src": [
        "'self'",
        "blob:",                   # standard for worker bundles
    ],
    "frame-src": ["'none'"],
    "object-src": ["'none'"],
}
Production CSP for any host app embedding the widget.

#Local dev: extra origins to allow

In local dev there's one extra wrinkle. AgentGraft's /embed.js view 301-redirects to a Vite dev server on a *different port* (http://127.0.0.1:3895/src/embed/index.js) so the widget bundle has HMR while you're iterating on it. The redirect target is a different origin from the API base, so it needs its own CSP entries.

This is invisible in production — the prod build serves the bundled embed.js straight from disk with no redirect. You only need the Vite origins in your local settings, not in prod.py.

python
# yourapp/settings/local.py — extends the base CSP

from yourapp.settings.base import *  # noqa

_AGENTGRAFT = "http://localhost:8000"
_AGENTGRAFT_VITE_IP = "http://127.0.0.1:3895"
_AGENTGRAFT_VITE_LH = "http://localhost:3895"

SECURE_CSP = {
    **SECURE_CSP,  # noqa
    "script-src": [
        *SECURE_CSP["script-src"],  # noqa
        _AGENTGRAFT,
        _AGENTGRAFT_VITE_IP,        # the redirect target
        _AGENTGRAFT_VITE_LH,        # belt-and-suspenders alias
        "blob:",
    ],
    "script-src-elem": [
        "'self'",
        "'unsafe-inline'",
        _AGENTGRAFT,
        _AGENTGRAFT_VITE_IP,
        _AGENTGRAFT_VITE_LH,
        "blob:",
    ],
    "connect-src": [
        *SECURE_CSP["connect-src"],  # noqa
        _AGENTGRAFT,
        _AGENTGRAFT_VITE_IP,
        _AGENTGRAFT_VITE_LH,
        "ws://127.0.0.1:3895",       # Vite HMR WebSocket
        "ws://localhost:3895",
    ],
    "font-src": [*SECURE_CSP["font-src"], _AGENTGRAFT],  # noqa
    "img-src": [*SECURE_CSP["img-src"], _AGENTGRAFT],    # noqa
    "worker-src": [*SECURE_CSP["worker-src"], "blob:"],  # noqa
}
Local-dev CSP override that punches through to AgentGraft's Vite HMR origin.

#Async views — the SynchronousOnlyOperation gotcha

If your host app serves the page that contains {% agentgraft_widget %} from an async def view (Daphne / ASGI), the widget tag can blow up with SynchronousOnlyOperation: You cannot call this from an async context. The reason is that the SDK template tag needs to read request.user.pk (for authenticated visitors) and may need to call session.create() (for anonymous visitors). Both are sync ORM operations, and Django blocks sync ORM from inside an async event loop as a safety measure.

**SDK 0.0.5+ handles this automatically.** The template tag detects the async context, catches the SynchronousOnlyOperation, and routes through async_to_sync(request.auser)() and async_to_sync(session.acreate)() so the lookup runs on a worker thread Django is happy with. Sync views still use the fast path with no thread hop.

**Pre-0.0.5 workaround**: either upgrade the SDK, or render the widget tag from a sync view that delegates to your async view's data. (Or patch the template tag yourself with the same async_to_sync fallback — the diff is in the agentgraft monorepo at sdk/agentgraft/django/templatetags/agentgraft.py.)

#Identity: how the widget tag signs the visitor

The template tag emits a single <script> element carrying the visitor's identity in five data-* attributes. The browser sends those to AgentGraft on every API call. Two flavours:

Authenticated visitor
When request.user.is_authenticated, the tag emits data-user-id="user:{pk}" and data-anonymous="false". The HMAC is computed from the webhook secret. Your @ag.user_loader should strip the user: prefix and return the matching Django User.
Anonymous visitor
When the visitor isn't logged in, the tag forces a Django session to exist (so the SAME visitor comes back to the SAME conversation across page reloads), then emits data-user-id="session:{session_key}" and data-anonymous="true". Your @ag.user_loader receives is_anonymous=True as a keyword arg — the SDK introspects your loader's signature and only forwards the kwarg if it accepts one.
python
# Reject anonymous visitors entirely
@ag.user_loader
def load_user(external_id: str, *, is_anonymous: bool = False):
    if is_anonymous or not external_id.startswith("user:"):
        raise User.DoesNotExist(
            "This chat requires a logged-in user."
        )
    return User.objects.get(pk=external_id.removeprefix("user:"))


# OR — accept both with a sentinel for anonymous
@ag.user_loader
def load_user(external_id: str, *, is_anonymous: bool = False):
    if is_anonymous:
        return {"session_key": external_id.removeprefix("session:")}
    return User.objects.get(pk=external_id.removeprefix("user:"))
Two valid user_loader patterns. Reject vs accept anonymous.

#Handling reference chip clicks

If your config declares reference types with ag.reference(...), the assistant's replies can contain clickable chips for objects in your app. Clicking one dispatches a cancelable agentgraft:reference event on window. Listen for it with a window listener rather than AgentGraft.on(...): the embed script loads async, so window.AgentGraft may not exist yet when your code runs, but a window listener can be attached at any time.

javascript
window.addEventListener("agentgraft:reference", (event) => {
  const { type, id, label, url } = event.detail;
  if (type !== "dataset") return;
  event.preventDefault();      // suppress the url fallback
  myRouter.push(`/datasets/${id}`);
});
Open the object in-app. Without preventDefault() the widget navigates to the type's url instead.

Treat id as untrusted client input and run your normal permission checks when you open the object. The widget also closes itself after a handled click in fullscreen or on mobile, where the panel would otherwise cover what you just opened.

#Knowing when the assistant is working

The widget emits streaming as it starts working on a turn ({streaming: true}), and again once it has no turn left ({streaming: false}), whichever thread the turns are in, private chats included. Use it to show that the assistant is busy somewhere on your page while the panel is shut. Listen once window.AgentGraft exists: the embed script loads async.

javascript
window.addEventListener("load", () => {
  window.AgentGraft.on("streaming", ({ streaming }) => {
    document.body.classList.toggle("assistant-busy", streaming);
  });
});

#Common errors and what they mean

If the chat bubble doesn't appear after dropping the tag, the browser console is the first place to look. Here's a translation table from the most common error messages to the underlying cause and the exact fix.

text
CSP: blocked a worker script (worker-src) at blob:... violates
script-src 'self' 'unsafe-inline' ...
  → Cause:  Firefox is checking blob-URL workers against script-src.
  → Fix:    Add `blob:` to script-src in your CSP. Don't remove
            `blob:` from worker-src either — both are needed.

Loading module from "http://localhost:8000/embed.js" was blocked
because of a disallowed MIME type ("text/html").
  → Cause:  The module-script fetch hit a 301 redirect, the redirect
            target is CSP-blocked, and the browser fell back to
            treating the 301 response (Content-Type: text/html) as
            the script body.
  → Fix:    Allow the redirect target in script-src. In dev that's
            http://127.0.0.1:3895 (the AgentGraft widget Vite dev
            server). In prod there's no redirect.

CORS header 'Access-Control-Allow-Origin' missing. Status code: 301.
  → Cause:  The 301 redirect from /embed.js doesn't carry the CORS
            header. Module loads CORS-check every step.
  → Fix:    AgentGraft 0.0.5+ sets Access-Control-Allow-Origin: *
            on both the prod and dev paths. Upgrade. If you can't,
            patch agentgraft_server/embed_views.py to set the header
            on the dev redirect path.

CORS request did not succeed. Status code: (null).
  → Cause:  The browser couldn't reach the server at all. Usually
            means agentgraft is not running, or the host:port in
            api_base is unreachable.
  → Fix:    `curl -I http://localhost:8000/health/`. If that fails,
            start the AgentGraft runserver. If it succeeds but the
            widget still can't reach it, check for a host firewall.

SynchronousOnlyOperation: You cannot call this from an async
context — use a thread or sync_to_async.
  → Cause:  Your host view is `async def` and the SDK template tag
            (pre-0.0.5) does sync ORM access on request.user.
  → Fix:    Upgrade to SDK 0.0.5+ which handles this automatically.

agentgraft_widget: profile 'default' is missing one of api_base /
public_key / webhook_secret in AGENTGRAFT settings
  → Cause:  One of the five env vars (AGENTGRAFT_API_BASE,
            _GRAFT_ID, _PUBLIC_KEY, _API_KEY, _WEBHOOK_SECRET)
            isn't set. The tag returns an HTML comment instead of a
            <script> tag — the page renders, the bubble doesn't.
  → Fix:    Sign up at <api_base>/accounts/signup/, save the four
            credentials from the welcome page, paste them into your
            host app's .env, restart.

Tool call returns "...requires a logged-in user."
  → Cause:  The widget tag rendered for an anonymous visitor and
            your @ag.user_loader rejects them.
  → Fix:    Sign in to the host app. Or, if anonymous chat is
            intentional, accept `is_anonymous=True` in your loader
            and return a sentinel (e.g. a dict) instead of raising.
Console error → diagnosis → fix.

#Production vs local dev — what changes

Most of the friction in this page is local-dev specific. In production, several things simplify:

  • **No Vite redirect.** /embed.js serves the built bundle directly from disk. You don't need 127.0.0.1:3895 in your CSP — only the AgentGraft origin itself.
  • **HTTPS only.** Your CSP entries should be https://agentgraft.com (or your self-hosted instance). Mixed-content blocking will reject any http: widget asset on an https: page.
  • **No HMR WebSockets.** Drop ws://...:3895 and ws://...:5173 from connect-src.
  • **Same-domain optional.** If you put your host app and AgentGraft on the same root domain (e.g. app.acme.com and chat.acme.com), everything still works the same way — but you could optionally use a stricter CSP that lists only chat.acme.com instead of a wildcard.
  • **api_base is hardcoded.** AgentGraft's own prod.py pins api_base and webhook_url to the production domain regardless of env vars, to prevent a missing or typo'd env from crashing the deploy. Your host app should do the same — derive api_base from a hardcoded production domain rather than reading from env in prod.py.

#Verifying it works (curl)

Before you debug in a browser, prove the network path is clean with three curl commands. Each one isolates one segment of the chain:

bash
# 1) Confirm AgentGraft is reachable AND embed.js redirect
#    response carries the CORS header.
curl -sI http://localhost:8000/embed.js | grep -iE \
    "^(http|location|access-control)"
# Expected:
#   HTTP/1.1 301 Moved Permanently
#   Location: http://127.0.0.1:3895/src/embed/index.js
#   Access-Control-Allow-Origin: *

# 2) Confirm the redirect target (Vite in dev, file in prod)
#    is also CORS-clean.
curl -sI http://127.0.0.1:3895/src/embed/index.js | grep -iE \
    "^(http|content-type|access-control)"
# Expected:
#   HTTP/1.1 200 OK
#   Content-Type: text/javascript
#   Access-Control-Allow-Origin: *

# 3) Confirm your host app's rendered home page contains the
#    widget script tag (with auth headers populated, not blank).
curl -s http://localhost:9000/ | grep -o \
    '<script[^>]*data-public-key[^>]*></script>'
# Expected:
#   <script type="module" src="http://localhost:8000/embed.js"
#   data-public-key="pk_live_..." data-user-id="user:1"
#   data-user-hmac="..." data-api-base="http://localhost:8000"
#   data-anonymous="false" async></script>
Three checks. If all three pass, the bubble will appear in the browser.

#If you're still stuck

The two endpoints AgentGraft exposes for AI-agent consumption (/docs/llms.txt and /docs/llms-full.txt) include this entire page in plain text. If you're debugging with an LLM coding assistant, point it at agentgraft.com/docs/llms-full.txt — the embedding checklist is in there verbatim.