# How to add an AI agent to your Rails app

To add an AI agent to your Rails 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](#add-the-agent-in-two-steps)). Your coding agent reads your routes, controllers and sign-in. The agent shows up as a chat widget rendered by your layout, and with the `vatio-sdk` gem it knows who is signed in: your app signs a short-lived token for the current user, Vatio verifies it, and the agent's private tools call your own controllers for that user's data. 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](https://vatio.ai/docs/mcp)).

**Step 2, build it into the Rails app.** In the project, run:

```text
npx @vatio-ai/skills
/vatio-init
```

Run the first line in a terminal at the root of your Rails app, 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 tag in your layout. You can say what you want after `/vatio-init`:

```text
/vatio-init an agent for our signed-in customers: answer from our help pages and check their orders through our Rails app
```

After launch, `/vatio-improve` is the daily routine ([After launch](#after-launch)).

## What vatio-init finds in a Rails project

vatio-init reads your app before it writes anything, then tells you what it found and what it recommends:

- **What the agent could call:** `config/routes.rb` and the controllers behind it, starting with the ones that already answer JSON.
- **How your users sign in:** Devise, `has_secure_password`, the Rails authentication generator, or Supabase. Whichever it is, your backend signs the token ([Authentication](https://vatio.ai/docs/authentication/)); on Supabase Auth, an Edge Function can ([Supabase apps](https://vatio.ai/docs/guides/ai-agent-for-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.

Then it 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. The workspace usually goes in the repository of the backend the agent calls, here your Rails app, so `vatio.yml`, `tools/` and `identity.pub` sit next to the code they describe.

## Put the agent in your app

Ask your coding agent to add this code; it is shown here so you can review it.

Your coding agent adds `gem "vatio-sdk", "~> 0.6"` to your Gemfile and configures it once:

```ruby
# config/initializers/vatio.rb
Vatio::Identity.configure do |c|
  c.audience    = "ridgeline"                              # your workspace slug
  c.private_key = ENV.fetch("VATIO_IDENTITY_PRIVATE_KEY")  # the PEM, not a path
end
```

The widget goes in `app/views/layouts/application.html.erb`, before `</body>`:

```erb
<%= raw Vatio::Identity.widget_tag(
      workspace: "ridgeline",
      token: ENV.fetch("VATIO_PUBLISHABLE_TOKEN"),
      subject: current_user&.id,
      claims: { name: current_user&.name, email: current_user&.email }) %>
```

`current_user` is Devise's name; use whatever returns your signed-in user. The `vatpub_` key is a publishable 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:

```bash
vatio keys create publishable --env live --label web --origin https://ridgeline.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. Rails renders the tag per request, so it is safe by default; if a page cache, a CDN or `caches_action` stores the layout, render it without `subject:` and call `window.VatioWidget.identify(token)` from an uncached endpoint ([Ruby gem](https://vatio.ai/docs/sdk/ruby)).

## Let it act for signed-in users

`subject:` is what tells the agent who the user is. `widget_tag` signs an RS256 token with `sub` set to the user's id, `aud` to your workspace slug and `exp` an hour away, and renders it as `data-visitor-token`. A signed-out visitor gets no token and only the agent's public tools. The key pair comes from `vatio auth --new-key`: `identity.pub` stays in the workspace, named in `vatio.yml`, and `identity.pem` goes into your app as the `VATIO_IDENTITY_PRIVATE_KEY` environment variable, never into `vatio secrets` or git. Vatio holds only the public half, so it can check a token but never mint one.

A tool marked `access: private` forwards that token to your API as `Authorization: Bearer`. The controller behind it verifies it with the same gem:

```ruby
# app/controllers/api/orders_controller.rb
class Api::OrdersController < ActionController::API
  include Vatio::Identity::Authentication
  before_action :authenticate_vatio!   # 401 unless the token verifies

  def index
    orders = Order.where(user_id: vatio_subject).order(created_at: :desc).limit(10)
    render json: { orders: orders.as_json(only: %i[number status total]) }
  end
end
```

It answers `GET /api/me/orders` (`get "me/orders", to: "orders#index"` inside `namespace :api` in your routes). `authenticate_vatio!` checks the signature with the algorithm pinned, never read from the token's header, requires `aud` to be your slug and `exp` to be in the future, and answers `401` otherwise: the same check Vatio runs. Inheriting from `ActionController::API` keeps your session login and CSRF check out of it, so a `POST` tool works too.

**The 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 `vatio_subject`, and by nothing the request body says ([Private tools](https://vatio.ai/docs/authentication/tools)).

## What your coding agent writes

You don't need to edit these files by hand, but they live in your repository, so you can review them like the rest of your code. An excerpt of `vatio.yml`:

```yaml
workspace: ridgeline
auth:
  public_key: identity.pub
agent:
  name: Ridgeline
  instructions: |
    You help signed-in customers of Ridgeline Outfitters with their orders
    and answer product, shipping and returns questions from knowledge.
    Orders: call my_orders and only state what it returned; if it could not
    confirm who the user is, ask them to sign in. Never promise a refund.
  tools: [my_orders, knowledge_lookup, ask_for_a_person]
  knowledge: [help]
```

`tools/my_orders.yml`:

```yaml
description: The signed-in customer's recent orders and their status.
when_to_use: The customer asks about their own orders, a delivery or a return.
access: private
request:
  method: GET
  base_url: "$env.RIDGELINE_API"
  path: /api/me/orders
  headers:
    Authorization: "Bearer $auth.token"
respond:
  data:
    orders: "$.orders"
```

There is no customer id in it: your controller reads the user from the token. `RIDGELINE_API`, your app's address, is a secret your coding agent stores in Vatio with `vatio secrets set`, never in the repository. [Tools](https://vatio.ai/docs/tools) covers tools that act, errors and statuses.

`tools/ask_for_a_person.yml`, which the agent calls when a person should take over:

```yaml
description: Tell the team that this conversation needs a person.
when_to_use: >
  The customer asks for a refund or disputes a charge, you cannot resolve it
  with what you know and your other tools, or they ask to talk to a person.
parameters:
  type: object
  properties:
    reason:
      type: string
      description: Why it needs a person, in one line, for whoever picks it up.
  required: [reason]
access: public
request:
  method: POST
  base_url: "$env.RIDGELINE_API"
  path: /api/vatio/ask-for-a-person
  requires: [chat.url]
  body:
    reason:
    chat_url: "$chat.url"
    name: "$contact.name"
    phone: "$contact.phone_number"
respond:
  message: >
    The team was told. Tell the customer plainly that you are checking this
    with a colleague, who will answer here.
```

The controller behind it alerts whoever handles refunds, with a mailer, a Slack message or a WhatsApp that carries `chat_url`, the link that opens the conversation in the inbox. Their reply there takes it over, and the agent stops answering on that chat; until then it keeps helping. See [Hand off to a person](https://vatio.ai/docs/handoff).

## 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, give your development app preview's widget:

```bash
vatio keys create publishable --env preview --label dev --origin http://localhost:3000
```

Set that key as `VATIO_PUBLISHABLE_TOKEN` in development and sign in as a test user. Tools run on Vatio's servers and can't reach `localhost`, so point preview at a tunnel to your app with `vatio secrets set RIDGELINE_API https://YOUR-TUNNEL --env preview`; live keeps calling production. Then ask about your own orders and check them against your app, ask for someone else's by name or email (it should find nothing), and ask for a refund (it should call `ask_for_a_person` and say it is checking with a colleague; your reply from the inbox takes the conversation over). Each reply in the inbox shows the tool calls behind it, with what your controller answered. When the agent does what you want, ask your coding agent to publish. Nothing reaches your users until you do.

## 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 controller) and pushes it to preview, and you review the results and approve the update ([Improve](https://vatio.ai/docs/improve)). The same agent answers on WhatsApp and Instagram ([Pricing](https://vatio.ai/pricing)). A chat there has no page to carry a token, so the agent needs a `sign_in_url`, and with vatio-sdk that page is one redirect to `Vatio::Identity.sign_in_redirect_url` ([Sign in from WhatsApp and Instagram](https://vatio.ai/docs/sdk/ruby#sign-in-from-whatsapp-and-instagram)).

## Frequently asked questions

### How do I add an AI agent to my Rails 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 Rails project run npx @vatio-ai/skills, then /vatio-init in Claude Code (in Cursor or Codex, ask it to use that skill). Your coding agent reads your routes, controllers and sign-in, and the widget goes in your layout with one helper from the vatio-sdk gem.

### How does the agent know which user is signed in to my Rails app?

The vatio-sdk gem's widget_tag signs a short-lived RS256 token for the signed-in user, with the private key from vatio auth --new-key, and renders it on the widget's script tag. Vatio verifies it with your public key, the only half it holds, so it can check who someone is but never create a token. A signed-out visitor gets no token and only the agent's public tools.

### How does a Rails controller check a request from a Vatio tool?

Include Vatio::Identity::Authentication and add before_action :authenticate_vatio!. It verifies the forwarded token with the algorithm pinned, requires an aud equal to your workspace slug and an exp that has not passed, and answers 401 otherwise. Then scope every query to vatio_subject, the user id from the token's sub.

## Related guides

- [Build an AI agent for your app](https://vatio.ai/docs/guides/build-an-ai-agent): The two-step path with your coding agent, from the first chat to publishing.
- [An AI chat for your web app or site](https://vatio.ai/docs/guides/ai-chat-widget-for-your-website): 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](https://vatio.ai/docs/guides/ai-agent-for-customer-support): Answer from your docs, check the user's own account, troubleshoot and open a ticket with what was tried.
