tetrixdev / laravel-ai-bridge
Laravel package for AI Bridge — unified streaming interface for CLI Bridge, BYOK, and managed AI modes
Requires
- php: ^8.2
- firebase/php-jwt: ^6.10|^7.0
- guzzlehttp/psr7: ^2.0
- illuminate/http: ^12.0
- illuminate/routing: ^12.0
- illuminate/support: ^12.0
- ratchet/rfc6455: ^0.4
- react/socket: ^1.14
Requires (Dev)
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
This package is auto-updated.
Last update: 2026-06-07 09:16:31 UTC
README
A unified AI streaming interface for Laravel. Connect any Chat Completions-compatible provider (OpenAI, Anthropic, Groq, Ollama, etc.) or local CLI tools (Codex, Claude, Gemini) to your app through a single, normalized streaming pipeline.
What is this?
Laravel AI Bridge provides a unified streaming pipeline: provider -> normalized events -> browser. No matter where the AI response originates, your application receives the same StreamEvent objects through the same callback API. Three modes of operation cover every use case:
- BYOK (Bring Your Own Key) -- User provides an API key and endpoint. No local install needed.
- Managed -- Your app provides the API key. Same code path as BYOK, different config source.
- CLI Bridge -- User runs
npx @tetrixdev/ai-bridgelocally. Their CLI tools (Codex, Claude, Gemini) connect to your app via a dedicated WebSocket server.
Installation
composer require tetrixdev/laravel-ai-bridge
Publish the config file:
php artisan vendor:publish --tag=ai-bridge-config
Publish the JavaScript client (optional):
php artisan vendor:publish --tag=ai-bridge-js
Add to your .env:
# Required for all modes AI_BRIDGE_TOKEN_SECRET=your-random-secret-here # For BYOK / Managed mode AI_BRIDGE_MODE=byok AI_BRIDGE_ENDPOINT=https://api.openai.com AI_BRIDGE_API_KEY=sk-... AI_BRIDGE_MODEL=gpt-4o # For CLI Bridge mode AI_BRIDGE_MODE=bridge AI_BRIDGE_SERVER_HOST=0.0.0.0 AI_BRIDGE_SERVER_PORT=8085
Quick Start
A minimal BYOK example in three steps.
1. Configure .env
# Generate with: openssl rand -hex 32 AI_BRIDGE_TOKEN_SECRET=REPLACE_WITH_OUTPUT_OF_openssl_rand_hex_32 AI_BRIDGE_MODE=byok AI_BRIDGE_ENDPOINT=https://api.openai.com AI_BRIDGE_API_KEY=sk-your-key AI_BRIDGE_MODEL=gpt-4o
2. Create a Controller
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Str; use Tetrix\AiBridge\Facades\AiBridge; class ChatController extends Controller { public function stream(Request $request) { // NOTE (UX-001): conversation_id must stay CONSTANT across all messages in the // same conversation. Generating a new ID on every request (e.g. uniqid()) creates // a brand-new conversation each time, so the AI has no memory of previous messages. // Store the ID in the session and reuse it for follow-up messages. $conversationId = $request->input('conversation_id') ?? $request->session()->get('ai_conversation_id') ?? 'conv-' . Str::uuid(); $request->session()->put('ai_conversation_id', $conversationId); return AiBridge::streamToResponse( conversationId: $conversationId, message: $request->input('message'), options: [ 'system_prompt' => 'You are a helpful assistant.', ], ); } }
3. Create a Blade View
<div id="chat"> <div id="messages"></div> <div id="loading" style="display:none; color: grey;">AI is thinking...</div> <div id="error" style="color: red; display: none;"></div> <input type="text" id="input" placeholder="Type a message..."> <button id="send-btn" onclick="send()">Send</button> </div> <script src="/js/vendor/ai-bridge.js"></script> <script> // NOTE (UX-003): crypto.randomUUID() requires a secure context (HTTPS or localhost). // Over plain HTTP on a LAN IP (common for staging / internal testing) it throws a // TypeError and breaks the whole page. This helper falls back to a Math.random()-based // ID generator so conversation IDs work everywhere. function generateId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } // Fallback for non-secure contexts (plain HTTP). Not cryptographically strong, // but sufficient for uniquely identifying a conversation in the browser. return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } // NOTE (UX-001): Generate conversationId ONCE at page load and reuse it for all // messages in this conversation. Regenerating on each send() creates a fresh // conversation every time and the AI loses all context from previous messages. const conversationId = 'conv-' + generateId(); const stream = new AiBridgeStream({ mode: 'sse', url: '/ai-bridge/stream/sse', }); // SEC: Use textContent instead of innerHTML — AI-generated content is untrusted // and must not be inserted as raw HTML. If you need markdown rendering, use // a sanitizer such as DOMPurify: el.innerHTML = DOMPurify.sanitize(rendered) stream.on('text', (content) => { const el = document.getElementById('messages'); el.textContent += content; }); stream.on('done', () => { document.getElementById('messages').textContent += '\n\n'; document.getElementById('loading').style.display = 'none'; document.getElementById('send-btn').disabled = false; document.getElementById('input').disabled = false; }); // UX-008: Also handle cancelled so UI is never left stuck after destroy() or cancellation stream.on('cancelled', () => { document.getElementById('loading').style.display = 'none'; document.getElementById('send-btn').disabled = false; document.getElementById('input').disabled = false; }); stream.on('error', (code, message) => { const el = document.getElementById('error'); el.textContent = `Error: ${message}`; el.style.display = 'block'; document.getElementById('loading').style.display = 'none'; document.getElementById('send-btn').disabled = false; document.getElementById('input').disabled = false; }); function send() { const input = document.getElementById('input'); if (!input.value.trim()) return; // Disable input during streaming and show loading indicator (UX-008) document.getElementById('send-btn').disabled = true; input.disabled = true; document.getElementById('loading').style.display = 'block'; document.getElementById('error').style.display = 'none'; // NOTE (UX-001): The conversation_id must remain STABLE across all messages in the // same conversation so the AI can remember the context. Generate it ONCE when the // page loads and reuse it for every send() call in this conversation session. // Using Date.now() here would create a new conversation on every message. stream.send({ message: input.value, conversation_id: conversationId }); input.value = ''; } </script>
Modes of Operation
BYOK (Bring Your Own Key)
The user provides an API key and endpoint for any Chat Completions-compatible provider. The server calls the API directly -- no local install required on the user's machine.
$stream = AiBridge::stream('conv-1', 'Hello!', [ 'mode' => 'byok', 'api_key' => $user->ai_api_key, // per-user key 'endpoint' => 'https://api.openai.com', 'model' => 'gpt-4o', 'system_prompt' => 'You are a game master.', ]); $stream->onBlockDelta(fn ($event) => $this->appendToChat($event->data['content'])); $stream->onDone(fn ($usage) => $this->logUsage($usage)); $stream->start();
Works with any Chat Completions-compatible endpoint: OpenAI, Anthropic (via proxy), Groq, Together, Ollama, LM Studio, vLLM, etc.
Managed
Identical to BYOK but the application provides its own API key. Users pay the app a subscription fee. No separate architecture needed -- same code path, different config source.
AI_BRIDGE_MODE=managed AI_BRIDGE_ENDPOINT=https://api.openai.com AI_BRIDGE_API_KEY=sk-your-app-key AI_BRIDGE_MODEL=gpt-4o
// No per-user key needed -- uses the app's configured key $stream = AiBridge::stream('conv-1', 'Hello!', [ 'system_prompt' => 'You are a helpful assistant.', ]); $stream->start();
CLI Bridge
The user installs the bridge locally via npx @tetrixdev/ai-bridge. It connects to your app's dedicated WebSocket server and proxies AI requests through their local CLI tools (using their existing subscriptions).
AI_BRIDGE_MODE=bridge AI_BRIDGE_TOKEN_SECRET=your-secret AI_BRIDGE_SERVER_PORT=8085
Start the bridge server:
php artisan ai-bridge:serve
Generate a token for the user, then have them connect:
php artisan ai-bridge:token --user-id=42 # User runs on their machine: npx @tetrixdev/ai-bridge --server=ws://yourapp.com:8085 --token=<JWT>
The server-side code is identical:
$stream = AiBridge::stream('conv-1', 'Hello!', [ 'user_id' => $user->id, ]); $stream->onBlockDelta(fn ($event) => echo $event->data['content']); $stream->start();
Configuration
Full reference for config/ai-bridge.php:
| Key | Env Variable | Default | Description |
|---|---|---|---|
mode |
AI_BRIDGE_MODE |
byok |
Active mode: byok, managed, or bridge |
token.secret |
AI_BRIDGE_TOKEN_SECRET |
null |
JWT signing secret (required) |
token.ttl |
AI_BRIDGE_TOKEN_TTL |
86400 |
Token TTL in seconds (24h) |
websocket.heartbeat_interval |
-- | 30 |
Seconds between ping/pong |
websocket.request_timeout |
-- | 300 |
Seconds before AI request timeout |
chat_completions.endpoint |
AI_BRIDGE_ENDPOINT |
null |
Chat Completions API base URL |
chat_completions.api_key |
AI_BRIDGE_API_KEY |
null |
API key for BYOK/managed |
chat_completions.model |
AI_BRIDGE_MODEL |
null |
Model name (e.g. gpt-4o) |
chat_completions.max_tokens |
AI_BRIDGE_MAX_TOKENS |
4096 |
Max response tokens |
chat_completions.allowed_models |
-- | [] |
Model allowlist. Empty array allows all models. When non-empty, requests for a model not in the list are rejected with HTTP 422 |
server.host |
AI_BRIDGE_SERVER_HOST |
127.0.0.1 |
Bridge WebSocket server bind address (set to 0.0.0.0 in Docker/multi-host setups) |
server.port |
AI_BRIDGE_SERVER_PORT |
8085 |
Bridge WebSocket server port |
stream_store.default |
AI_BRIDGE_STREAM_STORE |
redis |
Per-turn event buffer driver. Ships with redis and array (tests); apps register their own via StreamStore::extend(). |
stream_store.redis.connection |
AI_BRIDGE_STREAM_REDIS_CONNECTION |
null |
Redis connection from config/database.php. null = app default. |
stream_store.redis.prefix |
AI_BRIDGE_STREAM_REDIS_PREFIX |
ai-bridge:stream |
Key prefix for buffer entries. |
stream_store.redis.ttl_streaming |
AI_BRIDGE_STREAM_TTL_STREAMING |
3600 |
TTL (s) while a turn is live; refreshed on every event. |
stream_store.redis.ttl_completed |
AI_BRIDGE_STREAM_TTL_COMPLETED |
1800 |
TTL (s) once a turn has terminated; bounds how long a recent page-load can replay. |
stream_store.poll_interval_ms |
AI_BRIDGE_STREAM_POLL_MS |
100 |
SSE long-poll interval while a turn is streaming. |
stream_store.keepalive_interval_s |
AI_BRIDGE_STREAM_KEEPALIVE_S |
30 |
SSE keepalive comment cadence (must beat intermediate proxy idle timeouts). |
stream_store.max_connection_s |
AI_BRIDGE_STREAM_MAX_CONNECTION_S |
600 |
Per-SSE-connection lifetime ceiling; the browser auto-reconnects via Last-Event-ID after. |
logging.channel |
AI_BRIDGE_LOG_CHANNEL |
null |
Log channel for the bridge relay path. null uses the app's default channel; point it at a dedicated channel (e.g. a daily channel with its own retention) to keep bridge logs separate. Falls back to the default channel if the named one is undefined. |
logging.verbose |
AI_BRIDGE_LOG_VERBOSE |
false |
When true, also log per-event detail (every stream event, relayed payloads) at debug level. Useful in development; noisy in production. |
cli.local_path |
AI_BRIDGE_CLI_LOCAL_PATH |
null |
Absolute path to an ai-bridge repo checkout. When set and APP_ENV=local, the "Add a CLI bridge" command runs that checkout's build (node <path>/dist/cli.js) instead of npx @tetrixdev/ai-bridge@latest — for testing CLI changes without an npm publish. Build the checkout first (npm run build). |
streaming.suppress_thinking_blocks |
AI_BRIDGE_SUPPRESS_THINKING |
true |
Suppress AI chain-of-thought / thinking blocks from SSE output and the per-turn buffer. Set to false only when intentionally displaying AI reasoning to users. |
Relay-path logging
When a bridge-mode chat hangs on "Thinking", the bridge relay log is the first
place to look. A healthy turn logs relaying conversation message to bridge,
then relayed request to bridge server, then a terminal relayed stream done
(or relayed stream error with the cause). With logging.verbose on, every
stream event and the relayed payload are logged too.
Point logging.channel at a dedicated channel to keep these out of the main
app log and give them their own retention — e.g. in config/logging.php:
'ai-bridge' => [ 'driver' => 'daily', 'path' => storage_path('logs/ai-bridge.log'), 'days' => env('AI_BRIDGE_LOG_DAYS', 7), ],
then set AI_BRIDGE_LOG_CHANNEL=ai-bridge.
Streaming to Browser
Two methods for delivering AI responses to the browser.
SSE (Server-Sent Events)
Returns an SSE HTTP response. Simplest approach -- no extra infrastructure needed.
// In your controller public function stream(Request $request) { return AiBridge::streamToResponse( conversationId: $request->input('conversation_id'), message: $request->input('message'), options: [ 'system_prompt' => $request->input('system_prompt', ''), ], ); }
Or use the built-in endpoint:
POST /ai-bridge/stream/sse
Content-Type: application/json
{
"conversation_id": "conv-123",
"message": "Hello!",
"system_prompt": "You are a helpful assistant."
}
The response is text/event-stream with normalized events. The first event is always
conversation_id, carrying the server-generated conversation ID — capture it and send it
back with follow-up messages to continue a multi-turn conversation:
data: {"event":"conversation_id","data":{"conversation_id":"conv-123"}}
data: {"event":"block_start","data":{"block_type":"text","block_index":0}}
data: {"event":"block_delta","data":{"block_type":"text","block_index":0,"content":"Hello"}}
data: {"event":"block_delta","data":{"block_type":"text","block_index":0,"content":"!"}}
data: {"event":"block_stop","data":{"block_type":"text","block_index":0}}
data: {"event":"done","data":{"usage":{"prompt_tokens":10,"completion_tokens":5}}}
data: [DONE]
Buffered streaming (refresh-safe)
For persisted conversations, use the buffered streaming path. The server
writes every event to a short-lived per-turn buffer (Redis by default);
the browser tails it over SSE. Native EventSource reconnection via
Last-Event-ID makes refresh, tab-switch and network blip recover the
in-flight reply for free — without losing tokens or restarting the turn.
// In your controller — returns immediately with a request_id public function send(Request $request) { $conversation = AiBridge::conversationsQuery($request) ->whereKey($request->input('conversation_id')) ->firstOrFail(); $requestId = AiBridge::startConversationStream( $conversation, $request->input('message'), ); return response()->json([ 'status' => 'started', 'request_id' => $requestId, ]); }
Or use the built-in endpoint, which the bundled chat UI uses:
POST /ai-bridge/conversations/{id}/stream
Content-Type: application/json
{ "message": "I search the room" }
→ 200 { "status": "started", "request_id": "..." }
→ 409 { "error": "conflict", ... } when a turn is already in flight
Then tail the per-turn buffer from the browser using native
EventSource:
const es = new EventSource(`/ai-bridge/streams/${requestId}/events`, { withCredentials: true }); ['block_start','block_delta','block_stop','tool_call','tool_result','done','error','cancelled'] .forEach(name => es.addEventListener(name, e => { const data = JSON.parse(e.data); // ...render }));
On page load, check whether a turn is in flight and re-attach. The
conversation payload (GET /ai-bridge/conversations/{id}) carries
streaming_request_id when a turn is live:
const conv = await fetch(`/ai-bridge/conversations/${id}`).then(r => r.json()); if (conv.streaming_request_id) { // EventSource resumes from `Last-Event-ID` automatically on reconnect; // for a first attach on page load, replay from the start. new EventSource(`/ai-bridge/streams/${conv.streaming_request_id}/events`, { withCredentials: true }); }
Stop an in-flight turn:
POST /ai-bridge/streams/{requestId}/abort
JavaScript Client
A lightweight vanilla JS module that wraps the HTTP endpoints with the same refresh-safe semantics the bundled chat UI uses. Publish it first:
php artisan vendor:publish --tag=ai-bridge-js
This copies ai-bridge.js to resources/js/vendor/ai-bridge.js.
Using with Vite (recommended): Import it in your resources/js/app.js:
import './vendor/ai-bridge.js';
Manual approach: Copy the file to public/js/vendor/ai-bridge.js and include with a script tag:
<script src="/js/vendor/ai-bridge.js"></script>
Buffered mode (default — recommended)
For conversation-based streams. POSTs the message, then tails the
per-turn buffer over native EventSource. Refresh, tab-switch and
network blip recover the in-flight reply automatically via
Last-Event-ID.
const stream = new AiBridgeStream({ mode: 'buffered', api: '/ai-bridge' }); stream.on('text', (content) => { /* append to chat UI */ }); stream.on('thinking', (content) => { /* show reasoning */ }); stream.on('tool_call', (data) => { /* show tool usage */ }); stream.on('done', (usage) => { /* finalize, show token counts */ }); stream.on('error', (code, message) => { /* show error */ }); // 'cancelled' is a terminal event — handle it alongside 'done' and 'error' so UI // cleanup (hiding spinners, re-enabling inputs) still runs if the stream is cancelled. stream.on('cancelled', () => { /* reset UI */ }); stream.send({ conversationId: 42, message: 'I cast fireball' }); // On page load: if a turn is already in flight on this conversation, // re-attach to its buffer (replays everything emitted so far). // const conv = await fetch(`/ai-bridge/conversations/${id}`).then(r => r.json()); // if (conv.streaming_request_id) stream.attach(conv.streaming_request_id); // Cancel the in-flight turn server-side. // stream.abort();
SSE mode (one-shot, no conversation row)
For non-conversation use — a direct text/event-stream against
/ai-bridge/stream/sse. No resumption; a refresh loses the response.
Use buffered mode if you need refresh-safety.
const stream = new AiBridgeStream({ mode: 'sse', url: '/ai-bridge/stream/sse' }); stream.on('conversation_id', (id) => { /* persist for follow-ups */ }); stream.on('text', (content) => { /* append */ }); stream.on('done', (usage) => { /* finalize */ }); stream.send({ message: 'Hello!', conversation_id: 'temp-123' });
With Alpine.js
<div x-data="chat()" x-init="init()"> <div x-text="messages"></div> <input x-model="input" @keydown.enter="send()" :disabled="isStreaming"> <button @click="send()" :disabled="isStreaming">Send</button> </div> <script src="/js/vendor/ai-bridge.js"></script> <script> // NOTE (UX-003): crypto.randomUUID() requires a secure context (HTTPS or localhost) // and throws over plain HTTP on a LAN IP. This helper falls back to a Math.random() // based ID so conversation IDs work everywhere, including HTTP staging environments. function generateId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function chat() { return { messages: '', input: '', isStreaming: false, stream: null, conversationId: null, init() { // NOTE (UX-001): Generate the conversation_id ONCE here and reuse it for // every send() call. Regenerating it per message (e.g. with Date.now()) // starts a fresh conversation each time, so the AI loses all prior context. this.conversationId = 'conv-' + generateId(); this.stream = new AiBridgeStream({ mode: 'sse', url: '/ai-bridge/stream/sse', }); this.stream.on('text', (content) => { this.messages += content; }); this.stream.on('done', () => { this.messages += '\n\n'; this.isStreaming = false; }); this.stream.on('cancelled', () => { this.isStreaming = false; }); this.stream.on('error', (code, message) => { this.messages += `\n[Error: ${message}]\n`; this.isStreaming = false; }); }, send() { // UX: Prevent submitting while streaming or with empty input if (this.isStreaming || !this.input.trim()) return; this.isStreaming = true; // NOTE (UX-001): Reuse the conversationId generated once in init() so the // AI keeps context across every message in this conversation session. this.stream.send({ message: this.input, conversation_id: this.conversationId, }); this.input = ''; }, }; } </script>
Conversation Persistence
The package persists multi-turn conversations to the database so they can be
listed, resumed, and replayed. Persistence is always on — there is no opt-in
flag. Three tables are created by auto-loaded migrations (they are not
publishable — do not fork them): ai_bridge_conversations, ai_bridge_messages
and ai_bridge_connections, on the application's default database connection.
The tables are deliberately not linked to any of your tables. Your app
associates conversations/connections with its own users or sessions via its own
pivot tables, and tells the package which rows a request may see by registering
two scoping resolvers (e.g. in a service provider's boot()):
use Tetrix\AiBridge\Facades\AiBridge; AiBridge::resolveConversationsUsing( fn (Request $request) => $request->user()->conversations()->getQuery() ); AiBridge::resolveConnectionsUsing( fn (Request $request) => $request->user()->connections()->getQuery() );
Listen for ConversationCreated / ConnectionCreated to link a newly created
row to your owner model.
Connection lifecycle events
The package dispatches synchronous events around connection lifecycle so your app can keep its own state in step:
| Event | When | Use it to |
|---|---|---|
ConnectionCreated |
a connection is registered via the HTTP API | link the new row to your owner/session model |
ConnectionDeleted |
a connection is deleted via the HTTP API | run non-database cleanup tied to the connection |
Each event carries the Connection and the live Request.
Cascading the delete. Give your owner pivot a cascading foreign key so its rows are removed automatically when a connection is deleted — no listener needed for the database side:
Schema::create('user_connection', function (Blueprint $table) { $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('connection_id') ->constrained('ai_bridge_connections') ->cascadeOnDelete(); $table->primary(['user_id', 'connection_id']); });
With the cascade in place, listen for ConnectionDeleted only when you have
other work to do (e.g. notifying a service, clearing a cache). For deletes
that should be vetoed or cleaned up transactionally, you can also hook the
Eloquent deleting event on Tetrix\AiBridge\Models\Connection.
If your resolver reads the session (e.g.
$request->session()), the AI Bridge routes must run with the full cookie + session middleware stack — notStartSessionalone. Configureai-bridge.route_middlewareaccordingly:'route_middleware' => [ \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, ],Your web pages set an encrypted session cookie (via the
webgroup). WithoutEncryptCookieson the AI Bridge routes,StartSessioncannot decrypt that cookie and starts a brand-new session on every request — so session-scoped conversations and connections appear to vanish between requests. Apps that scope by an authenticated user instead ($request->user()) can use their normal auth middleware and are unaffected.
HTTP API
| Method & path | Purpose |
|---|---|
GET /ai-bridge/conversations |
List conversations (scoped + paginated) |
POST /ai-bridge/conversations |
Create a conversation |
GET /ai-bridge/conversations/{id} |
Conversation + messages + tools_stale flag |
DELETE /ai-bridge/conversations/{id} |
Delete a conversation |
POST /ai-bridge/conversations/{id}/stream |
Start a turn — returns {request_id}; the browser tails /streams/{rid}/events |
GET /ai-bridge/streams/{rid}/status |
Status snapshot of an in-flight or recently-completed turn |
GET /ai-bridge/streams/{rid}/events |
SSE tail of the per-turn event buffer; resumes by Last-Event-ID |
POST /ai-bridge/streams/{rid}/abort |
Cancel an in-flight turn (serve process observes the flag, sends cancel to the CLI) |
GET /ai-bridge/connections |
List connections with their advertised providers/models + live connected flag |
POST /ai-bridge/connections |
Register a CLI bridge or BYOK connection |
PATCH /ai-bridge/connections/{id} |
Rename a connection |
POST /ai-bridge/connections/{id}/regenerate |
Rotate a bridge's token (revokes the old one, disconnects any live bridge) |
DELETE /ai-bridge/connections/{id} |
Delete a connection (disconnects any live bridge) |
History injection retains prior text and tool calls/results but excludes thinking blocks; switching provider/model/mode mid-conversation is supported.
Reference Chat UI
A drop-in ChatGPT-style chat component ships with the package:
<x-ai-bridge::chat api="/ai-bridge" />
It is a thin wrapper that renders an <ai-bridge-chat> Web Component. The
component uses Shadow DOM, so it is fully isolated — it cannot conflict with
your app's CSS framework or JavaScript (no global Tailwind, no global Alpine).
Its pre-built bundle is served by the package; your app needs no build toolchain.
It is entirely optional — the backend is fully usable without it.
Customizing the chat UI
The component is a reference implementation — you are never locked into it.
-
Build your own UI (recommended for anything beyond light tweaks). Every piece of logic lives server-side, so a custom UI is lightweight: render JSON from the HTTP API above, POST messages to the stream endpoint, tail the returned
request_id's buffer overEventSource. Use any stack — Blade, Livewire, Vue, React. The stream emits these events (each as an SSEevent: <name>line with a JSONdata:payload):block_start,block_delta({block_type, content}),block_stop,tool_call({tool_name, parameters}),done({usage}),error,cancelled.The bundled component (
resources/dist/ai-bridge-chat.js) is the working reference for everything a client needs to do: API calls, EventSource lifecycle,Last-Event-IDresumption on conversation-open, reassemblingblock_*events into rendered messages, and watchdog handling for stalled turns. Read it as the example rather than re-deriving the contract. -
Fork the component. Copy
resources/dist/ai-bridge-chat.jsfrom the package into your app, adjust it, and point your own<script>/element at it. It is a single self-contained file with no build step.
Tool System
Register tools that the AI can call during a conversation. Tools work across all three modes.
Every parameter must be described. A tool registered with a parameter that has no (or an empty)
descriptionis rejected with anInvalidArgumentExceptionat registration time. This applies to every registration path below. Tool names must start with a letter and contain only letters, digits, underscores, or hyphens (max 64 characters).
Register with a Closure
The parameters argument is a raw JSON Schema object. Each entry under
properties must include a non-empty description.
// In a service provider's boot() method AiBridge::registerTool( name: 'roll_dice', description: 'Roll one or more dice', parameters: [ 'type' => 'object', 'properties' => [ 'sides' => ['type' => 'integer', 'description' => 'Number of sides on each die'], 'count' => ['type' => 'integer', 'description' => 'Number of dice to roll'], ], 'required' => ['sides'], ], handler: function (array $params) { $sides = $params['sides']; $count = $params['count'] ?? 1; $rolls = []; for ($i = 0; $i < $count; $i++) { $rolls[] = random_int(1, $sides); } return ['rolls' => $rolls, 'total' => array_sum($rolls)]; }, );
A tool that takes no parameters passes an empty array (parameters: []).
Register with the Structured AbstractTool API (recommended)
Extending AbstractTool is the recommended way to define a tool. Instead of
hand-writing a JSON Schema, you declare each parameter as a ToolParameter.
Because ToolParameter requires a non-empty description, it is impossible to
define a tool with an undescribed parameter -- the schema is generated for you.
use Tetrix\AiBridge\Tools\AbstractTool; use Tetrix\AiBridge\Tools\ToolParameter; class LookupCharacterTool extends AbstractTool { public function name(): string { return 'lookup_character'; } public function description(): string { return 'Look up a character in the database'; } protected function defineParameters(): array { return [ new ToolParameter( name: 'name', type: 'string', description: 'The full name of the character to look up', ), new ToolParameter( name: 'realm', type: 'string', description: 'Which realm to search in', required: false, enum: ['mortal', 'fae', 'celestial'], ), ]; } public function handle(array $params): mixed { return Character::where('name', $params['name'])->first()?->toArray(); } } // Register it AiBridge::registerToolHandler(new LookupCharacterTool());
ToolParameter accepts the JSON Schema types string, integer, number,
boolean, array, and object. defineParameters() returns a list of them,
and AbstractTool turns that list into the JSON Schema the API expects via the
final parameters() method.
Register with a ToolHandler Class
You can also implement the ToolHandler interface directly and build the JSON
Schema yourself. Every property must still include a non-empty description.
use Tetrix\AiBridge\Contracts\ToolHandler; class LookupCharacterTool implements ToolHandler { public function name(): string { return 'lookup_character'; } public function description(): string { return 'Look up a character in the database'; } public function parameters(): array { return [ 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string', 'description' => 'The full name of the character'], ], 'required' => ['name'], ]; } public function handle(array $params): mixed { return Character::where('name', $params['name'])->first()?->toArray(); } } // Register it AiBridge::registerToolHandler(new LookupCharacterTool());
Listening for Tool Calls
$stream = AiBridge::stream('conv-1', 'Roll 2d6 for damage'); $stream->onToolCall(function (string $name, array $params, string $callId) { // Tool execution happens automatically if registered. // This callback is for UI updates / logging. Log::info("AI called tool: {$name}", $params); }); $stream->start();
Bridge Server
The ai-bridge:serve command starts a dedicated WebSocket server for CLI bridge connections. It runs on its own port and speaks the AI Bridge Protocol — separate from any other realtime infrastructure your app may use.
php artisan ai-bridge:serve
Options:
--host=0.0.0.0 Bind address (default: from config or 0.0.0.0)
--port=8085 Port number (default: from config or 8085)
The server:
- Accepts WebSocket connections from bridge clients (
npx @tetrixdev/ai-bridge) - Validates JWT tokens from the
?token=query parameter - Routes AI request/response messages through the
MessageHandler - Tracks connections via
BridgeConnectionManager - Handles graceful shutdown on SIGINT/SIGTERM
Running bridge mode — one background process is required
Bridge mode needs one long-running process alongside your web server, because the AI response arrives at a different process than the one handling the browser request (see the data-flow diagram in Architecture):
| Process | Command | Why it is needed |
|---|---|---|
| AI Bridge server | php artisan ai-bridge:serve |
Accepts the WebSocket connection from the user's local npx @tetrixdev/ai-bridge CLI bridge, and writes stream events from it into the per-turn buffer the browser tails over SSE. |
It must run continuously — under a process manager (Supervisor), as a dedicated container, or via Octane in development. A typical Supervisor setup:
[program:ai-bridge-serve] command=php /app/artisan ai-bridge:serve --host=0.0.0.0 --port=8085 autostart=true autorestart=true
A working Redis (used for the per-turn buffer by default) is also required. BYOK / Managed mode needs neither — those stream over SSE directly from the web process, so a plain web server plus Redis is enough.
Artisan Commands
| Command | Description |
|---|---|
ai-bridge:serve |
Start the dedicated WebSocket server for CLI bridge connections |
ai-bridge:token |
Generate a JWT connection token for testing |
ai-bridge:test |
Send a test request through the configured mode |
ai-bridge:serve
php artisan ai-bridge:serve --port=8085
Starts the bridge WebSocket server. Bridge clients connect to ws://host:port?token=<JWT>.
ai-bridge:token
php artisan ai-bridge:token --user-id=42 --ttl=3600
Generates a JWT token for testing bridge connections without needing a full auth flow.
ai-bridge:test
# Test BYOK mode php artisan ai-bridge:test "What is 2+2?" # Test managed mode php artisan ai-bridge:test "Hello!" --mode=managed # Test bridge mode (requires active bridge connection) php artisan ai-bridge:test "Hello!" --mode=bridge
Sends a test request and displays streaming events in the console.
Architecture
graph LR
Browser["Browser"]
Laravel["Laravel App"]
Buffer["Per-turn buffer (Redis)"]
API["Chat Completions API"]
Bridge["Bridge (local)"]
CLI["CLI Tools"]
Browser <-->|SSE tail (Last-Event-ID)| Laravel
Laravel <--> Buffer
Laravel <-->|HTTPS| API
Laravel <-->|WebSocket :8085| Bridge
Bridge --> CLI
subgraph "BYOK / Managed"
API
end
subgraph "CLI Bridge"
Bridge
CLI
end
Loading
Browser <--SSE--> Laravel App <--WebSocket--> Bridge (local) --> CLI tools
|
per-turn buffer (Redis)
|
Chat Completions API (BYOK/Managed)
Data flow (BYOK/Managed):
- Browser POSTs the message to
/conversations/{id}/stream; Laravel returns{request_id}and runs the upstream AI call in aterminating()callback. - Each streamed event is written to the per-turn buffer keyed by
request_id. - Browser opens
EventSource('/streams/{rid}/events')and tails the buffer; nativeLast-Event-IDreconnect makes refresh/blip recover the in-flight reply.
Data flow (CLI Bridge):
- Browser POSTs the message; the web worker relays an
ai_requestto theai-bridge:serveprocess over its internal HTTP API. - The serve process sends the request over WebSocket to the user's local bridge; events come back asynchronously into the same serve process.
- Each event is written to the per-turn buffer (and the assistant message is persisted at terminal).
- Browser tails the buffer over SSE, same as BYOK/Managed — uniform shape across modes.
Protocol
The WebSocket protocol between the server and CLI bridge is documented in PROTOCOL.md.
License
MIT. See LICENSE.