Packstub

Budgets and limits

An agent that calls a frontier model on every question needs a ceiling. The package counts what laravel/ai already stores with every assistant message (the provider's token usage) and refuses a turn before it reaches the provider when a limit is hit. The provider's own spend limit stays the real backstop.

The limits

Limit Scope Config key / env
Questions per minute per user turns_per_minute / AGENT_TURNS_PER_MINUTE (6)
Answers per day per workspace (a message the app posted is not one) turns_per_day / AGENT_TURNS_PER_DAY (150)
Tokens per day per workspace, all token kinds tokens_per_day / AGENT_TOKENS_PER_DAY (600,000)
Tokens per month per workspace, all token kinds tokens_per_month / AGENT_TOKENS_PER_MONTH (3,000,000)
Tokens per day per user, inside the workspace user_tokens_per_day / AGENT_USER_TOKENS_PER_DAY (100,000)
Tokens per month per user, inside the workspace user_tokens_per_month / AGENT_USER_TOKENS_PER_MONTH (1,500,000)
Max question length characters prompt_max_chars / AGENT_PROMPT_MAX_CHARS (2,000)

null (or 0 in the environment) disables a limit. The values in config/packstub-agents.php are the platform's ceiling; the rows below override them.

When a turn is refused nothing is sent to the provider. The question stays in the conversation and the turn ends refused with the reason ("This workspace reached today's limit of 150 answers. It resets at midnight."), so nothing typed is lost and it can be sent again once the limit allows. The EnforceBudget middleware makes the check when the turn runs, and counts it, so the limits hold for every turn however it was started — a follow-up that waited in the queue, a console command, an app that prompts the agent directly.

The agent_limits rows

Rows in the agent_limits table (Packstub\Agents\Models\AgentLimit) override the config with three scopes:

Scope Applies to Fields
global every workspace and user all of them, plus enabled
tenant (scope_id = the workspace key) one workspace all of them
user (scope_id = the user id) one account, in every workspace the per-user fields: enabled, turns_per_minute, user_tokens_per_day, user_tokens_per_month, prompt_max_chars
use Packstub\Agents\Models\AgentLimit;
use Packstub\Agents\Support\AgentLimits;

AgentLimit::query()->create(['scope' => 'global', 'turns_per_day' => 300]);
AgentLimit::query()->create(['scope' => 'tenant', 'scope_id' => (string) $team->id, 'tokens_per_month' => 10_000_000, 'note' => 'Enterprise plan']);
AgentLimit::query()->create(['scope' => 'user', 'scope_id' => (string) $user->id, 'enabled' => false]);
AgentLimits::flush();

Empty fields inherit: user → workspace → everyone → the config defaults. enabled on a workspace row switches the agent off for that workspace entirely (AgentModels::enabled() turns false, so a chat surface hides the assistant); on a user row, or in an app without workspaces, it refuses every turn instead (AgentBudget::refusal() names it) while the assistant stays visible. Rows live on packstub-agents.limits_connection (AGENT_LIMITS_CONNECTION), the central connection in a database-per-tenant app, since limits are the operator's, not the workspace's. Resolved limits are cached for the request; call AgentLimits::flush() after an edit. Agents::limitsAuthorizeUsing() and Agents::canManageLimits() are the hook and the check for who may edit them in your own admin.

In a Filament panel, the operator's AI limits resource of Filament Agents edits these rows, and its AI turns page lists the records below.

In code

use Packstub\Agents\Support\AgentBudget;
use Packstub\Agents\Support\AgentLimits;

AgentLimits::effective();          // the merged limits for the current tenant and user
AgentLimits::effective($tenant, $user);
AgentBudget::refusal($prompt);     // the reason a turn may not start, or null
AgentBudget::summary();            // turns today, tokens this month, per-user counters and their limits
AgentBudget::turnsToday();         // the counters behind them, for the workspace…
AgentBudget::tokensToday($userId); // …and for one person (null = the workspace)
AgentBudget::tokensThisMonth($userId);
AgentBudget::hit();                // count a turn against the per-user burst limit (the job does this)

AgentBudget::summary() is what a workspace settings page shows next to "your AI usage this month". Every counter comes from agent_turns, the record each turn leaves when it ends (see below): the turns that ended in the current workspace, so on a shared database one workspace's turns never count against another's, and a person's own tokens are theirs in each workspace. Answers per day are the turns the provider answered (done, or stopped by the person while it answered); tokens are what every ended turn used, a turn that failed half-way included. Outside every workspace, and in an app without workspaces, the turns without one count. No extra bookkeeping is needed, but keep chat.keep_turns_days at 31 or more (it is 90 by default): the monthly counters only see the turns that are still there.

A guard rail of your own (a plan without the assistant, a frozen workspace) is a middleware that throws TurnRefused with the message the person should read.

What each turn cost

Every turn leaves a record on its agent_turns row (Packstub\Agents\Models\AgentTurn) when it ends: the provider and model that answered, the token usage the provider reported (input_tokens and output_tokens, inclusive as laravel/ai 1.0 counts them, beside the cache reads and writes and the reasoning tokens they include; tokensIn() and tokensOut() read a row stored before 1.0 the same way), the tools called in order, the wall time, and how it ended — stop, length, content_filter or dropped from the provider, stopped by the person, refused by a middleware, failed by an error or a lost worker. Query the model for a usage page, a cost per team or an alert.

Ended turns are kept for chat.keep_turns_days (AGENT_KEEP_TURNS_DAYS, 90; null keeps them forever) and pruned by Laravel's model pruning — add the model to your schedule:

Schedule::command('model:prune', ['--model' => [
    \Packstub\Agents\Models\AgentTurn::class,
    \Packstub\Agents\Models\AgentToolCall::class, // the tool call log, after tool_log.keep_days
]])->daily();

The rows live with the conversations (ai.conversations.connection); in a database-per-tenant app that is the tenant database.

Cost in money

Fill pricing.models with your provider's prices per million tokens by model name (a key is also a prefix, so claude-opus-5 covers every dated variant): in for the uncached input and out for the answer (reasoning tokens count as out), cache_read and cache_write for the cached prefix where the provider reports it. When a turn ends its cost column is computed from its usage (AgentPricing::cost($model, $usage)), in pricing.currency (AGENT_PRICING_CURRENCY, USD); a model without a price leaves it null and the tokens alone show. AgentChat::history()['turns']['cost'] sums it over a chat, AgentPricing::format($cost) prints it ("$0.0123"). The package ships no prices, since they change without notice.

The log line

Set log.channel (AGENT_LOG_CHANNEL) to a channel from config/logging.php and every ended turn writes one info line there — Agent turn done: anthropic/claude-opus-5, 1,240 tokens in, 310 out, 2 tool calls, 4.2 s, ended stop — with the whole record in the context (turn, conversation, user, tenant, panel, status, provider, model, model_key, input_tokens, output_tokens, the cache and reasoning counts, tool_calls, duration_ms, cost, finish_reason, error). Point it at a JSON channel for your log platform, or at stack to keep it with the app log. null (the default) logs nothing; the row carries the record either way.

For anything beyond that — an audit trail with the prompt, redaction, a plan check — write a middleware and read the response in its then() callback.