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**Loads
/embed.js** from yourapi_base(http://localhost:8000/embed.jsin dev,https://agentgraft.com/embed.jsin prod). This is a cross-origin module-script load, so the browser checksscript-srcAND requires CORS headers on every response in the chain (including redirects). - 2**(Dev only)** Follows a
301redirect from/embed.jsto AgentGraft's widget Vite dev server athttp://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**Loads woff2 fonts** (Funnel Display, Funnel Sans, Geist Mono) from
<api_base>/static/fonts/*.woff2. These are also cross-origin and need to be infont-src. The widget self-hosts them so it never depends on a third-party CDN. - 4**Opens a conversation** by
POSTing to<api_base>/api/v1/widget/conversations/. The request carries the three identity headersX-AgentGraft-Public-Key,X-AgentGraft-User-Id,X-AgentGraft-User-HMACand goes through the browser's CORS preflight machinery. - 5**Streams chat turns** by
POSTing to<api_base>/api/v1/widget/conversations/<id>/stream/withAccept: text/event-stream. This is a long-lived Server-Sent Events connection. The browser'sconnect-srcdirective 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 throughdjango-cors-headerswithCORS_URLS_REGEX = r"^/api/v1/(widget|policies)/.*$"andCORS_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 withsk_live_*tokens. - **Custom widget headers** (
X-AgentGraft-Public-Key,X-AgentGraft-User-Id,X-AgentGraft-User-HMAC) are inCORS_ALLOW_HEADERSso the browser's preflight lets them through. - **Static fonts** under
/static/fonts/*.woff2getAccess-Control-Allow-Origin: *from WhiteNoise automatically — itsWHITENOISE_ALLOW_ALL_ORIGINSdefaults to True. No extra config. - **
/embed.js** has its own custom view that setsAccess-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.
# 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'"],
}
#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.
# 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
}
#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:
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.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.# 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:"))
#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.
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}`);
});
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.
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.
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.
#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.jsserves the built bundle directly from disk. You don't need127.0.0.1:3895in 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 anyhttp:widget asset on anhttps:page. - **No HMR WebSockets.** Drop
ws://...:3895andws://...:5173fromconnect-src. - **Same-domain optional.** If you put your host app and AgentGraft on the same root domain (e.g.
app.acme.comandchat.acme.com), everything still works the same way — but you could optionally use a stricter CSP that lists onlychat.acme.cominstead of a wildcard. - **
api_baseis hardcoded.** AgentGraft's ownprod.pypinsapi_baseandwebhook_urlto 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 — deriveapi_basefrom a hardcoded production domain rather than reading from env inprod.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:
# 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>
#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.