#How to add an AI agent to your Django or FastAPI app
To add an AI agent to your Django or FastAPI app, create the agent from a chat with the Vatio MCP server, then build it into the code with npx @vatio-ai/skills and /vatio-init (steps below). Your coding agent reads your app. The agent shows up as a chat widget in your base template. To act for the signed-in user, your app signs a short-lived token with PyJWT, Vatio verifies it, and the agent's private tools call your own endpoints, which verify the same token and answer only for that user. Your team supervises the conversations in the inbox, and nothing reaches your users until you publish.
#Add the agent in two steps
Step 1, from any chat. Add the remote MCP server https://vatio.ai/mcp as a connector in Claude, ChatGPT or Cursor, sign in with a Vatio account and ask for the agent. It is created in a preview you can try; nothing reaches your users until you publish (MCP).
Step 2, build it into the Django or FastAPI code. In the project, run:
npx @vatio-ai/skills/vatio-initRun the first line in a terminal at the root of your project, and the second in Claude Code (in Cursor or Codex, ask it to use the vatio-init skill). /vatio-init looks at what already exists, then writes the agent and pushes a preview. The first time it pushes, your browser opens to sign in with email, Google or GitHub. Then ask your coding agent to put the widget script tag in your base template. You can say what you want after /vatio-init:
/vatio-init a support agent for our Django app: answer from our docs and look up the signed-in user's invoicesAfter launch, /vatio-improve is the daily routine (After launch).
#What vatio-init finds in a Django or FastAPI project
vatio-init reads your app before it writes anything, tells you what it found and what it recommends, and then asks only what the code can't answer: what the agent may do, what it must never say or promise, and when a person takes over. In a Django or FastAPI project, that means:
- What the agent could call: your
urls.pyand views (Django REST Framework included), or your FastAPI routers. - How your users sign in:
django.contrib.author django-allauth, a FastAPI security dependency, or Supabase. Whichever it is, your backend signs the token (Authentication); on Supabase Auth, an Edge Function can (Supabase apps). - What it could answer from: your README, a
docs/folder, help and policy pages. What the agent needs from them becomes knowledge entries.
#Put the agent in your app
Ask your coding agent to add this code; it is shown here so you can review it.
The widget is one script tag in your base template, before </body>. Django templates and the Jinja2 templates FastAPI renders read it the same way:
<script async src="https://cdn.vatio.ai/v1/widget.js" data-workspace="ledgerly" data-token="{{ vatio_publishable }}" {% if vatio_token %}data-visitor-token="{{ vatio_token }}"{% endif %}></script>data-token takes a publishable vatpub_ key, made for page source: it selects one workspace and environment, and works only from the origins listed on it, plus Vatio's own pages and the app builders. Create one for production with vatio keys create publishable --env live --label web --origin https://app.ledgerly.example. Origins are exact scheme://host[:port] values, and a refused one is named in the browser console. They live on the token, never in vatio.yml: change them with vatio keys origins PREFIX URL... or in Settings → API keys in the inbox. Never put a vat_ developer token in a page. If your FastAPI app serves a separate frontend, return the user's token from an endpoint and pass it to window.VatioWidget.identify(token) instead.
#Let it act for signed-in users
vatio auth --new-key writes the key pair. identity.pub stays in the workspace, named in vatio.yml; your app gets its contents as VATIO_IDENTITY_PUBLIC_KEY, and identity.pem as VATIO_IDENTITY_PRIVATE_KEY, never in vatio secrets or git. Vatio holds only the public half, so it can check a token but never mint one. Your coding agent can add one module that signs and verifies:
import osimport timeimport jwt # pip install "pyjwt[crypto]"
WORKSPACE = "ledgerly" # your workspace slug: the token's aud
def widget_context(user): # user is None when nobody is signed in token = jwt.encode( {"sub": str(user.id), "aud": WORKSPACE, "exp": int(time.time()) + 3600, "email": user.email}, os.environ["VATIO_IDENTITY_PRIVATE_KEY"], algorithm="RS256", ) if user else None return {"vatio_publishable": os.environ["VATIO_PUBLISHABLE_TOKEN"], "vatio_token": token}
def vatio_subject(authorization): # the user id from a forwarded token, or None try: claims = jwt.decode( authorization.removeprefix("Bearer "), os.environ["VATIO_IDENTITY_PUBLIC_KEY"], algorithms=["RS256"], # pinned, never read from the token's header audience=WORKSPACE, options={"require": ["sub", "aud", "exp"]}) except jwt.InvalidTokenError: # bad signature, wrong aud, expired, missing return None return claims["sub"]In Django, a context processor puts the widget's values in every template, and the tool's endpoint is a plain view:
from django.http import JsonResponsefrom .models import Invoicefrom .vatio_identity import vatio_subject, widget_context
def vatio(request): # add to TEMPLATES' "context_processors" return widget_context(request.user if request.user.is_authenticated else None)
def my_invoices(request): # path("api/me/invoices", views.my_invoices) user_id = vatio_subject(request.headers.get("Authorization", "")) if user_id is None: return JsonResponse({"error": "unauthorized"}, status=401) invoices = Invoice.objects.filter(user_id=user_id).values("number", "status", "total")[:10] return JsonResponse({"invoices": list(invoices)})A view a tool sends a POST to needs @csrf_exempt: the verified token, not a cookie, is what authenticates it. In FastAPI, a route that renders a page passes the same values to its template, templates.TemplateResponse(request=request, name="home.html", context=widget_context(user)), with user from your own sign-in dependency, or None. The tool's endpoint reads the header:
from fastapi import Header, HTTPExceptionfrom .vatio_identity import vatio_subject
@app.get("/api/me/invoices")def my_invoices(authorization: str = Header("")): user_id = vatio_subject(authorization) if user_id is None: raise HTTPException(status_code=401, detail="unauthorized") return {"invoices": invoices_for(user_id)} # your query, scoped to user_idThe user comes from the token, never from a parameter. A private tool cannot declare a user id (user_id, customer_id, account_id and similar) as a parameter, and the deploy fails if it does. Scope every query by the id vatio_subject returns (Private tools).
#What your coding agent writes
These files live in your repository, so you can review them like the rest of your code. Below are an excerpt of vatio.yml and tools/my_invoices.yml, a private tool with no user id in it: your endpoint reads the user from the token. LEDGERLY_API, your app's address, is a secret your coding agent stores in Vatio with vatio secrets set, never in the repository. Tools covers tools that act, errors and statuses.
workspace: ledgerlyauth: public_key: identity.pubagent: instructions: | You help signed-in Ledgerly users with their invoices and how-to questions. Only state what my_invoices returned. Never promise a refund or a credit. tools: [my_invoices, knowledge_lookup] knowledge: [docs]description: The signed-in user's recent invoices, with status and total.when_to_use: The user asks about their own invoices, payments or charges.access: privaterequest: method: GET base_url: "$env.LEDGERLY_API" path: /api/me/invoices headers: Authorization: "Bearer $auth.token"respond: data: invoices: "$.invoices"#Try it, then publish
vatio-init's push ends with a link to the inbox, with Try the agent open, where you try the preview on WhatsApp, Instagram or a web chat. The web chat is a signed-out visitor, so it only reaches public tools. To try what a signed-in user gets, create a preview token for the address runserver or uvicorn prints (vatio keys create publishable --env preview --label dev --origin http://127.0.0.1:8000), use it as VATIO_PUBLISHABLE_TOKEN there and sign in as a test user. Tools run on Vatio's servers and can't reach localhost, so point preview at a tunnel with vatio secrets set LEDGERLY_API https://YOUR-TUNNEL --env preview; live keeps calling production. Ask about your own invoices and check them against your app, then ask for someone else's by name or email: it should find nothing. Each reply in the inbox shows the tool calls behind it, with what your endpoint answered. When the agent does what you want, ask your coding agent to publish.
#After launch
Your team reads real conversations in the inbox. On a wrong reply, Report this reply asks what should have happened, and that note becomes a test. Once a day, run /vatio-improve in your project: your coding agent fixes the cause where it lives (the instructions, a knowledge entry, a tool or your endpoint) and pushes it to preview, and you review the results and approve the update (Improve). The same agent answers on WhatsApp and Instagram (Pricing). There a private tool sends a Sign in button to your login page, which redirects back with the same token (On WhatsApp and Instagram).
#Frequently asked questions
#How do I add an AI agent to my Django or FastAPI app?
Step 1: add the MCP server https://vatio.ai/mcp as a connector in Claude, ChatGPT or Cursor, sign in and ask for the agent; it is created in a preview. Step 2: in your project run npx @vatio-ai/skills, then /vatio-init in Claude Code (in Cursor or Codex, ask it to use that skill). The widget is one script tag in your base template.
#How does the agent know which user is signed in to my Python app?
Your app signs a short-lived JWT for the signed-in user with PyJWT, using RS256 and the private key from vatio auth --new-key, with sub set to the user's id, aud to your workspace slug and an exp. The template renders it on the widget as data-visitor-token. Vatio verifies it with your public key, the only half it holds, so it can check a token but never create one.
#How should a Django or FastAPI endpoint verify a token from a Vatio tool?
Decode it with jwt.decode, passing the public key, algorithms limited to RS256 so the algorithm is pinned, audience set to your workspace slug, and sub, aud and exp required. PyJWT rejects an expired token. Answer 401 when anything fails, and take the user from the token's sub, never from the request body or query.
#Related guides
- Build an AI agent for your app: The two-step path with your coding agent, from the first chat to publishing.
- An AI chat for your web app or site: One script tag puts the agent in your web app or site, themed to your brand and able to recognize signed-in users.
- Support for signed-in users: Answer from your docs, check the user's own account, troubleshoot and open a ticket with what was tried.