AI agents by framework / Python
How to add an AI agent to a Flask app
An AI agent in a Flask app is a view that calls a model with tools, runs the tool calls the model makes against your own data layer as the logged-in user, and streams the reply to the page. The Anthropic Python SDK's tool runner covers the model loop synchronously, which suits Flask's WSGI workers; Flask-Login and Flask-WTF cover sign-in and CSRF. If the agent only needs to do what your pages already do, you can embed one instead.
What an agent in a Flask app is made of
- A view: a blueprint route such as
POST /agent/chatbehind Flask-Login's@login_requiredand Flask-WTF's CSRF check. It holds the model API key, which comes from the server environment. - Tools: functions the model may call, each wrapping a query or service your app already has, and each created for the current user.
- The agent loop: the model calls a tool, receives the result and decides the next step until it can answer. Cap the number of model calls.
- Streaming to the page: a generator returned as a
text/event-streamresponse, and a Jinja template with a small script that renders it. - Authorization and confirmation: tools read only the logged-in user's data, and the model can propose a change but the user makes it through your normal, CSRF-protected endpoint.
Ways to add an agent to Flask
| Approach | Good for | What you take on |
|---|---|---|
| A model provider's SDK (Anthropic or OpenAI) in a view | A custom agent with your own tools and UI, using synchronous code and few dependencies. | Tool definitions, authorization in every tool, a confirmation step for writes, rate and cost limits. |
| An agent library or framework (for example Pydantic AI or LangGraph) | Built-in approval flows, switching between model providers, multi-step workflows. | Another layer to learn and deploy, plus everything in the row above. |
| Hosted answer-only chat widget | Questions about your docs or help center. | Keeping its content current. It does not act in your app. |
| Embedded in-app agent (Bunkhouse) | Letting signed-in users get tasks done through the pages and API you already have. | A script tag, an identity endpoint and hints for operations discovery cannot see. |
Build an agent with the Anthropic Python SDK
The Anthropic Python SDK includes a tool runner (in beta) that calls Claude, runs the tools it asks for and sends the results back until it has an answer. The runner is synchronous, so it fits an ordinary Flask view under a WSGI server. The examples below use anthropic 2.3 with Flask 3.1, Flask-Login 0.6 and Flask-WTF 1.3:
pip install anthropic flask-login flask-wtfThe client reads the API key from the ANTHROPIC_API_KEY environment variable. The app is assumed to use Flask-Login for sign-in and to have Flask-WTF's CSRFProtect initialised, which checks every POST.
1. The blueprint
The @beta_tool decorator builds each tool's schema from its type hints and the Args section of its docstring. The view creates the tools per request inside order_tools, closing over current_user.get_id(), so the model can choose an order ID but never whose orders it reads.
With stream=True, the runner yields one event stream per model reply, and the view forwards text as server-sent events. The option max_iterations=5 stops the loop after five model calls. The view returns before the generator runs, so @stream_with_context keeps the request context alive for current_user inside it. As Flask's streaming guide warns, do not modify the session in the generator: the Set-Cookie header has already been sent.
The write needs the user's confirmation, and the runner can only hold a tool call while its loop runs inside one request. So cancel_order does not cancel anything: it checks that the order is the user's and still open, records a proposal and tells the model the user has been asked. With run_tools_eagerly=True, each tool runs as soon as the model finishes writing its call, so the proposal is ready when that reply's stream ends and goes to the page as a confirm event. The actual cancellation is the cancel view: an ordinary POST that Flask-Login and Flask-WTF check like any other, which looks the order up for the logged-in user again. Register the blueprint in your app factory with app.register_blueprint(bp).
import json
from typing import Literal
from anthropic import Anthropic, beta_tool
from flask import Blueprint, Response, abort, request, stream_with_context
from flask_login import current_user, login_required
from . import orders # your existing data layer
client = Anthropic() # reads ANTHROPIC_API_KEY
bp = Blueprint("agent", __name__)
INSTRUCTIONS = (
"You help the signed-in user manage their orders. Use the tools; never guess order data. "
"cancel_order only asks the user to confirm in the page; it does not cancel anything itself."
)
def order_tools(user_id: str, proposals: list[dict]):
@beta_tool
def list_orders(status: Literal["open", "shipped", "cancelled"] | None = None) -> str:
"""List the signed-in user's recent orders.
Args:
status: Only return orders with this status.
"""
return json.dumps(orders.list_orders(user_id, status=status))
@beta_tool
def cancel_order(order_id: str) -> str:
"""Ask the user to confirm cancelling one of their open orders.
Args:
order_id: The ID of the order to cancel.
"""
order = orders.get_order(user_id, order_id)
if order is None or order["status"] != "open":
return "The user has no open order with that ID."
proposals.append({"type": "confirm", "order_id": order_id})
return "The user was asked to confirm the cancellation in the page."
return [list_orders, cancel_order]
def sse(data: dict) -> str:
return f"data: {json.dumps(data)}\n\n"
@bp.post("/agent/chat")
@login_required
def chat():
message = request.form["message"]
@stream_with_context
def events():
proposals: list[dict] = []
runner = client.beta.messages.tool_runner(
model="claude-sonnet-5-5",
max_tokens=1024,
max_iterations=5,
system=INSTRUCTIONS,
tools=order_tools(current_user.get_id(), proposals),
messages=[{"role": "user", "content": message}],
stream=True,
run_tools_eagerly=True,
)
for stream in runner:
for event in stream:
if event.type == "text":
yield sse({"type": "text", "delta": event.text})
yield from map(sse, proposals)
proposals.clear()
return Response(events(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache"})
# The confirmation is an ordinary authenticated, CSRF-protected POST, not a model decision.
@bp.post("/orders/<order_id>/cancel")
@login_required
def cancel(order_id: str):
order = orders.cancel_order(current_user.get_id(), order_id)
if order is None:
abort(404)
return order2. The template
Flask-WTF's csrf_token() renders the token into a meta tag, and the script sends it in the X-CSRFToken header, which CSRFProtect accepts by default. The page posts with fetch because EventSource can only send GET requests, then reads the event stream from the response body. A confirm event becomes a button that posts to the cancel endpoint; the order ID comes from the model, so it is rendered as text and URL-encoded.
<meta name="csrf-token" content="{{ csrf_token() }}">
<form id="agent-form">
<input name="message" aria-label="Message" autocomplete="off" required>
<button>Send</button>
</form>
<div id="agent-output" aria-live="polite"></div>
<script>
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const form = document.getElementById("agent-form");
const output = document.getElementById("agent-output");
function post(url, body) {
return fetch(url, { method: "POST", headers: { "X-CSRFToken": csrfToken }, body });
}
function confirmCancel(orderId) {
const box = output.appendChild(document.createElement("p"));
const button = box.appendChild(document.createElement("button"));
button.textContent = "Cancel order " + orderId; // text, never HTML
button.onclick = async () => {
const response = await post("/orders/" + encodeURIComponent(orderId) + "/cancel");
box.textContent = response.ok ? "Order " + orderId + " cancelled." : "Could not cancel " + orderId + ".";
};
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await post("/agent/chat", new FormData(form));
form.reset();
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
const reply = output.appendChild(document.createElement("p"));
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const events = buffer.split("\n\n");
buffer = events.pop();
for (const event of events) {
const data = JSON.parse(event.slice("data: ".length));
if (data.type === "text") reply.textContent += data.delta;
if (data.type === "confirm") confirmCancel(data.order_id);
}
}
});
<\/script>This example answers one message per request. To carry a conversation across requests, store the messages on the server, keyed by the user, and pass them back as messages.
Production checklist for Flask
- Pick a worker type that can hold streams. Gunicorn's default
syncworker handles one request per process and restarts workers that stay silent longer than itstimeout(30 seconds by default), so a long answer ties up a whole process and can be cut off. Use thegthreadworker with several threads, orgevent, and size it for concurrent streams; see Gunicorn's settings. - Turn off proxy buffering. A reverse proxy that buffers responses holds the whole stream until it ends; disable buffering for the agent route and raise its read timeout.
- Keep CSRF and login checks on every route. Do not exempt the agent views from
CSRFProtect, keep@login_requiredon them, and scope every query in every tool to the logged-in user, not to an ID the model supplies. - Treat tool input and tool results as untrusted. The model chooses tool arguments, and data a tool returns (user-written text, emails, web pages) can contain instructions. Validate arguments with your own business rules, and keep irreversible actions behind a user confirmation.
- Limit cost. Cap model calls with
max_iterationsand output withmax_tokens, rate-limit the view per user (for example with Flask-Limiter) and set spending limits with your model provider. - Keep secrets server-side. Read the model key from the environment, not from a committed config file, and never render it into a template.
Option: embed an in-app agent with Bunkhouse
If the agent's job is to help signed-in users do work your Flask app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.
The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your Flask backend keeps enforcing its own authorization on every request.
In Flask, render the identity hook and then the loader in the base template's head, before your own scripts. For signed-in users, register a blueprint with a GET /api/agent-identity route that signs a short-lived token for the user in g.user; with Flask-Login, set g.user from current_user in a before_request hook. The signing secret stays in a server environment variable.
<head>
<!-- ... -->
<script src="{{ url_for('static', filename='agent-identity.js') }}"><\/script>
<script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
<!-- your own scripts come after the loader -->
</head>The tested Flask integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.
When Bunkhouse fits, and when to build it yourself
- Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
- Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
- Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
- Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.
Try it on your Flask app first
A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your Flask app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Flask page at http://127.0.0.1:5000.Use a page with disposable data: everything the assistant reads is sent to your agent.
The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.
Related guides
- How to add AI to your website: chat assistants, in-app agents and custom builds compared
- MCP vs WebMCP: choosing where outside agents act on your site
- AI agents in other Python frameworks: Django, FastAPI