Laravel
This guide adds Bunkhouse to a server-rendered Laravel application. Every file shown below is part of a runnable example app that is tested end to end against the real Bunkhouse API and embed.
Tested setup
Section titled “Tested setup”| Framework | Laravel 13.39.0 (laravel/framework), Blade views, session authentication (Auth) |
| Runtime | PHP 8.4 (8.4.27 in the tested image) on FrankenPHP 1.13 in classic mode |
| Example | apps/docs/integrations/laravel/ in the Bunkhouse repository |
You need:
- Your application’s exact origin registered on the site (for example
https://app.example.com). - The site’s public key (
pk_...). - For identity tokens (recommended): a signing secret (
ss_...) and its key ID from the site’s Keys page. - For server mint instead: a mint key (
sk_...). - Existing sign-in. The example uses Laravel’s session guard with two demo users in SQLite; your own authentication takes its place. Bunkhouse only needs one thing from it: the signed-in user’s stable ID on the server, which the endpoints read with
$request->user().
| File | Purpose |
|---|---|
config/agent.php, app/Providers/AppServiceProvider.php |
Bunkhouse configuration, public values validated at startup |
app/Http/Controllers/AgentController.php |
GET /api/agent-identity and POST /api/agent-session |
routes/web.php |
Registers both endpoints in the web group |
bootstrap/app.php |
CSRF exemption for the mint route and the CSP middleware |
app/Http/Middleware/ContentSecurityPolicy.php |
Your Content-Security-Policy with the Bunkhouse sources added |
resources/views/agent-embed/*.blade.php |
The script tags for one installation mode |
public/agent-identity.js, public/agent-auth-headers.js |
Page hooks, loaded before the loader |
Identity token (recommended)
Section titled “Identity token (recommended)”Set the server environment (.env or your host’s secret store). Only AGENT_PUBLIC_KEY and the origins ever reach the browser:
AGENT_PUBLIC_KEY=pk_...AGENT_SIGNING_SECRET=ss_... # server onlyAGENT_SIGNING_KID=... # key ID shown with the signing secretAGENT_ALLOWED_ORIGINS='["https://app.example.com"]'# AGENT_API_BASE and AGENT_EMBED_ORIGIN default to https://api.bunkhouse.ai and https://embed.bunkhouse.ai<?php
// Bunkhouse configuration. The public values are validated at startup (AppServiceProvider); the// signing secret and mint key are server-only and checked by the endpoint that uses them.return [ 'public_key' => env('AGENT_PUBLIC_KEY', ''), 'allowed_origins' => json_decode(env('AGENT_ALLOWED_ORIGINS', '[]'), true), 'api_base' => env('AGENT_API_BASE', 'https://api.bunkhouse.ai'), 'embed_origin' => env('AGENT_EMBED_ORIGIN', 'https://embed.bunkhouse.ai'), 'signing_secret' => env('AGENT_SIGNING_SECRET'), 'signing_kid' => env('AGENT_SIGNING_KID'), 'embed_secret_key' => env('AGENT_EMBED_SECRET_KEY'),];<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;use InvalidArgumentException;
class AppServiceProvider extends ServiceProvider{ /** Fails startup on invalid public Bunkhouse configuration. */ public function boot(): void { $config = config('agent'); if (! str_starts_with($config['public_key'], 'pk_')) { throw new InvalidArgumentException('AGENT_PUBLIC_KEY must be the site\'s public key (pk_...)'); } $origins = $config['allowed_origins']; if (! is_array($origins) || ! array_is_list($origins) || $origins === []) { throw new InvalidArgumentException('AGENT_ALLOWED_ORIGINS must be a JSON array such as ["https://app.example.com"]'); } foreach ([...$origins, $config['api_base'], $config['embed_origin']] as $origin) { if (! is_string($origin) || ! preg_match('#^https?://[a-z0-9.-]+(:\d{1,5})?$#i', $origin)) { throw new InvalidArgumentException('Bunkhouse origins must be exact origins like https://app.example.com'); } } }}The controller signs a ten-minute HS256 token with PHP’s hash_hmac for the user your session already authenticated. It rejects requests whose Sec-Fetch-Site is not same-origin (403) and signed-out users (401, never a login redirect), and marks every response Cache-Control: no-store. The subject comes only from $request->user(), never from the query, body or headers. The same file contains the server mint endpoint used by the alternative below.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Client\ConnectionException;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;use Illuminate\Support\Facades\Http;use Illuminate\Support\Facades\Log;use Symfony\Component\HttpFoundation\Response;
/** * Bunkhouse endpoints: GET /api/agent-identity (identity token mode) and POST /api/agent-session * (server mint mode). Both vouch only for the user your existing session already authenticated: * $request->user() is the seam to your authentication. */class AgentController{ public function identity(Request $request): JsonResponse { if ($request->header('Sec-Fetch-Site') !== 'same-origin') { return $this->json(['error' => 'forbidden'], 403); } $user = $request->user(); if (! $user) { return $this->json(['error' => 'unauthorized'], 401); } $secret = config('agent.signing_secret'); $kid = config('agent.signing_kid'); if (! $secret) { return $this->notConfigured('AGENT_SIGNING_SECRET'); } if (! $kid) { return $this->notConfigured('AGENT_SIGNING_KID'); }
return $this->json(['identityToken' => self::signIdentityToken((string) $user->getAuthIdentifier(), $secret, $kid)]); }
public function session(Request $request): Response { // The embed's POST carries no CSRF token; these three checks are this route's CSRF defense. $origin = $request->header('Origin'); if (! in_array($origin, config('agent.allowed_origins'), true) || $request->header('X-Agent-Embed') !== '1' || $request->header('Sec-Fetch-Site') !== 'same-origin') { return $this->json(['error' => 'forbidden'], 403); } $user = $request->user(); if (! $user) { return $this->json(['error' => 'unauthorized'], 401); } $key = config('agent.embed_secret_key'); if (! $key) { return $this->notConfigured('AGENT_EMBED_SECRET_KEY'); } try { $upstream = Http::withToken($key)->timeout(15)->post(config('agent.api_base').'/v1/embed/sessions', [ 'publicKey' => config('agent.public_key'), 'mode' => 'signed', 'userId' => (string) $user->getAuthIdentifier(), 'origin' => $origin, ]); } catch (ConnectionException) { return $this->json(['error' => 'session_service_unavailable'], 502); }
return response($upstream->body(), $upstream->status(), [ 'Content-Type' => $upstream->header('Content-Type') ?: 'application/json', 'Cache-Control' => 'no-store', ]); }
/** HS256 identity token: kid header, sub = stable opaque user ID, aud = public key, 10 minutes. */ public static function signIdentityToken(string $userId, string $secret, string $kid): string { $base64url = fn (string $bytes) => rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); $iat = time(); $header = $base64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT', 'kid' => $kid])); $payload = $base64url(json_encode(['sub' => $userId, 'aud' => config('agent.public_key'), 'iat' => $iat, 'exp' => $iat + 600])); $signature = $base64url(hash_hmac('sha256', "{$header}.{$payload}", $secret, true));
return "{$header}.{$payload}.{$signature}"; }
private function json(array $body, int $status = 200): JsonResponse { return response()->json($body, $status, ['Cache-Control' => 'no-store']); }
private function notConfigured(string $variable): JsonResponse { Log::error("Bunkhouse: {$variable} is not set");
return $this->json(['error' => 'not_configured'], 503); }}Register the routes in routes/web.php so they run in the web middleware group, where the session and the authenticated user are available. Don’t add the auth middleware: it redirects guests to a login page, and these endpoints must answer 401. Other HTTP methods get Laravel’s own 405 with an Allow header; bootstrap/app.php below makes those responses no-store too.
// Bunkhouse endpoints in the web group, so the session and $request->user() are available. Other// methods get Laravel's 405 with an Allow header.Route::get('/api/agent-identity', [AgentController::class, 'identity']);Route::post('/api/agent-session', [AgentController::class, 'session']);$request->user() is the seam to your authentication. Bunkhouse receives getAuthIdentifier(), so it should be a stable, opaque ID. The example’s users table uses string primary keys such as fixture-alice:
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
/** Demo users. The primary key is an opaque stable ID (fixture-alice): that is what Bunkhouse receives. */class User extends Authenticatable{ public $incrementing = false;
public $timestamps = false;
protected $keyType = 'string';
protected $hidden = ['password'];}In the layout’s <head>, load the page hooks and then the loader, before your application’s own scripts: the auth-headers hook (see below), then the identity hook and the loader from the mode partial. Both hooks are external same-origin scripts, so no inline script needs a CSP exception:
{{-- Identity token mode (recommended): the hook first, then the loader, before the app's scripts. --}}<script src="/agent-identity.js" nonce="{{ Vite::cspNonce() }}"></script><script src="{{ config('agent.embed_origin') }}/embed.js" nonce="{{ Vite::cspNonce() }}" data-public-key="{{ config('agent.public_key') }}" data-hints-url="/agent-hints.json"></script>// Identity token mode: the embed calls this hook whenever it starts a session for the signed-in user.window.AgentEmbedConfig = { ...window.AgentEmbedConfig, getIdentityToken: async () => { const res = await fetch("/api/agent-identity", { credentials: "same-origin", cache: "no-store" }); if (!res.ok) throw new Error("Not signed in"); return (await res.json()).identityToken; },};<meta name="csrf-token" content="{{ csrf_token() }}" />{{-- Page hooks define window.AgentEmbedConfig before the loader reads it. --}}<script src="/agent-auth-headers.js" nonce="{{ Vite::cspNonce() }}"></script>@include("agent-embed.{$mode}")<!-- The application's own JavaScript comes after the loader. --><script src="/app.js" nonce="{{ Vite::cspNonce() }}" defer></script>The example layout includes one of the three agent-embed partials, selected by a test-only ?mode= switch. Your application uses exactly one.
Server mint alternative
Section titled “Server mint alternative”Instead of the identity hook, add data-session-endpoint to the loader. Do not keep agent-identity.js on the page: when getIdentityToken is defined it takes precedence.
{{-- Server mint mode: no identity hook; the embed posts to the same-origin session endpoint. --}}<script src="{{ config('agent.embed_origin') }}/embed.js" nonce="{{ Vite::cspNonce() }}" data-public-key="{{ config('agent.public_key') }}" data-hints-url="/agent-hints.json" data-session-endpoint="/api/agent-session"></script>session() in AgentController.php above accepts only requests with an Origin from AGENT_ALLOWED_ORIGINS, X-Agent-Embed: 1 and Sec-Fetch-Site: same-origin (403 otherwise), authenticates the session (401), then creates the session with the mint key through Laravel’s HTTP client and returns the API’s status and body unchanged with Cache-Control: no-store. If the API is unreachable it returns 502 without details. Set AGENT_EMBED_SECRET_KEY=sk_... on the server; the identity endpoint does not need it.
Laravel 13’s default PreventRequestForgery middleware already accepts this POST without a CSRF token, because the browser sends Sec-Fetch-Site: same-origin; with the default middleware you need no exclusion. The example requires the CSRF token on every write (see RequireCsrfToken below), and the embed’s POST carries no token, so the example excludes only api/agent-session; that route’s three header checks protect it instead. Every other route, including login and logout, keeps CSRF protection:
->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ContentSecurityPolicy::class], replace: [ PreventRequestForgery::class => RequireCsrfToken::class, ]); // The embed's mint POST carries no CSRF token; AgentController checks Origin, // X-Agent-Embed and Sec-Fetch-Site instead. Every other route keeps CSRF protection. $middleware->preventRequestForgery(except: ['api/agent-session']);})Anonymous alternative
Section titled “Anonymous alternative”Anonymous sessions need only the loader. Enable anonymous access for the site in the dashboard first and set its monthly token and spend limits; usage is charged to your account’s credit and stops at a limit. Anyone who can load your page can start one, so it is not proof of identity.
{{-- Anonymous mode (enable it for the site first): the loader alone. --}}<script src="{{ config('agent.embed_origin') }}/embed.js" nonce="{{ Vite::cspNonce() }}" data-public-key="{{ config('agent.public_key') }}" data-hints-url="/agent-hints.json"></script>Sign-in, sign-out and navigation
Section titled “Sign-in, sign-out and navigation”The example signs in and out with ordinary Blade form posts (with @csrf) that redirect back to the same page. Every account change therefore loads a new document, and a new document starts a new embed with no session, so the previous user’s session can never carry over. As the authentication docs recommend, the session is regenerated on login and invalidated on logout; Laravel’s session cookie is HttpOnly and SameSite=Lax by default:
public function login(Request $request): Response{ $credentials = ['name' => (string) $request->input('username'), 'password' => (string) $request->input('password')]; if (! Auth::attempt($credentials)) { return response('Invalid username or password', 401); } $request->session()->regenerate(); $request->session()->regenerateToken();
return redirect($this->safeNext($request));}
public function logout(Request $request): RedirectResponse{ Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken();
return redirect($this->safeNext($request));}If your app changes the signed-in user without a full page load (Livewire, Inertia or a fetch login), call window.AgentEmbed?.resetSession() right after the login, logout or account switch succeeds and before the new user’s requests. Render the loader once in the layout, not in per-page views or components.
Auth headers, user IDs and CSP
Section titled “Auth headers, user IDs and CSP”The assistant calls your API with the page’s cookies; supply any extra header your API requires with getAuthHeaders. The example returns Laravel’s CSRF token from the conventional <meta name="csrf-token"> tag as X-CSRF-TOKEN:
// Laravel's CSRF token (the csrf-token meta tag) for the assistant's calls to this app's API.// Values never reach the model or the sandbox.window.AgentEmbedConfig = { ...window.AgentEmbedConfig, getAuthHeaders: () => { const token = document.querySelector('meta[name="csrf-token"]')?.content; return token ? { "X-CSRF-TOKEN": token } : {}; },};The hook merges into window.AgentEmbedConfig, so it works with the identity hook in either order; load both before the loader. Its values never reach the model or the sandbox.
Laravel 13’s PreventRequestForgery middleware also accepts any request whose Sec-Fetch-Site is same-origin without a token. The example replaces it in the web group with a token-only subclass, so every write must present the session token. With the default middleware the assistant’s same-origin calls pass either way, and the header is harmless.
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
/** * Laravel 13's request forgery middleware, token-only: a same-origin Sec-Fetch-Site alone is not * accepted, so every write must present the session's CSRF token (form _token or X-CSRF-TOKEN). */class RequireCsrfToken extends PreventRequestForgery{ protected function hasValidOrigin($request) { return false; }}Send a stable, opaque user ID (the example’s fixture-alice), not a name or email address.
Add the Bunkhouse sources to your existing policy rather than replacing it. The middleware calls Vite::useCspNonce() for a fresh nonce per request, and the views put Vite::cspNonce() on their script tags (no Vite build is needed for this). No 'unsafe-inline' or 'unsafe-eval' is needed:
<?php
namespace App\Http\Middleware;
use Closure;use Illuminate\Http\Request;use Illuminate\Support\Facades\Vite;use Symfony\Component\HttpFoundation\Response;
/** * The app's existing Content-Security-Policy with the Bunkhouse sources added: the embed origin to * script-src and frame-src, the API to connect-src. Vite::useCspNonce() creates a fresh nonce per * request; views put Vite::cspNonce() on their script tags. */class ContentSecurityPolicy{ public function handle(Request $request, Closure $next): Response { $nonce = Vite::useCspNonce(); $embed = config('agent.embed_origin'); $api = config('agent.api_base');
$response = $next($request); $response->headers->set('Content-Security-Policy', implode('; ', [ "default-src 'self'", "script-src 'self' 'nonce-{$nonce}' {$embed}", "style-src 'self' 'nonce-{$nonce}'", "frame-src {$embed}", "connect-src 'self' {$api}", "img-src 'self' data:", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'self'", ]));
return $response; }}Verify
Section titled “Verify”From a checkout of the Bunkhouse repository, with Docker and local Supabase running:
node tests/framework-integrations/run.ts laravelThe run builds the example image, starts it against a fresh local site and drives the real pane in Chromium: identity, mint and anonymous sessions, endpoint guards, sign-out and account switches, discovery of the page’s startup request, a CSRF-protected write through getAuthHeaders, CSP and secret checks. In your own app, sign in and open the Assistant launcher: the first message creates a signed session.
| Symptom | Check |
|---|---|
/api/agent-identity returns 401 |
The routes are outside the web group (no session), or $request->user() does not find the user for your guard. |
A redirect to /login instead of 401 |
Remove the auth middleware from the two Bunkhouse routes; they check the user themselves. |
| 403 from either endpoint | The request is cross-site or missing Sec-Fetch-Site; for mint, Origin must exactly match AGENT_ALLOWED_ORIGINS and the loader must carry data-session-endpoint. |
419 from /api/agent-session |
You use a token-only CSRF check like the example’s RequireCsrfToken and have not excluded api/agent-session. Laravel’s default PreventRequestForgery accepts this same-origin POST without an exclusion. |
| 503 | The signing secret, key ID or mint key for the mode you use is not set (or the config is cached without it: rerun php artisan config:cache). |
| The pane reports a CSP block | Add the embed origin to script-src and frame-src and the API to connect-src, and put Vite::cspNonce() on every script tag. |
| Startup requests are not discovered | The loader must come before your application’s scripts in the head. |
| Two launchers | Render the loader once, in the layout. |
| A secret in page source | Only AGENT_PUBLIC_KEY and origins belong in views; keep ss_ and sk_ values in server environment variables. |
Related
Section titled “Related”- Installation, Security and the Page API
- Laravel: Routing, Authentication, CSRF protection