Packstub

Tenancy

The package works in an app without tenancy: one workspace, the app's name, everyone's conversations in one table. With workspaces it follows the current one: the prompt names it, the budget counts it, the MCP endpoint carries it and tokens are bound to it.

Workspaces

Tell the package what a workspace is and how the current one is found (see Installation for each hook):

use App\Models\Team;
use Packstub\Agents\Facades\Agents;

Agents::tenantModel(Team::class, slugAttribute: 'slug');
Agents::tenantUsing(fn (): ?Team => auth()->user()?->currentTeam);
Agents::enteringTenant(function (Team $team): Closure {
    tenancy()->initialize($team);

    return fn () => tenancy()->end();
});

A queue worker running a turn, an MCP request, AgentRun::in() and the email channel all enter the workspace the turn, the path, the call or the mail names: the model is found by key or slug, the person's canAccessTenant() is checked, and enteringTenant() runs (its return value runs on leaving), so a database switch or a scope happens before any tool does. When the person is not a member, nothing is entered: the context throws Packstub\Agents\Exceptions\WorkspaceAccessDenied ("You are not a member of this workspace."), AgentRun lets it through to the caller, the email channel drops the mail without a reply, and a queued turn whose membership was revoked after the question ends failed with that line. A key or slug that matches no row of the workspace model (deleted after the question, an unknown slug in a mail) is refused the same way, with WorkspaceNotFound ("This workspace no longer exists.", a WorkspaceAccessDenied), never entered as "no workspace".

A workspace is also refused when nobody acts at all: AgentRuntime::enter(['tenant' => $key]) with no user and nobody signed in throws the same exception rather than entering unchecked. A job of your own that works inside a workspace for the app itself, not for a person (a scheduled re-embedding, a nightly report), says so with system => true:

$leave = AgentRuntime::enter(['tenant' => $team->getKey(), 'system' => true]);

try {
    // enteringTenant() has run; queries are scoped to $team, nobody is signed in.
} finally {
    $leave();
}

system only stands in for the missing person: once a user is given or signed in, their membership is checked as usual. packstub-agents:embed --tenant= enters this way.

The check does not stop at the door. A turn is several model round-trips with tool calls in between and keeps the person and the workspace from the moment it entered, so every tool call asks canAccessTenant() again: once the person is removed from the workspace, the remaining calls of the turn are refused with that line (a tool error the model reads, ToolAuthorized with refusedBy: 'workspace') and nothing more is read or changed for them. Keep canAccessTenant() a query of what is true now (a pivot lookup, not a flag cached on the user) for this to hold; without workspaces nothing is asked.

In a Filament panel, the panel's tenant is the workspace, Filament's TenantSet event plays the part of enteringTenant(), and Filament Tenancy switches the database on it; see Filament Agents.

The MCP path

Put {tenant} in the path so an external agent works inside one workspace (once Agents::tenantModel() is set, a path without it is refused with a 404 and the line to fix it):

// config/packstub-agents.php
'mcp' => [
    'path' => 'mcp/{tenant}',
],

AuthenticateAgent then, after auth:sanctum:

  1. looks the workspace up by the slug attribute of tenantModel() (or the key);
  2. checks the person's membership with canAccessTenant() on the user model (404 otherwise);
  3. checks that the token carries tenant:{slug} (403 when it was issued for another workspace, even when the person is a member there);
  4. signs the token's user in on the request's guard and enters the workspace.

Tools do not need to know they run over MCP. Mint the token with the workspace ability for that URL:

$user->createToken('acme', ['read', 'tenant:'.$team->slug], now()->addDays(30));

A workspace's own key

A workspace can bring its own provider, key and preferred model. Tell the package where they come from:

use Packstub\Agents\Ai\WorkspaceCredentials;
use Packstub\Agents\Facades\Agents;

Agents::credentialsUsing(fn () => ($team = Agents::tenant())
    ? new WorkspaceCredentials(
        provider: $team->settings->assistant_provider,   // 'anthropic' | 'openai' | 'gemini' | 'xai' | any laravel/ai text provider
        apiKey: $team->settings->assistant_api_key,
        model: $team->settings->assistant_model,         // a model key: 'auto' | 'fast' | 'deep'
    )
    : null);

When the callback returns credentials with a key, the turn runs on that provider with that key (the key is swapped into laravel/ai's config for the request), and the agent is enabled for that workspace even when the platform has no key. Return null for "the platform's provider". The catalog for the workspace is that provider's list without any entry that names another provider (see models): such an entry would run on the platform's key and silently change who pays. A workspace on the platform key sees every entry whose provider has a platform key.

Per-workspace limits

An agent_limits row with the tenant scope overrides the global one for that workspace; enabled on it switches the agent off for the workspace. See Budgets and limits.

Database per tenant

By default the package runs its migrations from the vendor directory. In a database-per-tenant app, the tables belong to different databases:

Table Where Why
agent_limits central limits are the operator's
agent_conversations, agent_conversation_messages, agent_message_feedback, agent_conversation_summaries, agent_turns, agent_tool_calls tenant one workspace never sees another's conversations, and an export or restore carries them along; the turn job reads its row on the workspace's connection

So:

// config/packstub-agents.php
'run_migrations' => false,
'limits_connection' => env('AGENT_LIMITS_CONNECTION', 'central'),
php artisan vendor:publish --tag=packstub-agents-migrations

Keep create_agent_limits_table with your central migrations and move create_agent_chat_tables, create_agent_conversation_summaries_table, create_agent_turns_table and create_agent_tool_calls_table next to your tenant migrations. AgentLimit reads limits_connection, so limits are edited from the central side; agent_turns is then a tenant table, read inside the workspace.

What follows the workspace

Without tenancy With tenancy
Prompt "Workspace: {app name}" "Workspace: {tenant name}"
Per-minute rate limit key central + user tenant key + user
Turns per day, tokens per month the whole app the tenant's database (with database per tenant)
Tokens read / write read / write / tenant:{slug}
A queued turn runs as the person enters the workspace first, leaves it after