Configuration
php artisan packstub-agents:install publishes config/packstub-agents.php. What the app registers in code goes through the Agents facade (below); config is what every runtime (queue, console, MCP requests) reads, so anything a panel plugin sets fluently is mirrored into it.
config/packstub-agents.php
| Key | Default | Env | What it does |
|---|---|---|---|
name |
Assistant |
AGENT_NAME |
how the assistant introduces itself |
panel |
null |
the panel the assistant lives in; set by the Filament plugin when it registers, null otherwise |
|
provider |
anthropic |
AGENT_PROVIDER |
anthropic, openai, gemini or xai have picker entries; any other laravel/ai text provider (ollama, openrouter, mistral, groq, deepseek…) runs on its smartest and cheapest models. The platform default; a workspace may bring its own |
failover |
[] |
AGENT_FAILOVER |
providers to fall back to, in order (gemini,openai), when the platform provider refuses a turn before it started answering; see Failover |
enabled |
null |
AGENT_ENABLED |
null = enabled when a key exists for the provider in use or for the provider of any catalog entry; false switches the agent off (the MCP endpoint stays) |
models |
see below | AGENT_MODEL, AGENT_MODEL_FAST, AGENT_MODEL_DEEP |
the model catalog per provider: model, effort, an optional label (the model's name otherwise) and optionally the provider the entry runs on |
max_steps |
12 |
tool round-trips one turn may take before the agent has to answer | |
max_tokens |
4096 |
answer length | |
max_conversation_messages |
40 |
a second ceiling on the history window beside history.max_tokens: at most this many earlier rows are replayed, however many fit the token budget |
|
middleware |
[] |
your own agent middleware, run on every turn after the package's guard rails; see Middleware | |
prompt_guard.enabled |
false |
AGENT_PROMPT_GUARD |
classify every question before the assistant reads it and refuse injections, jailbreaks and data exfiltration; see The prompt guard |
prompt_guard.provider, prompt_guard.model |
null |
AGENT_PROMPT_GUARD_PROVIDER, AGENT_PROMPT_GUARD_MODEL |
where the classifier runs; null = the turn's provider, its cheapest model |
prompt_guard.refuse |
injection, jailbreak, data_exfiltration | the categories that stop a turn, as a map ('off_topic' => true adds one) |
|
prompt_guard.fail_open |
true |
whether a turn runs when the classifier itself fails | |
redact.enabled |
false |
AGENT_REDACT |
replace secrets and personal data in answers, while they stream and as stored, and in stored tool results; see Redaction |
redact.detect |
card, ssn, api_key | the built-in detectors, as a map ('ssn' => false switches one off) |
|
redact.patterns |
[] |
your own, label => regex | |
redact.replacement |
[redacted] |
what a value is replaced with | |
decision_classifier.enabled |
false |
AGENT_DECISION_CLASSIFIER |
read a reply typed over pending proposals with a small model when the word lists cannot, one decision per proposal; see Decisions in words |
decision_classifier.driver |
agent |
AGENT_DECISION_CLASSIFIER_DRIVER |
what reads the reply: agent (a side agent on your provider), jev (TypeSafe's Jev decision model) or a class implementing Packstub\Agents\Contracts\DecisionClassifier |
decision_classifier.provider, decision_classifier.model |
null |
AGENT_DECISION_CLASSIFIER_PROVIDER, AGENT_DECISION_CLASSIFIER_MODEL |
where the agent driver runs; null = the provider of the model the reply was sent with, its cheapest model |
decision_classifier.min_confidence |
0.8 |
AGENT_DECISION_CLASSIFIER_MIN_CONFIDENCE |
a decision given with a lower confidence makes the reply a question; only drivers that give one (jev) are held to it; null applies every decision |
decision_classifier.jev.key, .url, .model, .timeout |
null, https://api.typesafe.ai, jev-latest, 5 |
TYPESAFE_API_KEY, TYPESAFE_BASE_URL, TYPESAFE_DEFAULT_MODEL |
the jev driver: the API key, the base URL, the model (jev-latest or a pinned jev-1.13.0) and the request timeout in seconds |
classify.enabled |
false |
AGENT_CLASSIFY |
classify each chat after an answer (topic, sentiment, resolved); see Classification |
classify.topics |
[] |
a fixed list of topics to pick from; empty = the model names the topic | |
web_search.enabled |
false |
AGENT_WEB_SEARCH |
the provider's web search in the chat; see Web search |
web_search.allow |
[] |
AGENT_WEB_SEARCH_ALLOW |
the domains it may read, comma-separated in env; empty = the whole web |
web_search.max |
3 |
AGENT_WEB_SEARCH_MAX |
searches per turn |
web_search.location |
none | city, region, country to refine results |
|
knowledge_base.* |
none | the app's documents for "how do I…" questions: model, column, title, content, url, min_similarity, limit, stores, ability; see Knowledge base |
|
history.max_tokens |
24000 |
AGENT_HISTORY_MAX_TOKENS |
the history window, in estimated tokens; what no longer fits is folded into a rolling summary the model reads first |
history.keep_tool_results_turns |
3 |
tool results older than this many turns are replaced by a one-line placeholder when replayed | |
history.notice_share |
0.7 |
from this share of the window a chat surface suggests continuing in a new chat | |
history.meter_share |
0.25 |
from this share of the window a chat surface shows a context meter | |
history.compress_keep_turns |
2 |
how many of the latest exchanges AgentConversationStore::compactNow() keeps verbatim while it folds the rest into the rolling summary |
|
chat.driver |
queue |
AGENT_TURN_DRIVER |
how a turn runs: queue hands the job to a worker, sync runs it inside the request (no worker; an answer ends with the request that asked) |
chat.queue_connection |
null |
AGENT_QUEUE_CONNECTION |
the queue connection the turn job runs on with the queue driver; null = the app's default |
chat.queue |
null |
AGENT_QUEUE |
the queue name; null = the connection's default |
chat.job_timeout |
600 |
AGENT_JOB_TIMEOUT |
how long one turn may run on the worker, in seconds; a turn whose job went quiet for longer is marked failed |
chat.worker_wait |
10 |
AGENT_WORKER_WAIT |
how long a turn may wait for a worker before the status line says none has taken it, in seconds (the queue driver only) |
chat.poll_interval |
600 |
AGENT_POLL_INTERVAL |
how often a chat surface asks for the answer so far while a turn runs, in milliseconds |
chat.stream_interval |
150 |
AGENT_STREAM_INTERVAL |
how often the stream endpoint checks the turn row while it pushes events, in milliseconds |
chat.stream_seconds |
55 |
AGENT_STREAM_SECONDS |
how long one event stream stays open before the browser reconnects |
chat.attachments.enabled |
true |
AGENT_ATTACHMENTS |
files a person may attach to a question |
chat.attachments.disk |
null |
AGENT_ATTACHMENTS_DISK |
the disk they are stored on; null = the default filesystem disk |
chat.attachments.directory |
agent-attachments |
the directory on that disk | |
chat.attachments.max_kb |
10240 |
AGENT_ATTACHMENTS_MAX_KB |
the size cap per file |
chat.attachments.max_files |
5 |
how many files one question may carry | |
chat.attachments.mimes |
images, PDF, text, CSV, Markdown, JSON | the accepted MIME types (image/* accepts every image) |
|
chat.attachments.temporary_urls |
false |
AGENT_ATTACHMENTS_TEMPORARY_URLS |
ask the disk for signed URLs when a chat shows a file (S3) |
chat.path |
agents |
where the poll, stream and email endpoints live: GET {path}/chat/{conversation}/turn, GET …/stream, POST {path}/email, see Routes |
|
chat.middleware |
['web', 'auth'] |
the middleware of that endpoint; the Filament plugin registers its own on the panel's routes instead | |
chat.keep_turns_days |
90 |
AGENT_KEEP_TURNS_DAYS |
how long ended turns (the per-turn record, which the daily and monthly budget counters read: keep it at 31 or more) are kept; null keeps them; pruned by model:prune --model=Packstub\Agents\Models\AgentTurn |
pricing.currency |
USD |
AGENT_PRICING_CURRENCY |
the currency of the prices below |
pricing.models |
[] |
prices per million tokens by model name (in, out, cache_read, cache_write); see Cost in money |
|
email.enabled |
false |
AGENT_EMAIL |
the assistant by email, see The assistant by email |
email.secret |
null |
AGENT_EMAIL_SECRET |
the shared secret the mail webhook must send (X-Agent-Secret) |
email.from |
null |
AGENT_EMAIL_FROM |
the reply's sender; null = the app's mail.from |
email.middleware |
['api'] |
the webhook route's middleware, before the secret check | |
log.channel |
null |
AGENT_LOG_CHANNEL |
the log channel that gets one line per ended turn (provider, model, tokens, tools, duration, how it ended); null logs nothing. See What each turn cost |
tool_log.enabled |
true |
AGENT_TOOL_LOG |
one agent_tool_calls row per tool call, chat and MCP, see The tool call log |
tool_log.results |
true |
AGENT_TOOL_LOG_RESULTS |
keep the result the model read; false keeps the outcome alone |
tool_log.max_bytes |
65536 |
AGENT_TOOL_LOG_MAX_BYTES |
arguments or a result larger than this as JSON keep their first bytes, marked truncated |
tool_log.keep_days |
90 |
AGENT_TOOL_LOG_KEEP_DAYS |
how long rows are kept; null keeps them; pruned by model:prune --model=Packstub\Agents\Models\AgentToolCall |
limits.* |
see Budgets and limits | AGENT_TURNS_PER_MINUTE, AGENT_TURNS_PER_DAY, AGENT_TOKENS_PER_DAY, AGENT_TOKENS_PER_MONTH, AGENT_USER_TOKENS_PER_DAY, AGENT_USER_TOKENS_PER_MONTH, AGENT_PROMPT_MAX_CHARS |
the platform ceiling |
limits_connection |
null |
AGENT_LIMITS_CONNECTION |
the connection of the agent_limits table (the central one in a database-per-tenant app) |
mcp.enabled |
true |
AGENT_MCP_ENABLED |
the MCP endpoint |
mcp.path |
mcp |
the endpoint path; mcp/{tenant} with workspaces |
|
mcp.server |
null |
an AgentServer subclass; null = the package's server with the tools given to Agents::useTools() |
|
mcp.middleware |
['throttle:60,1', 'auth:sanctum', AuthenticateAgent::class] |
the endpoint's middleware | |
run_migrations |
true |
run the package migrations from the vendor directory; false to publish and split them |
The provider keys themselves live in laravel/ai's config/ai.php (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, XAI_API_KEY, and so on for the other providers).
models
'models' => [
'anthropic' => [
'auto' => ['label' => null, 'model' => env('AGENT_MODEL', 'claude-opus-5'), 'effort' => 'medium'],
'fast' => ['label' => null, 'model' => env('AGENT_MODEL_FAST', 'claude-haiku-4-5'), 'effort' => null],
'deep' => ['label' => null, 'model' => env('AGENT_MODEL_DEEP', 'claude-opus-5'), 'effort' => 'xhigh'],
// 'flash' => ['label' => 'Gemini Flash', 'provider' => 'gemini', 'model' => 'gemini-3.5-flash-lite', 'effort' => 'low'],
// 'local' => ['label' => 'Local', 'provider' => 'ollama', 'model' => 'llama3.3', 'effort' => null, 'failover' => []],
],
'openai' => [
'auto' => ['label' => null, 'model' => env('AGENT_MODEL'), 'effort' => 'medium'],
'fast' => ['label' => null, 'model' => env('AGENT_MODEL_FAST'), 'effort' => 'low'],
'deep' => ['label' => null, 'model' => env('AGENT_MODEL_DEEP'), 'effort' => 'high'],
],
'gemini' => [
'auto' => ['label' => null, 'model' => env('AGENT_MODEL', 'gemini-3.8-flash'), 'effort' => 'medium'],
'fast' => ['label' => null, 'model' => env('AGENT_MODEL_FAST', 'gemini-3.5-flash-lite'), 'effort' => 'low'],
'deep' => ['label' => null, 'model' => env('AGENT_MODEL_DEEP', 'gemini-3.8-flash'), 'effort' => 'high'],
],
'xai' => [
'auto' => ['label' => null, 'model' => env('AGENT_MODEL', 'grok-4.6'), 'effort' => 'medium'],
'fast' => ['label' => null, 'model' => env('AGENT_MODEL_FAST', 'grok-4.6'), 'effort' => 'low'],
'deep' => ['label' => null, 'model' => env('AGENT_MODEL_DEEP', 'grok-4.6'), 'effort' => 'xhigh'],
],
],
Each entry is one item in the catalog. Its fields:
| Field | Meaning |
|---|---|
label |
what the catalog shows; null names the entry after its model (Claude Opus 5). A second unlabelled entry on the same model gets its key added (Claude Opus 5 · Deep) |
model |
the model id, in the provider's own terms; null is the provider's smartest model, or its cheapest on the fast key |
effort |
Anthropic's output_config.effort, OpenAI's and xAI's reasoning.effort (reasoning models only) or Gemini's thinking level: low, medium, high (xhigh is sent as high). null sends nothing; use it for a model that rejects the parameter |
provider |
optional: the provider the entry runs on, when it is not the list's own. See Mixing providers |
failover |
optional: this entry's own fallback list, replacing the global failover; [] means none |
The catalog shows the list of the provider in use: models[provider], or the workspace's own provider when it brings a key. Rename, remove or add entries; the keys are yours.
A provider without entries
Ollama, OpenRouter, Mistral, Groq, DeepSeek… get two entries: auto on the provider's smartest model and fast on its cheapest, as laravel/ai names them, with no effort. On OpenRouter those are Claude Opus 5 and Claude Haiku 4.5, routed through OpenRouter. To run the models you chose the provider for, add its list, with an OpenRouter model id (vendor/model, from openrouter.ai/models) in each entry:
'models' => [
'openrouter' => [
'auto' => ['label' => 'Mercury 2.5', 'model' => 'inception/mercury-2.5', 'effort' => null],
'fast' => ['label' => 'GLM 5.3 Flash', 'model' => 'z-ai/glm-5.3-flash', 'effort' => null],
'deep' => ['label' => 'DeepSeek 4.1 Flash', 'model' => 'deepseek/deepseek-v4.1-flash', 'effort' => null],
],
],
The key is OPENROUTER_API_KEY in .env, read by the openrouter entry of laravel/ai's config/ai.php; that entry also takes a url for a compatible gateway and the http_referer and x_title headers OpenRouter uses for app attribution. Effort stays null on OpenRouter, since the reasoning knob differs per model behind it.
Mixing providers
One list can offer models of several providers. An entry names the provider it runs on with provider; its model and effort are in that provider's terms. Claude as the default, Gemini Flash for cheap questions and a local Ollama model for data that must not leave the server:
'provider' => 'anthropic',
'failover' => ['gemini'],
'models' => [
'anthropic' => [
'auto' => ['label' => null, 'model' => 'claude-opus-5', 'effort' => 'medium'],
'fast' => ['label' => null, 'model' => 'claude-haiku-4-5', 'effort' => null],
'flash' => ['label' => 'Gemini Flash', 'provider' => 'gemini', 'model' => 'gemini-3.5-flash-lite', 'effort' => 'low'],
'local' => ['label' => 'Local', 'provider' => 'ollama', 'model' => 'llama3.3', 'effort' => null, 'failover' => []],
],
],
ANTHROPIC_API_KEY=sk-ant-…
GEMINI_API_KEY=AIza…
OLLAMA_API_KEY=local
- The entries live under the platform provider's list (
anthropichere);AGENT_PROVIDERpicks the list,provideron an entry picks where that entry runs. - An entry on another provider is listed only when that provider has a key in
config/ai.php. WithoutGEMINI_API_KEY, Gemini Flash is left out, quietly. Ollama needs no key, so giveOLLAMA_API_KEYany value for its entry to count as configured. AgentModels::groups()groups the entries under provider headings, the platform provider first; a picker built on it shows Anthropic, then Gemini, then Ollama.- Failover works per entry: the global
failoverlist applies to every entry, with the entry's own provider skipped and the platform provider included when it is listed. Here Claude falls back to Gemini, and Gemini Flash has nothing to fall back to. An entry's ownfailoverreplaces the global list;[]onlocalkeeps that model local. - A workspace on its own key sees only the entries of its provider, see Tenancy. The agent is on when the provider in use has a key, or when any listed entry's provider has one.
The Agents facade
Packstub\Agents\Facades\Agents is how the app registers itself, from a service provider's boot():
use Packstub\Agents\Facades\Agents;
Agents::useAgent(Assistant::class);
Agents::useServer(AcmeServer::class);
Agents::useTools([SearchOrders::class, ConfirmOrder::class]);
Agents::useResources([Orders::class, Customers::class]);
Agents::useMiddleware([AuditTurns::class]);
Agents::authorizeUsing(fn (string $ability): bool => auth()->user()->can($ability));
Agents::roleLabelUsing(fn (): ?string => auth()->user()->role?->getLabel());
Agents::credentialsUsing(fn (): ?WorkspaceCredentials => ...);
Agents::limitsAuthorizeUsing(fn (): bool => auth()->user()->is_admin);
Agents::tenantModel(Team::class, slugAttribute: 'slug');
Agents::tenantUsing(fn (): ?Team => auth()->user()?->currentTeam);
Agents::enteringTenant(fn (Team $team): ?Closure => ...);
| Method | |
|---|---|
useAgent(class) |
your Agent subclass (default: the package's DefaultAgent) |
useServer(class) |
the AgentServer subclass with the tool list, name and instructions |
useTools(array) |
the tool list when there is no server class |
addTools(array|Closure) |
tools appended to the server's own list (what the Filament plugin uses for show-table) |
useResources(array) |
the AgentResource classes for filters, summaries and page context |
useMiddleware(array) |
your own agent middleware — classes with handle(PendingStep $step, Closure $next), instances or closures — run on every step of every turn after the package's guard rails, after the ones in config; see Middleware |
authorizeUsing(fn (string $ability): bool) |
how a tool's ability is checked for the current person (default: the Gate when it has that ability, otherwise allowed) |
roleLabelUsing(fn (): ?string) |
the person's role label for the prompt and refusals |
credentialsUsing(fn (): ?WorkspaceCredentials) |
where a workspace's own provider, key and model come from |
limitsAuthorizeUsing(fn (): bool) |
who may edit the agent_limits rows; read back with canManageLimits() |
tenantModel(class, ?slugAttribute) |
the workspace model, and the attribute the MCP path names it by |
tenantUsing(fn (): ?Model) |
how the current workspace is found; unregistered, the app is one workspace |
enteringTenant(fn (Model $tenant): ?Closure) |
what a worker or an MCP request does on entering a workspace; the returned closure runs on leaving |
knowledgeBase(model, column, …) |
the app's documents the assistant searches and cites, see Knowledge base |
redactUsing(fn (string $text): string) |
a redaction of your own, run after the built-in patterns, see Redaction |
It also reads back what the app told the package: name(), tenant(), inPanel(), toolClasses(), optInTools(), knowledge(), redactor(), agentClass(), serverClass(), resourceClasses(), registeredResources() (the resources with their keys), middleware(), allows($ability), roleLabel(), credentials(), canManageLimits(), tenantModelClass(), tenantSlugAttribute(), tenantResolver(), tenantEnterHook(), and context() — the AgentContext that knows who is acting and where (Support\Context\LaravelContext, or the Filament plugin's FilamentContext in a panel). Tools use it; your own code may too. agent($pageContext, $modelKey) builds the configured agent for a turn, the page context and the picker key applied. agentAccess(), agentAccessAbility(), agentAccessGroup(), hideAskButtonOn() and askButtonHiddenOn() live on the same facade but are read by the Filament plugin only.
In a Filament panel, AgentsPlugin::make() has a fluent method for each of these and adds the pages; see Filament Agents.
Translations
Strings are __() calls keyed by the English text, with JSON files for German, Spanish, Romanian and Russian in resources/lang. Add your own language by publishing a JSON file with the same keys into your app's lang/ directory.