We added a chatbot to Pulse, the app we run our agency on. Of course we did; it's 2026 and everyone is adding a chatbot to everything. Pulse tracks projects, sprints, time logs, and client repos for a few hundred users, which right now means 258 projects and about 43 thousand tracked work items. The chatbot needed tools: list my projects, show this sprint, read that pull request. Standard function calling, nothing exotic.

Then we hit the question that teams building like this hit, usually about three tools in: who's actually making this request, the chatbot or the user?

Our answer ended up being the user. That one decision settl, because the same code that authorizes every other request can authorize this one, and the alternative is to build your authorization a second time and watch it drift out of sync with the first. The mechanism for reusing the original is a Rails API you already have installed. It's been sitting in your test suite the whole time. Two ideas carry the whole pattern: the agent is just a user, making requests by other means, and the LLM is just another format your controllers respond to, sitting next to format.html and format.json like it was always supposed to be there. Everything else, including every section below, is plumbing between those two sentences, and the whole of it comes to about 750 lines counting the comments.

The trap: a second authorization layer you didn't mean to write

Here's the trap, and lots of teams building tools walk into it. Your app already knows who can see what. Years of authorization fixes live in your controllers, your policies, your scopes. The moment you hand those resources to a model through a separate tool layer, you start re-deriving all of it in a second place: which rows this user can read, which actions they can take, which fields they're allowed to see. That second copy is never quite the first. It's missing the edge cases you patched last fall, and it drifts a little further from the real one every sprint.

We call it the shadow API: a second set of endpoints with the same names as your real ones, minus the years of fixes. Everything below is one long argument for not building it. The two ideas that carry the pattern both reduce to the same move, which is to make the model go through the authorization you already have instead of around it.

But first, the alternatives, because we tried to like them.

The paths we didn't take: why the obvious fixes don't escape the trap

The default path is the one the library docs show you. We use RubyLLM and we like it a lot; acts_as_chat persistence alone pays for the dependency. Its tools guide tells you to subclass RubyLLM::Tool and write an execute method, and the security section says this:

Treat any arguments passed to your execute method as potentially untrusted user input, as the AI model generates them based on the conversation.

Good advice. Now search that page for the words user, tenant, or permission. Nothing. That's not a knock on RubyLLM, which is an LLM client and has no idea Pundit exists. But it means the obvious implementation looks like this:

class ListProjects < RubyLLM::Tool
  description "Lists the user's projects"
  def execute
    Project.all.map { |p| { id: p.id, name: p.name } }
  end
end

Project.all. Whose projects? Everyone's projects. To fix it you thread the current user into every tool and re-apply your scoping rules inside every execute. Congratulations: you've just built the shadow API by hand, one execute method at a time.

What about MCP? MCP is great for what it's for: exposing tools to agents you don't control, across a process boundary. The price is that you, a person who wanted to add a chatbot to your app, now operate an OAuth resource server. The authorization spec needed a community rescue by Aaron Parecki, got rewritten in June 2025, still has a documented confused deputy problem, and enterprise identity folks remain unconvinced. All of that machinery exists to answer one question: what is this agent allowed to do on behalf of this user? If the agent is your own chatbot inside your own app, your session already answers that question on every request. Standing up a second auth system to re-derive it is work you get to do twice and desync once.

Third option: have tools call your own REST API over actual HTTP. Warmer! The controllers do the authorizing. But now you need API tokens for an internal caller, the tokens are almost always coarser than a session ("can read projects" vs. "is Felipe"), and you've added a network hop so your chatbot can talk to the process it's running in.

We wanted the third option without the HTTP and without the tokens.

Idea one: the agent is just a user

Start with the first idea: an agent acting on behalf of a user is that user, making requests by other means. The auth world has precise words here. The user is the principal, the identity on whose authority things happen, and the software doing the asking is the actor. The authorization question your app answers on every request was always about the principal, never about which software asked. The web even named the actor decades ago: HTTP calls the browser a user agent, software that makes requests on behalf of a user. Every request your app has ever served came from an agent acting for a principal. The browser just happened to be a very obedient one, and an LLM is only the newest one.

Rails, meanwhile, has shipped a way to make authenticated, full-stack, in-process requests since before any of us had heard the word "agent": ActionDispatch::Integration::Session. It's what your integration tests use, assuming you write them. It runs the entire middleware stack (routing, Warden, strong params, Pundit, your jbuilder views) over an in-process Rack call, with no server or socket involved.

So in Pulse, a tool call is a real request, signed in as the chatting user. A small dispatcher wraps an integration session, mints a short-lived signed token carrying the user's id, and attaches it to every request through the Rack env rather than a header. It also introduces itself honestly, with a User-Agent of Pulse-Chatbot.

Putting the token on the env, not a header, is what makes it safe. A request has two kinds of input: the parts a remote client controls (headers, params, the URL) and the Rack env, which only in-process code can write. Put the token in a header and two bad things follow: every tool that snapshots request data (error trackers, APM, proxy access logs) records a live credential, and because the auth strategy is global, anyone who replays that header against your public app authenticates as the user. Put it on the env instead and both problems vanish. The capture tools don't read arbitrary env keys, and a remote client has no way to set one, so the public boundary can't present the token at all. It is reachable only from inside the process that minted it.

From the controller's point of view, a tool call is the same principal who clicks around the app, arriving through a different user agent that introduces itself honestly in the User-Agent header, because that is literally what it is. current_user is correct. policy_scope filters rows. Strong params reject junk arguments the model hallucinated. The tool cannot leak what the user cannot see, because there is no tool-specific data path to forget the check in.

Who checks that token? Warden, the auth framework already sitting underneath Devise. Warden is a strategy framework at heart, and authenticating from a signed token is just one more strategy: it reads the token off the env slot the dispatcher set, verifies the signature, loads the user, and refuses to serialize anything into the session so the credential can't outlive its request. One unshift in the Devise initializer puts it ahead of the cookie and password strategies. The whole thing is sixty-one lines, most of them comments.

A real request runs your whole stack, and the agent runs it in ways a browser never did. Two things follow, and both fall straight out of the agent being a real request.

What the agent breaks first: the policy check that never ran

Reusing your controllers means inheriting their authorization, which is the whole point. But the agent exercises those endpoints in ways a browser never did: the model will call projects_index with no arguments on its first turn just to see what comes back, where a page only ever lists through the sidebar with a scope already applied. An action that looks correct in the UI but never actually applied a policy has simply never been asked the awkward question before. The agent asks it on turn one.

Which is why one Pundit setting stops being optional the day you do this. Pundit ships two after_actions that raise at response end if an action never consulted a policy:

# in ApplicationController
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index

Turn those on and a forgotten scope fails loudly on the next request instead of quietly returning everything. The rule is simple: don't hand the model an endpoint whose policy check isn't guaranteed to run. The cleanest way to guarantee it is to make exposing an action arm its verifier in the same breath, so the two can never drift apart.

The only new code: one signed token doing two jobs

Reusing the request pipeline means inheriting CSRF protection, which will correctly reject in-process POSTs that carry no authenticity token. The escape hatch is the same token that authenticates the request. The dispatcher mints it fresh for every dispatch with Rails' own message_verifier, signs it with secret_key_base, gives it a five-minute expiry, and reads it back off the same env slot to wave the request past CSRF. One token, two consumers: the Warden strategy reads the user out of its payload, and the CSRF check rides the same verification.

Three properties keep it honest. It's signed with your master key, so no one can mint one without it. It expires after five minutes. And because it travels on the env rather than a header, it never reaches a log or an error tracker, and the public boundary can't replay it even if it somehow did leak. It is a bearer credential, but one that lives and dies inside one process.

Together with the Warden strategy, this is the entire amount of novel security code the pattern required. We've written bigger commit messages.

Idea two: the LLM is just another format your app already negotiates

The oldest idea in Rails is that one controller serves many representations of the same resource: respond_to has been negotiating HTML and JSON and XML from the same action since DHH was blogging about REST. Every pattern we surveyed earlier quietly abandons that idea: it puts the model behind a separate tool layer, a separate server, a separate API. We registered a MIME type instead.

# config/initializers/mime_types.rb
Mime::Type.register "application/x-llm", :llm

After that one line, the model is just another format the app knows how to negotiate. The dispatcher's Accept: application/x-llm, application/json;q=0.9 is ordinary content negotiation, and the controller answers it the way it answers everything else:

respond_to do |format|
  format.html # people with browsers
  format.json # programs
  format.llm { render_llm } # models
end

Your app has been multi-audience since the day you added format.json. The LLM is the first new audience to show up in about fifteen years, and it slotted into a twenty-year-old API without that API noticing.

The model gets its own representation rather than eating the JSON because it's an audience with expensive attention. Views answer the question "what should this audience see". For this audience, every field is tokens, billed and re-read on every subsequent turn of the conversation. So exposed actions render an *.llm.jbuilder template: a deliberately tight projection, 25 items a page, a pagination envelope the tool description teaches the model to read. The JSON view keeps serving browsers and picking up whatever fields the frontend needs; the LLM view doesn't inherit them. We deliberately do not fall back to .json when an .llm template is missing; boot fails instead. A silent fallback means the model's context inherits whatever a frontend needed last month.

format.llm is a sentence we did not expect to write in 2026, but it's done more for our token bill than any prompt engineering. And it gives token budgeting a home: it's a rendering concern, in the view layer, where projections of a resource have always lived. When the model needs less detail, you edit a jbuilder template. Nobody touches the agent.

A tool is just a declaration over a controller

If the LLM is just a format, a tool is just a pointer to the action that renders it. A tool in Pulse is pure declaration. There are thirty-three of them, and each one is a small file like this:

module Chatbot
  module Tools
    class ProjectsIndex < Chatbot::Tool
      action ProjectsController, :index
      summary "Returns the projects the current user can see. " \
              "The response is already scoped to the user."
      param :status, type: :string,
        desc: "Restrict by status (proposal, fulfilment, inactive, archived)."
      param :page, type: :integer,
        desc: "Page number (1-indexed)."
    end
  end
end

If this reads like a mailer, that's deliberate. The controller stays a plain HTTP controller; its only nod to the LLM is an allowlist of the actions and params it's willing to expose:

class ProjectsController < ApplicationController
  include ExposesToLlm
  exposes_to_llm :index, params: %i[status q organization_id page]
  exposes_to_llm :show
end

A registry walks the tool classes at boot and synthesizes a RubyLLM::Tool subclass for each, pointing at the matching route. The declaration and the controller have to agree, and the registry checks the whole handshake at boot: the action called exposes_to_llm, every param the tool advertises is in the controller's allowlist, a route exists, and the .llm template exists. Any mismatch refuses the boot with a named error. Rename a controller action and the app won't start until the tool file agrees. Without those checks a renamed action would synthesize a tool that 404s on every call, and a missing .llm view would silently feed the model whatever the JSON view emits. Both now cost a red boot instead of a confused afternoon. We've been saved by this twice already.

Field notes: the conveniences we never had to build

None of these were designed up front. They fell out of tools being declarations over controllers, and each one would have been real work in a hand-rolled tool layer.

Identity is not a tool parameter. Everything in a tool's schema is something the model can set, so the user must never appear there. Our agent injects identity in a side channel instead, overriding each tool's call to pass a server-side context that carries the user the conversation belongs to, captured when the agent was built. There is no user_id argument to hallucinate, no prompt injection that can ask for someone else's rows. The model literally has no vocabulary for "as a different user".

Status messages nobody wrote. During a multi-step turn the chat UI shows what the model is doing: "Looking up time logs", then "Loading the pull request". That copy doesn't exist anywhere in the codebase. It's derived from the controller and action the tool dispatches to: an index becomes "Looking up projects", a show becomes "Loading the project". When someone adds a tool, the status message is already there, because it falls out of the controller name. It's the same convention-over-configuration bet Rails makes everywhere else, pointed at a part of the app we didn't expect to point it at.

Tools that excuse themselves. Every tool class answers available_for?(context:, user:) before the agent binds it to a chat. The repository tools (file tree, commits, pull requests) return false when the page you're chatting from has no repository attached, so the model never sees a tool it can't use. Fewer schemas in the request means fewer tokens spent on every single turn, and no doomed calls for the model to flail through.

This is what signing the agent in buys you: not just the authorization you already wrote, but a handful of conveniences you didn't. The chatbot turned out to be the app you already had, answering one more kind of request.

If you've built the write-tool story properly, or you think signing an agent into Warden is an abomination and you have a better way to reuse ten years of authorization code: we want to hear it.

Athlete pole vaulting over a bar