Packstub

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 (anthropic here); AGENT_PROVIDER picks the list, provider on 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. Without GEMINI_API_KEY, Gemini Flash is left out, quietly. Ollama needs no key, so give OLLAMA_API_KEY any 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 failover list 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 own failover replaces the global list; [] on local keeps 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.