03-custom-skills/examples/module-assistant/SKILL.md
Add an AI assistant chat panel to any Laravel project — with tool calling, streaming, and MCP support
npx skillsauth add escapeboy/ai-prompts module:assistantInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Implement an AI-powered assistant chat panel embedded in your Laravel application. The assistant understands your domain, can execute tools to read/write data, and supports multiple LLM providers including local agents.
Reference implementation: Agent Fleet — Livewire panel with streaming, PrismPHP tool calling, local agent support (Claude Code text-based <tool_call> loop, Codex via MCP), conversation history, context awareness.
/module:assistant [action]
/module:assistant # Full implementation
/module:assistant analyze # Only analyze and produce a component plan
/module:assistant tools # Only generate the tool registry (add to existing assistant)
Implements the complete assistant: database, backend, tools, frontend.
Process:
Read CLAUDE.md to understand the domain, models, and architecture.
Check prerequisites:
composer show livewire/livewire)composer show prism-php/prism)If MCP is already set up (from /module:mcp): the assistant can reference MCP tools for local agents that support MCP natively (e.g. Codex).
Identify domain tools — the assistant needs tools to interact with your data. Scan models and actions to plan what tools to create (same analysis as /module:mcp analyze).
Decide the tool strategy:
<tool_call> format — system prompt describes tool schemas, responses parsed for <tool_call> tags./module:mcp first).Create the migration for assistant conversations and messages.
php artisan make:migration create_assistant_tables
Migration:
Schema::create('assistant_conversations', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->string('title')->nullable();
$table->string('context_type')->nullable(); // e.g. 'order', 'product'
$table->uuid('context_id')->nullable(); // ID of the entity being discussed
$table->timestamps();
});
Schema::create('assistant_messages', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('conversation_id')->constrained('assistant_conversations')->cascadeOnDelete();
$table->string('role'); // 'user', 'assistant', 'system'
$table->text('content');
$table->jsonb('tool_calls')->nullable(); // Tool execution results
$table->jsonb('token_usage')->nullable(); // {prompt_tokens, completion_tokens, cost}
$table->timestamps();
$table->index(['conversation_id', 'created_at']);
});
Models:
// app/Domain/Assistant/Models/AssistantConversation.php
class AssistantConversation extends Model
{
use HasUuids;
protected $fillable = ['user_id', 'title', 'context_type', 'context_id'];
public function user(): BelongsTo { return $this->belongsTo(User::class); }
public function messages(): HasMany { return $this->hasMany(AssistantMessage::class, 'conversation_id'); }
}
// app/Domain/Assistant/Models/AssistantMessage.php
class AssistantMessage extends Model
{
use HasUuids;
protected $fillable = ['conversation_id', 'role', 'content', 'tool_calls', 'token_usage'];
protected function casts(): array {
return ['tool_calls' => 'array', 'token_usage' => 'array'];
}
public function conversation(): BelongsTo { return $this->belongsTo(AssistantConversation::class, 'conversation_id'); }
}
Create tool classes that the assistant can call. These are PrismPHP Tool objects, NOT MCP tools (MCP tools are for external agents; PrismPHP tools are for in-process tool calling).
Tool registry at app/Domain/Assistant/Services/AssistantToolRegistry.php:
<?php
namespace App\Domain\Assistant\Services;
use App\Domain\Assistant\Tools\ListTools;
use App\Domain\Assistant\Tools\GetTools;
use App\Domain\Assistant\Tools\MutationTools;
use App\Models\User;
class AssistantToolRegistry
{
/**
* Get all available tools for the given user.
*
* @return array<\Prism\Prism\Tool>
*/
public function getTools(User $user): array
{
$tools = [];
// Read tools — always available
$tools = array_merge($tools, ListTools::tools());
$tools = array_merge($tools, GetTools::tools());
// Write tools — check user permissions
if ($this->canWrite($user)) {
$tools = array_merge($tools, MutationTools::writeTools());
}
// Destructive tools — admin only
if ($this->canDestroy($user)) {
$tools = array_merge($tools, MutationTools::destructiveTools());
}
return $tools;
}
private function canWrite(User $user): bool
{
// Adapt to your auth model
return true; // or $user->hasRole('admin', 'editor')
}
private function canDestroy(User $user): bool
{
return $user->is_admin ?? false;
}
}
Tool implementation pattern (PrismPHP tools):
<?php
namespace App\Domain\Assistant\Tools;
use App\Models\Order;
use Prism\Prism\Tool;
class ListTools
{
/**
* @return array<Tool>
*/
public static function tools(): array
{
return [
self::listOrders(),
self::listProducts(),
// ... add more list tools
];
}
private static function listOrders(): Tool
{
return Tool::as('list_orders')
->for('List orders with optional status filter. Returns id, number, status, total, date.')
->withStringParameter('status', 'Filter: pending, processing, completed, cancelled')
->withNumberParameter('limit', 'Max results (default 10)')
->using(function (?string $status = null, ?int $limit = 10): string {
$query = Order::query()->orderByDesc('created_at');
if ($status) {
$query->where('status', $status);
}
$items = $query->limit(min($limit ?? 10, 100))
->get(['id', 'order_number', 'status', 'total', 'created_at']);
return json_encode(['count' => $items->count(), 'orders' => $items->toArray()]);
});
}
}
Group tools by concern: ListTools, GetTools, MutationTools, StatusTools. Each returns an array of Prism\Prism\Tool objects.
ConversationManager — handles conversation CRUD and message history:
<?php
namespace App\Domain\Assistant\Services;
use App\Domain\Assistant\Models\AssistantConversation;
class ConversationManager
{
public function addMessage(AssistantConversation $conversation, string $role, string $content, ?array $toolCalls = null, ?array $tokenUsage = null): void
{
$conversation->messages()->create([
'role' => $role,
'content' => $content,
'tool_calls' => $toolCalls,
'token_usage' => $tokenUsage,
]);
}
public function buildMessageHistory(AssistantConversation $conversation, int $limit = 20): array
{
return $conversation->messages()
->orderBy('created_at')
->limit($limit)
->get(['role', 'content'])
->map(fn ($m) => ['role' => $m->role, 'content' => $m->content])
->toArray();
}
public function generateTitle(AssistantConversation $conversation): void
{
if ($conversation->title) return;
$firstMessage = $conversation->messages()->where('role', 'user')->first();
if ($firstMessage) {
$conversation->update(['title' => Str::limit($firstMessage->content, 80)]);
}
}
}
ContextResolver — injects page-level context into the system prompt:
<?php
namespace App\Domain\Assistant\Services;
class ContextResolver
{
/**
* Resolve context for the current page the user is viewing.
*/
public function resolve(?string $contextType, ?string $contextId): string
{
if (! $contextType || ! $contextId) {
return 'The user is on the dashboard.';
}
return match ($contextType) {
'order' => $this->resolveOrder($contextId),
'product' => $this->resolveProduct($contextId),
// Add more entity types as needed
default => "Viewing {$contextType} #{$contextId}",
};
}
private function resolveOrder(string $id): string
{
$order = \App\Models\Order::with('items')->find($id);
if (! $order) return 'Order not found.';
return "Viewing Order #{$order->order_number} — Status: {$order->status}, Total: {$order->total}, Items: {$order->items->count()}";
}
}
SendAssistantMessageAction — the core action that orchestrates everything:
<?php
namespace App\Domain\Assistant\Actions;
use App\Domain\Assistant\Models\AssistantConversation;
use App\Domain\Assistant\Services\AssistantToolRegistry;
use App\Domain\Assistant\Services\ContextResolver;
use App\Domain\Assistant\Services\ConversationManager;
use App\Models\User;
class SendAssistantMessageAction
{
public function __construct(
private readonly ConversationManager $conversationManager,
private readonly ContextResolver $contextResolver,
private readonly AssistantToolRegistry $toolRegistry,
) {}
public function execute(
AssistantConversation $conversation,
string $userMessage,
User $user,
?string $contextType = null,
?string $contextId = null,
?string $provider = null,
?string $model = null,
): mixed {
// 1. Save user message
$this->conversationManager->addMessage($conversation, 'user', $userMessage);
// 2. Resolve provider (from settings or defaults)
$provider = $provider ?? config('assistant.default_provider', 'anthropic');
$model = $model ?? config('assistant.default_model', 'claude-sonnet-4-5');
// 3. Detect local vs cloud provider
$isLocal = in_array($provider, ['codex', 'claude-code']);
// 4. Get tools based on user permissions
$tools = $this->toolRegistry->getTools($user);
// 5. Build system prompt
$context = $this->contextResolver->resolve($contextType, $contextId);
$systemPrompt = $this->buildSystemPrompt($context, $user, $tools, $isLocal, $provider);
// 6. Build conversation history
$history = $this->conversationManager->buildMessageHistory($conversation);
$userPrompt = $this->buildUserPrompt($history, $userMessage);
// 7. Execute based on provider type
if ($isLocal && $provider === 'claude-code') {
// Claude Code: text-based <tool_call> loop
$response = $this->executeWithLocalToolLoop($provider, $model, $systemPrompt, $userPrompt, $tools, $user);
} elseif ($isLocal && $provider === 'codex') {
// Codex: MCP native — tools come from MCP server, no PrismPHP tools needed
$response = $this->executeLocal($provider, $model, $systemPrompt, $userPrompt, $user);
} else {
// Cloud: PrismPHP handles tool calling natively
$response = $this->executeCloud($provider, $model, $systemPrompt, $userPrompt, $tools, $user);
}
// 8. Save assistant response
$this->conversationManager->addMessage($conversation, 'assistant', $response->content);
$this->conversationManager->generateTitle($conversation);
return $response;
}
private function buildSystemPrompt(string $context, User $user, array $tools, bool $isLocal, string $provider): string
{
// See the full system prompt structure in Phase 5
return "...";
}
}
The system prompt is the most critical piece. It defines the assistant's identity, capabilities, and behavior.
Structure (adapt to your domain):
You are the **{Project Name} Assistant** — an AI helper embedded in {Project Name}.
{intro — can/cannot execute tools}
## About {Project Name}
{2-3 paragraph description of what the platform does, its key entities, and how they relate}
## Current User
- Name: {user name}
- Role: {user role}
## Current Context
{What page/entity the user is viewing — from ContextResolver}
## Available Tools
### Read Tools (always available)
- `list_orders` — List orders with status/date filters
- `get_order` — Get order details with items
- ...
### Write Tools (if user role permits)
- `create_order` — Create a new order
- ...
### Destructive Tools (admin only)
- `cancel_order` — Cancel an order permanently
- ...
## Guidelines
- Be concise and direct. Use markdown formatting.
- Always use tools when the user asks you to do something.
- Present results in clean tables or bullet lists.
- For write operations, state what you will do before calling the tool.
- If something fails, explain clearly and suggest alternatives.
- Respond in the same language the user writes in.
For local agents (Claude Code) — append the tool calling format:
## How to Call Tools
<tool_call>
{"name": "tool_name", "arguments": {"param1": "value1"}}
</tool_call>
### Tool Schemas
```json
[{"name": "list_orders", "description": "...", "parameters": {...}}]
**For MCP-native agents (Codex)** — describe tool domains instead:
You have MCP tools connected to {Project Name}. Tool names follow {domain}_{action}.
#### Phase 6: Livewire Component
**Backend** at `app/Livewire/Assistant/AssistantPanel.php`:
```php
<?php
namespace App\Livewire\Assistant;
use App\Domain\Assistant\Actions\SendAssistantMessageAction;
use App\Domain\Assistant\Models\AssistantConversation;
use Livewire\Component;
class AssistantPanel extends Component
{
public bool $open = false;
public string $message = '';
public ?string $conversationId = null;
public array $messages = [];
public bool $loading = false;
public string $contextType = '';
public string $contextId = '';
protected $listeners = ['assistant:open' => 'openPanel', 'assistant:context' => 'setContext'];
public function openPanel(): void
{
$this->open = true;
}
public function setContext(string $type, string $id): void
{
$this->contextType = $type;
$this->contextId = $id;
}
public function send(SendAssistantMessageAction $action): void
{
if (trim($this->message) === '') return;
$user = auth()->user();
$messageText = $this->message;
$this->message = '';
// Optimistic UI — show user message immediately
$this->messages[] = ['role' => 'user', 'content' => $messageText];
$this->loading = true;
// Get or create conversation
$conversation = $this->conversationId
? AssistantConversation::find($this->conversationId)
: AssistantConversation::create(['user_id' => $user->id]);
$this->conversationId = $conversation->id;
try {
$response = $action->execute(
conversation: $conversation,
userMessage: $messageText,
user: $user,
contextType: $this->contextType ?: null,
contextId: $this->contextId ?: null,
);
$this->messages[] = ['role' => 'assistant', 'content' => $response->content];
} catch (\Throwable $e) {
$this->messages[] = ['role' => 'assistant', 'content' => "Error: {$e->getMessage()}"];
} finally {
$this->loading = false;
}
}
public function newConversation(): void
{
$this->conversationId = null;
$this->messages = [];
}
public function loadConversation(string $id): void
{
$conversation = AssistantConversation::where('user_id', auth()->id())->find($id);
if (! $conversation) return;
$this->conversationId = $id;
$this->messages = $conversation->messages()
->orderBy('created_at')
->get(['role', 'content'])
->map(fn ($m) => ['role' => $m->role, 'content' => $m->content])
->toArray();
}
public function render()
{
$conversations = auth()->check()
? AssistantConversation::where('user_id', auth()->id())
->orderByDesc('updated_at')
->limit(20)
->get(['id', 'title', 'updated_at'])
: collect();
return view('livewire.assistant.assistant-panel', [
'conversations' => $conversations,
]);
}
}
Frontend at resources/views/livewire/assistant/assistant-panel.blade.php:
Key features to implement:
contextType and contextId from the current page<div x-data="{
open: @entangle('open'),
panelWidth: parseInt(localStorage.getItem('assistant-panel-width')) || 420,
resizing: false,
startResize(e) {
this.resizing = true;
const startX = e.clientX;
const startWidth = this.panelWidth;
const onMove = (ev) => {
this.panelWidth = Math.max(320, Math.min(startWidth + (startX - ev.clientX), window.innerWidth * 0.8));
};
const onUp = () => {
this.resizing = false;
localStorage.setItem('assistant-panel-width', this.panelWidth);
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
}">
<!-- Toggle button (floating) -->
<button @click="open = !open" class="fixed bottom-6 right-6 z-40 ...">
AI Assistant
</button>
<!-- Panel -->
<div x-show="open" x-cloak
:style="'width: ' + panelWidth + 'px'"
class="fixed inset-y-0 right-0 z-50 flex flex-col bg-white shadow-xl border-l">
<!-- Resize handle -->
<div @mousedown.prevent="startResize($event)"
class="absolute inset-y-0 left-0 w-1.5 cursor-col-resize hover:bg-primary-300"></div>
<!-- Header -->
<div class="flex items-center justify-between border-b px-4 py-3">
<h2 class="font-semibold">Assistant</h2>
<button @click="open = false">Close</button>
</div>
<!-- Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-3"
x-ref="messages"
x-effect="$nextTick(() => $refs.messages.scrollTop = $refs.messages.scrollHeight)">
@foreach($messages as $msg)
<div class="{{ $msg['role'] === 'user' ? 'ml-8 bg-primary-50' : 'mr-8 bg-gray-50' }} rounded-lg p-3 text-sm">
{!! \Illuminate\Support\Str::markdown($msg['content']) !!}
</div>
@endforeach
@if($loading)
<div class="mr-8 bg-gray-50 rounded-lg p-3 text-sm text-gray-400 animate-pulse">
Thinking...
</div>
@endif
</div>
<!-- Input -->
<form wire:submit="send" class="border-t p-3">
<div class="flex gap-2">
<textarea wire:model="message"
@keydown.enter.prevent="if (!$event.shiftKey) $wire.send()"
rows="1" placeholder="Ask anything..."
class="flex-1 resize-none rounded-lg border px-3 py-2 text-sm"></textarea>
<button type="submit" class="rounded-lg bg-primary-600 px-4 py-2 text-sm text-white">
Send
</button>
</div>
</form>
</div>
</div>
Include the panel in your layout (resources/views/layouts/app.blade.php):
<!-- Before closing </body> -->
@auth
<livewire:assistant.assistant-panel />
@endauth
Pass context from detail pages (optional):
<!-- On an order detail page -->
<div x-init="$dispatch('assistant:context', { type: 'order', id: '{{ $order->id }}' })">
If you want the assistant to work with locally installed CLI agents (Claude Code, Codex):
Claude Code — text-based <tool_call> loop:
<tool_call>{"name": "...", "arguments": {...}}</tool_call> tagsCodex — MCP native (requires /module:mcp):
codex exec --full-auto -c 'mcp_servers={"project-name": {"command": "php", "args": ["artisan", "mcp:start", "project-name"]}}'analyze)/module:assistant analyze
Scans the project and outputs a plan: which tools to create, what context types to support, and the system prompt structure.
tools)/module:assistant tools
Only generates the PrismPHP tool registry and tool classes. Useful when adding tools to an existing assistant.
┌─────────────────────────────────────────────┐
│ Livewire AssistantPanel │
│ ┌────────────────────────────────────────┐ │
│ │ Messages list + Input + Conversation │ │
│ │ sidebar + Resizable panel │ │
│ └────────────────┬───────────────────────┘ │
│ │ wire:submit="send" │
├───────────────────┼─────────────────────────┤
│ ▼ │
│ SendAssistantMessageAction │
│ ┌────────────────────────────────────────┐ │
│ │ 1. Save user message │ │
│ │ 2. Resolve provider (cloud/local) │ │
│ │ 3. Get tools (filtered by role) │ │
│ │ 4. Build system prompt + context │ │
│ │ 5. Execute (cloud/claude-code/codex) │ │
│ │ 6. Save assistant response │ │
│ └────────────────┬───────────────────────┘ │
│ ┌─────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌────────┐ ┌──────────┐ │
│ │ Cloud │ │ Claude │ │ Codex │ │
│ │ PrismPHP│ │ Code │ │ MCP │ │
│ │ native │ │ <tool_ │ │ native │ │
│ │ tools │ │ call> │ │ tools │ │
│ └─────────┘ └────────┘ └──────────┘ │
└─────────────────────────────────────────────┘
After implementation, verify:
assistant_conversations, assistant_messages)@authtools
Add a Laravel MCP server to any project — full domain coverage with dual transport (HTTP + stdio)
development
Search the web for latest Claude API changes, compare findings to existing documentation, and apply targeted updates to keep prompts and configs current. Use when docs are outdated, after a Claude API update, to refresh system prompts, or to validate documentation accuracy.
development
Reduce token usage by selecting a planning strategy, loading Serena memories, enforcing symbol-first exploration, and reporting savings. Use when starting a non-trivial task, asking to reduce token costs, requesting efficient execution, or wanting optimized Claude Code workflows.
development
Initialize Claude Code optimization for a new project — detect stack, create memories, generate constitution, configure settings