AI agents by framework / Rust

How to add an AI agent to an Actix Web app

An AI agent in an Actix Web app is a handler that calls a model with tools, runs the tool calls the model makes as the signed-in user, and streams the reply to the browser as server-sent events. Rig supplies the model clients and the tool-calling loop; middleware supplies the user and HttpResponse::streaming sends the events. If the agent only needs to do what your pages already do, you can embed one instead.

What an agent in an Actix Web app is made of

  • A server endpoint: an async handler in a web::scope("/api/agent") that calls the model. The agent is built once and shared with every worker as web::Data, and the model API key is read from the server environment, never rendered into a template or a static file.
  • Tools: Rust functions the model may call, each wrapping an operation your app already has (an sqlx or Diesel query, a service call, an internal API).
  • The agent loop: the model calls a tool, receives the result and decides the next step until it can answer. Cap the number of turns.
  • A chat UI: the page your templates already render, plus a small script that posts the message and reads the reply as server-sent events. Actix Web 4 itself has no SSE response type; the handler streams text/event-stream bytes with HttpResponse::streaming.
  • Authorization and approval: middleware on the agent scope puts the signed-in user into the request extensions, tools act only as that user, and changes to data wait for the user to confirm them.

Ways to add an agent to Actix Web

ApproachGood forWhat you take on
Rig in an Actix Web handlerA custom agent with your own tools, any of Rig's model providers and your own UI.Tool definitions, authorization in every tool, the confirmation flow, the client, rate and cost limits.
A provider client with your own loop (for example async-openai)Full control over every request to one OpenAI-compatible API.Writing the tool-calling loop, tool schemas and stream handling yourself, plus everything in the row above.
Hosted answer-only chat widgetQuestions 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 Rig

Rig is a Rust library for LLM applications: one API over many model providers, typed tools and an agent loop that runs tool calls and streams every step. The examples below use Rig 0.46 (which needs Rust 1.97.1 or newer) with Actix Web 4. Actix Web runs on Tokio, so Rig's bundled HTTP client works inside handlers without extra setup. Add the crates with:

Terminal
cargo add [email protected] actix-web@4 futures serde serde_json --features serde/derive

The examples call Claude through Rig's Anthropic provider, which reads the API key from the ANTHROPIC_API_KEY variable. Other providers, such as OpenAI or OpenRouter, only change the client and model lines.

1. Tools scoped to the user

The #[rig::rig_tool] macro turns an async function into a tool: the doc comment becomes the description, and the arguments become the JSON Schema the model fills in. The signed-in user's ID is not an argument. It travels in Rig's tool context, which the handler fills for each run and the model cannot see or set, so the model can choose an order ID but never whose orders it reads. Your data layer still checks that the order belongs to that user.

cancel_order does not cancel anything. It checks that the order is open and returns a proposal that the page asks the user to confirm, and the confirmed request goes to the cancel endpoint your app already has. Rig can also gate tool calls in an on_dispatch hook or pause a durable run for approval, but a hook waits inside the running request, and a durable run needs storage for the paused state.

src/tools.rs
use rig::prelude::*;
use rig::providers::anthropic::{self, Anthropic};
use rig::tool::ToolExecutionError;
use serde::{Deserialize, Serialize};

use crate::orders; // your existing data layer

/// The signed-in user's ID. The handler sets it for each run; the model never does.
#[derive(Serialize, Deserialize, rig::ContextValue)]
pub struct UserId(pub String);

/// List the signed-in user's recent orders.
#[rig::rig_tool(params(status = "Only orders in this state: open, shipped or cancelled"))]
async fn list_orders(
    ctx: &mut rig::tool::ToolContext,
    status: Option<String>,
) -> Result<Vec<orders::Order>, ToolExecutionError> {
    let user = ctx.require::<UserId>()?;
    orders::list(&user.0, status.as_deref())
        .await
        .map_err(|_| ToolExecutionError::other("could not load orders"))
}

#[derive(Serialize)]
pub struct PendingCancel {
    order_id: String,
    needs_confirmation: bool,
}

/// Ask to cancel one of the signed-in user's open orders. The user confirms it in the page.
#[rig::rig_tool(params(order_id = "The order to cancel"))]
async fn cancel_order(
    ctx: &mut rig::tool::ToolContext,
    order_id: String,
) -> Result<PendingCancel, ToolExecutionError> {
    let user = ctx.require::<UserId>()?;
    match orders::find_open(&user.0, &order_id).await {
        Ok(Some(_)) => Ok(PendingCancel { order_id, needs_confirmation: true }),
        _ => Err(ToolExecutionError::not_found("no open order with that id")),
    }
}

pub fn build_agent() -> Result<Agent, Box<dyn std::error::Error>> {
    // Reads ANTHROPIC_API_KEY from the environment.
    let model = Anthropic::from_env()?.completion(anthropic::CLAUDE_SONNET_5_5);
    Ok(AgentBuilder::new(model)
        .preamble("You help the signed-in user manage their orders. Use the tools; never guess order data.")
        .tool(ListOrders)
        .tool(CancelOrder)
        .build())
}

2. Middleware and the streaming handler

require_user is a from_fn middleware: it looks up the signed-in user from your session, answers 401 for signed-out requests before any handler runs, and stores CurrentUser in the request extensions. The handler reads it with web::ReqData, puts the ID into the tool context, caps the run with max_turns(5) and calls stream(), which yields the model's text fragments, tool calls and tool results as they happen. The sse helper formats each item as one server-sent event with a JSON payload, so newlines in the model's text cannot break the event format, and HttpResponse::streaming sends the events as they are produced.

src/agent.rs
use std::convert::Infallible;

use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized};
use actix_web::http::header::{CacheControl, CacheDirective};
use actix_web::middleware::Next;
use actix_web::{Error, HttpMessage, HttpResponse, web};
use futures::StreamExt;
use rig::agent::{Agent, MultiTurnStreamItem};
use rig::streaming::{Item, StreamEvent};
use rig::tool::ToolContext;
use serde::{Deserialize, Serialize};

use crate::auth; // your existing session lookup
use crate::tools::UserId;

#[derive(Clone)]
pub struct CurrentUser {
    pub id: String,
}

/// Middleware for the agent scope: signed-out requests get 401 and never reach the handler.
pub async fn require_user(
    req: ServiceRequest,
    next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
    let id = auth::session_user_id(req.request())
        .await
        .ok_or_else(|| ErrorUnauthorized("sign in first"))?;
    req.extensions_mut().insert(CurrentUser { id });
    next.call(req).await
}

#[derive(Deserialize)]
pub struct ChatRequest {
    message: String,
}

/// One server-sent event with a JSON payload.
fn sse(name: &str, data: &impl Serialize) -> Option<web::Bytes> {
    let json = serde_json::to_string(data).ok()?;
    Some(web::Bytes::from(format!("event: {name}\ndata: {json}\n\n")))
}

pub async fn chat(
    user: web::ReqData<CurrentUser>,
    agent: web::Data<Agent>,
    body: web::Json<ChatRequest>,
) -> Result<HttpResponse, Error> {
    let mut context = ToolContext::new();
    context
        .insert(UserId(user.into_inner().id))
        .map_err(ErrorInternalServerError)?;

    let events = agent
        .prompt(body.into_inner().message)
        .tool_context(context)
        .max_turns(5)
        .stream()
        .filter_map(|item| async move {
            let frame = match item {
                Ok(MultiTurnStreamItem::StreamAssistantItem(Item::Event(StreamEvent::Text { text, .. }))) => {
                    sse("text", &text)
                }
                Ok(MultiTurnStreamItem::ToolResult { tool_result })
                    if tool_result.name == "cancel_order" && !tool_result.is_error =>
                {
                    sse("confirm", tool_result.content.first()?.as_json()?)
                }
                Err(_) => sse("error", &"The agent stopped. Try again."),
                _ => None,
            };
            frame.map(Ok::<_, Infallible>)
        });

    Ok(HttpResponse::Ok()
        .content_type("text/event-stream")
        .insert_header(CacheControl(vec![CacheDirective::NoCache]))
        .streaming(events))
}

Build the agent once, before HttpServer::new. The application factory runs once per worker, and web::Data wraps the agent in an Arc so every worker shares it. Wrapping only the scope keeps the middleware off your other routes:

src/main.rs
mod agent;
mod auth;
mod orders;
mod tools;

use actix_web::middleware::from_fn;
use actix_web::{App, HttpServer, web};

#[actix_web::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let agent = web::Data::new(tools::build_agent()?);

    HttpServer::new(move || {
        App::new()
            .app_data(agent.clone())
            // ...your existing session middleware and routes
            .service(
                web::scope("/api/agent")
                    .wrap(from_fn(agent::require_user))
                    .route("", web::post().to(agent::chat)),
            )
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await?;
    Ok(())
}

This handler answers one message at a time. To keep a conversation, store the messages from the final response per user on the server and pass them to the next run with .history(..); see Rig's streaming guide.

3. The browser client

The browser's EventSource only sends GET requests, so the client posts the message with fetch and reads the events from the response body. On a confirm event it shows a button; the click calls your existing cancel endpoint with the same session cookie and CSRF token as any other form on the page.

static/agent.js
const log = document.querySelector("#agent-log");
const form = document.querySelector("#agent-form");
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const message = form.elements.message.value;
  form.reset();
  const reply = log.appendChild(document.createElement("p"));

  const res = await fetch("/api/agent", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message }),
  });
  if (!res.ok) {
    reply.textContent = "Error " + res.status;
    return;
  }

  // Read the server-sent events from the response body.
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;
    const frames = buffer.split("\n\n");
    buffer = frames.pop();
    for (const frame of frames) {
      const name = /^event: (.*)$/m.exec(frame)?.[1];
      const data = /^data: (.*)$/m.exec(frame)?.[1];
      if (!data) continue;
      if (name === "text") reply.textContent += JSON.parse(data);
      if (name === "error") reply.textContent = JSON.parse(data);
      if (name === "confirm") confirmCancel(JSON.parse(data).order_id);
    }
  }
});

// The agent only proposes the cancellation; the user's click calls your normal endpoint.
function confirmCancel(orderId) {
  const button = log.appendChild(document.createElement("button"));
  button.textContent = "Cancel order " + orderId;
  button.addEventListener("click", async () => {
    button.disabled = true;
    const res = await fetch("/orders/" + encodeURIComponent(orderId) + "/cancel", {
      method: "POST",
      headers: { "X-CSRF-Token": csrfToken },
    });
    button.textContent = res.ok ? "Order " + orderId + " cancelled" : "Could not cancel (" + res.status + ")";
  });
}

Production checklist for Actix Web

  • Authorize in middleware and in every tool. Middleware on the scope covers every route added to it later, and web::ReqData fails with a 500 if the middleware is missing instead of running without a user. Every query a tool makes is scoped to the ID from the tool context, never to an ID the model supplies.
  • Protect writes against CSRF. Actix Web has no built-in CSRF middleware. web::Json requires a JSON Content-Type by default, which a cross-site form cannot send and a cross-site fetch only sends after a CORS preflight, so do not configure actix-cors to allow credentials from other origins. The cancel endpoint keeps whatever CSRF check your app already uses.
  • 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 anything irreversible behind the user's confirmation.
  • Limit cost. Cap turns, rate-limit the scope per user (in your middleware or with a crate such as actix-governor), and set spending limits with your model provider.
  • Never block a worker. HttpServer starts one worker per physical CPU by default, and each worker handles its requests on a single thread. A blocking call in a tool stalls every request on that worker, including other users' streams; use async database drivers or web::block, as the server documentation describes.
  • Allow for long streams. On shutdown, workers get 30 seconds by default to finish before they are dropped, so a deploy can cut off a long run; raise shutdown_timeout if your runs take longer. Make sure your reverse proxy does not buffer text/event-stream responses and allows reads as long as several model turns take.
  • Keep secrets server-side. ANTHROPIC_API_KEY belongs in the server environment or a secret manager, never in templates or static files.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your Actix Web 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 Actix Web backend keeps enforcing its own authorization on every request.

In Actix Web, render the identity hook and the loader in the <head> of your layout template, before your application's own scripts, so they load once per document. For signed-in users, register a GET /api/agent-identity handler inside your session middleware (for example with configure). It takes Option<CurrentUser>, whose FromRequest implementation reads your session, and signs a short-lived token for that user with jsonwebtoken; the signing secret stays in a server environment variable.

templates/layout.html (excerpt)
<head>
  <!-- The identity hook first, then the loader, before your application's own scripts -->
  <script src="/agent-identity.js"><\/script>
  <script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
</head>

The tested Actix Web 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 Actix Web 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 Actix Web 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:

Agent trialPrompt for your coding agent
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Actix Web page at http://localhost:8080.

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.