On this page

#How to add an AI agent to your Laravel app

To add an AI agent to your Laravel 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 Blade layout. To act for the signed-in user, your app signs a short-lived token with firebase/php-jwt, Vatio verifies it, and the agent's private tools call your own routes, 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 Laravel code. In the project, run:

npx @vatio-ai/skills
/vatio-init

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

/vatio-init an agent for our signed-in customers: answer from our help pages and tell them about their subscription and next delivery

After launch, /vatio-improve is the daily routine (After launch).

#What vatio-init finds in a Laravel 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 Laravel project, that means:

  • What the agent could call: routes/web.php, routes/api.php and the controllers behind them, starting with the routes that already return JSON.
  • How your users sign in: Breeze, Jetstream or Fortify, Sanctum, Socialite, 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 the layout your pages share, such as resources/views/layouts/app.blade.php, before </body>:

<script async src="https://cdn.vatio.ai/v1/widget.js"
data-workspace="{{ config('services.vatio.workspace') }}"
data-token="{{ config('services.vatio.publishable_token') }}"
@if ($vatioToken) data-visitor-token="{{ $vatioToken }}" @endif></script>

Your coding agent adds a vatio entry to config/services.php whose workspace (your workspace slug), publishable_token, private_key and public_key read VATIO_WORKSPACE, VATIO_PUBLISHABLE_TOKEN, VATIO_IDENTITY_PRIVATE_KEY and VATIO_IDENTITY_PUBLIC_KEY from the environment, so php artisan config:cache keeps working. 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 with vatio keys create publishable --env live --label web --origin https://freshcrate.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.

#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, and your app gets its contents as VATIO_IDENTITY_PUBLIC_KEY; identity.pem becomes 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. After composer require firebase/php-jwt, a view composer in AppServiceProvider::boot() signs a token for whoever is signed in:

View::composer('layouts.app', function ($view) { // Illuminate\Support\Facades\View
$user = auth()->user();
$view->with('vatioToken', $user ? JWT::encode([ // Firebase\JWT\JWT
'sub' => (string) $user->id,
'aud' => config('services.vatio.workspace'),
'exp' => time() + 3600,
'name' => $user->name,
'email' => $user->email,
], config('services.vatio.private_key'), 'RS256') : null);
});

A signed-out visitor gets no token and only the agent's public tools. A tool marked access: private forwards the token to your API as Authorization: Bearer, and a middleware verifies it:

<?php
namespace App\Http\Middleware;
use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Http\Request;
class VerifyVatioToken
{
public function handle(Request $request, Closure $next)
{
try {
// The Key pins RS256; the token's own header is never trusted.
$claims = JWT::decode((string) $request->bearerToken(),
new Key(config('services.vatio.public_key'), 'RS256'));
} catch (\Exception) {
return response()->json(['error' => 'unauthorized'], 401);
}
// php-jwt rejects an expired exp, but checks no aud and accepts a token without exp.
if (($claims->aud ?? null) !== config('services.vatio.workspace') || empty($claims->exp) || empty($claims->sub)) {
return response()->json(['error' => 'unauthorized'], 401);
}
$request->attributes->set('vatio_subject', (string) $claims->sub);
return $next($request);
}
}

The tool's route, in routes/api.php (php artisan install:api creates it on Laravel 11 and later), where no CSRF check applies:

use App\Http\Middleware\VerifyVatioToken;
use App\Models\Subscription;
Route::get('/me/subscription', fn (Request $request) => [
'subscription' => Subscription::where('user_id', $request->attributes->get('vatio_subject'))->first(),
])->middleware(VerifyVatioToken::class);

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 the vatio_subject attribute the middleware sets (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_subscription.yml, a private tool with no user id in it: your route reads the user from the token. FRESHCRATE_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: freshcrate
auth:
public_key: identity.pub
agent:
instructions: |
You help signed-in Freshcrate customers with their subscription and deliveries.
Only state what my_subscription returned. Never promise a refund or a discount.
tools: [my_subscription, knowledge_lookup]
knowledge: [help]
description: The signed-in customer's subscription, plan and next delivery date.
when_to_use: The customer asks about their own subscription, plan or next box.
access: private
request:
method: GET
base_url: "$env.FRESHCRATE_API"
path: /api/me/subscription
headers:
Authorization: "Bearer $auth.token"
respond:
data:
subscription: "$.subscription"

#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 php artisan serve prints (vatio keys create publishable --env preview --label dev --origin http://127.0.0.1:8000), set it as VATIO_PUBLISHABLE_TOKEN in your local .env and sign in as a test user. Tools run on Vatio's servers and can't reach your machine, so point preview at a tunnel with vatio secrets set FRESHCRATE_API https://YOUR-TUNNEL --env preview; live keeps calling production. Ask about your own subscription and check it 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 route 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 route) 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 Laravel 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 Laravel 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 Blade layout.

#How does the agent know which Laravel user is signed in?

Your app signs a short-lived RS256 JWT for the signed-in user with firebase/php-jwt and the private key from vatio auth --new-key: sub is the user's id, aud your workspace slug, exp an hour away. A view composer passes it to the Blade layout, which renders it 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 does a Laravel route verify a token forwarded by a Vatio tool?

With a middleware that calls JWT::decode with a Key holding your public key and RS256, which pins the algorithm and rejects a bad signature or an expired exp. php-jwt does not check aud and accepts a token with no exp, so the middleware also requires an aud equal to your workspace slug, an exp and a sub, and answers 401 otherwise. The route then reads the user from sub.

Last updated:

Latest CLI version: 0.114.0 · Changelog · The whole contract as Markdown: /docs/docs.md · or vatio docs