Installation
Requirements
| PHP | 8.4 or newer |
| Laravel | 13.x |
| laravel/ai | ^1.0 |
| laravel/mcp | ^1.0 |
| laravel/sanctum | ^4 (tokens for MCP clients) |
The package requires laravel/ai, laravel/mcp and laravel/sanctum, so Composer installs them for you. It has no front end of its own: the tools, the MCP server and its tokens, the turn job, budgets and limits run in any Laravel app.
In a Filament panel, install packstub/filament-agents instead. It requires this package, registers it through AgentsPlugin in the panel provider, and adds the chat pages, the Agent access page and the operator pages. Everything below applies there too; only the registration differs.
Install
composer require packstub/agents
php artisan packstub-agents:install
The install command publishes config/packstub-agents.php, offers to run the migrations and scaffolds app/Ai/Agents/Assistant.php. The migrations create the agent_limits table and the chat tables (agent_conversations, agent_conversation_messages, agent_message_feedback, agent_conversation_summaries, agent_turns) and the tool call log (agent_tool_calls). They run from the package by default; a database-per-tenant app publishes and splits them, see Tenancy. An app upgrading from 1.4 gets one more, add_steps_to_agent_conversation_messages_table, which rewrites the stored messages into the shape laravel/ai 1.0 reads (steps and a status) and keeps the old columns empty; decide or abandon any proposal still waiting before running it.
Register the agent and the tools
A service provider tells the package what it works with, through the Agents facade:
use App\Ai\Agents\Assistant;
use App\Mcp\Servers\AcmeServer;
use Packstub\Agents\Facades\Agents;
public function boot(): void
{
Agents::useAgent(Assistant::class);
Agents::useServer(AcmeServer::class); // or Agents::useTools([SearchOrders::class, ConfirmOrder::class])
Agents::authorizeUsing(fn (string $ability) => auth()->user()->can($ability));
Agents::roleLabelUsing(fn () => auth()->user()->role?->getLabel());
}
useAgent()is yourAgentsubclass, see The agent. Until you call it, the package'sDefaultAgentanswers with only the registered tools and a generic persona.useServer()names theAgentServersubclass with the tool list, see Tools;config('packstub-agents.mcp.server')does the same from config. Without either, the package's own server serves whatuseTools()was given, or its genericdraw-charttool alone.authorizeUsing()tells the package how to check an ability for the current person. Without it, an ability goes through Laravel'sGatewhen a gate of that name exists and is otherwise allowed, so an app without abilities works out of the box.roleLabelUsing()gives the prompt and the refusal messages a role name ("Your role (Viewer) is not allowed to do this").useResources(),credentialsUsing(),useMiddleware()andlimitsAuthorizeUsing()are described in Configuration.- The assistant's name comes from config (
AGENT_NAME).
The scaffold commands print the same registration: packstub-agents:agent and packstub-agents:tool end with the facade call to add.
Who is acting and where
Every part of the package that needs the person, the guard, the workspace or the locale reads it from Packstub\Agents\Contracts\AgentContext, bound in the container as Packstub\Agents\Support\Context\LaravelContext:
- The person is whoever the guard in use holds — the default guard, or the one an auth middleware picked (
auth:sanctumon the MCP endpoint). A turn records the guard it was asked on and the worker signs the person in on that same guard. - The workspace is what
Agents::tenantUsing()resolves; unregistered, the app is one workspace. - The locale is the app's.
Workspaces
Tell the package what a workspace is and how the current one is found:
use App\Models\Team;
use Packstub\Agents\Facades\Agents;
Agents::tenantModel(Team::class, slugAttribute: 'slug');
Agents::tenantUsing(fn (): ?Team => auth()->user()?->currentTeam);
// Optional: what a database switch needs when a worker or an MCP request enters a workspace.
Agents::enteringTenant(function (Team $team): Closure {
tenancy()->initialize($team);
return fn () => tenancy()->end();
});
tenantModel()is the model a worker finds a workspace by (its key is stored on the turn) and the MCP path names it by (slug, or the key when null).tenantUsing()resolves the current workspace of a request: budgets, limits, the prompt's workspace line and a workspace's own provider key (credentialsUsing()) are keyed by it.enteringTenant()runs when a queue worker or an MCP request enters a workspace that was found by key or slug — the place for a database switch or whatever your tenancy layer needs. Whatever it returns runs when the worker leaves the workspace again, so a long-lived worker does not stay on the last workspace's connection; return nothing when there is nothing to undo.- Membership goes through your user model's
canAccessTenant(Model $tenant): boolwhen it has one; without it every signed-in person may enter every workspace, so add the method as soon as you have more than one.
Put {tenant} in the MCP path ('mcp/{tenant}') and a token is bound to the workspace it was minted for: see Tenancy.
A queue worker
Every answer is produced in a queued job (Packstub\Agents\Jobs\RunAgentTurn), so no request holds a connection open while the model works and an answer keeps coming after the request that asked for it ended. Run a worker as you would for any queued job (php artisan queue:work, Horizon, Laravel Cloud's workers); chat.queue_connection and chat.queue pick where the jobs go.
A turn that sits on the queue for chat.worker_wait seconds (10) without a worker taking it says so on the status line, with the command to run, instead of "Thinking…" until the job timeout.
No worker? Set chat.driver to sync (AGENT_TURN_DRIVER=sync) and the job runs inside the request that asked, whatever the app's queue connection is — everything else the same, except that an answer dies with the request. See The agent.
Sanctum
MCP clients authenticate with Sanctum personal access tokens, so your user model needs the HasApiTokens trait and the personal_access_tokens table:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens;
}
php artisan vendor:publish --tag=sanctum-migrations
php artisan migrate
Skip this when you only run turns from your own app and set AGENT_MCP_ENABLED=false.
Routes
| Route | Where | Middleware |
|---|---|---|
POST {mcp.path} |
the MCP endpoint | mcp.middleware (throttle:60,1, auth:sanctum, AuthenticateAgent) |
GET {chat.path}/chat/{conversation}/turn |
what a chat polls while an answer is produced: {"active": {"id", "status", "statusText", "html", "tools"} | null, "version"} — the running turn with its status line (AgentTurns::statusText()), the answer so far rendered, the tools called so far, and a version stamp that changes whenever the conversation did |
chat.middleware (['web', 'auth']) |
GET {chat.path}/chat/{conversation}/stream |
the same state as server-sent events, pushed whenever it changes, see Live updates | chat.middleware |
POST {chat.path}/email |
the inbound mail webhook, see The assistant by email; 404 until the channel is on | email.middleware (['api']), then the shared secret |
chat.path defaults to agents. The poll and stream endpoints answer for the conversation's own participant; they are left out when the Filament plugin registered its own on the panel.
The provider key
Provider credentials live in laravel/ai's config/ai.php, so the usual environment variables work:
AGENT_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-…
or
AGENT_PROVIDER=openai
OPENAI_API_KEY=sk-…
or
AGENT_PROVIDER=gemini
GEMINI_API_KEY=AIza…
or
AGENT_PROVIDER=xai
XAI_API_KEY=xai-…
or
AGENT_PROVIDER=openrouter
OPENROUTER_API_KEY=sk-or-…
The first four have model entries out of the box (auto, fast and deep, named by model, see Configuration). Any other laravel/ai text provider works too — AGENT_PROVIDER=ollama for a local model, openrouter as above, mistral, groq, deepseek — with its key in config/ai.php; the catalog then offers the two models laravel/ai names for the provider, its smartest and its cheapest, by name; pin other ones with an entry per model, see OpenRouter for an example.
Without a key AgentModels::enabled() is false — a chat surface hides itself — and the MCP endpoint keeps answering, since it does not need a model. AGENT_ENABLED=false switches the agent off regardless.
Write a first tool
php artisan packstub-agents:tool SearchOrders --ability=orders.view
Add it to the server's $tools, mint a token and connect a client. The next page, Tools, explains what goes into a tool.