The SDK’s unit is the agent: a class that holds the instructions, the schema and the tools for one job.
php artisan make:agent SiteCopywriter --structured
The class name is the first decision, and it deserves more care than it gets. Name it after what your application needs, not after the model or the provider. SiteCopywriter, FieldRewriter, PageTranslator, EditorPlanner. Never OpenAiAgent or GptHelper.
namespace App\Ai\Agents;
use App\Ai\Lane;
use App\Ai\TenantProviders;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\MaxTokens;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Laravel\Ai\Providers\Provider;
#[Timeout(55)]
#[MaxTokens(6000)]
final class SiteCopywriter
implements Agent, HasStructuredOutput
{
use Promptable;
public function __construct(
private readonly Workspace $workspace,
) {}
public function provider(): Provider
{
return app(TenantProviders::class)
->for($this->workspace, Lane::Copy);
}
public function instructions(): string
{
return SiteCopyPrompt::SYSTEM;
}
public function schema(JsonSchema $schema): array
{
return SiteCopySchema::for($schema);
}
}
There is no messages array in here, no response_format, no temperature. Those are one provider’s vocabulary, and they stay inside the SDK. What the class does say is everything your application has decided about this job: whose credentials pay for it, how long it may take, what it may cost, what it is told and what shape it must return.
That last list is the point. An agent class is not a wrapper around a model. It is the place where the decisions about one use of a model are written down, in a file, in a commit, where a reviewer can see them.
The test of whether the boundary is in the right place is the same as it always was: could you move this job to a completely different provider without changing a single caller? With the provider built in one method and the vocabulary owned by the SDK, the answer is a one-line change in TenantProviders.
Credentials Belong to a Tenant, Not to the Application
Here is the decision that most first implementations get wrong, and it is very hard to retrofit.
The SDK’s configuration file holds one key per provider. That is the right default for a demo and the wrong one for a product. A single application-wide key means every tenant’s usage lands in one bucket. You cannot attribute spend. You cannot cap one noisy tenant without capping all of them. You cannot let a customer bring their own key. And if that key is rotated or rate-limited, every tenant fails at once.
So the provider is built per call, from the workspace that is paying for it:
namespace App\Ai;
use App\Exceptions\MissingAiCredentials;
use App\Models\Workspace;
use Laravel\Ai\Ai;
use Laravel\Ai\Providers\Provider;
final class TenantProviders
{
public function for(
Workspace $workspace,
Lane $lane,
): Provider
{
$credential = $workspace->aiCredential;
if (
$credential === null
|| $credential->key === ''
) {
throw MissingAiCredentials::for($workspace);
}
return Ai::build([
'driver' => $credential->driver,
'key' => $credential->key,
'models' => [
'text' => [
'default' => $lane->model(
$credential->driver,
),
],
],
]);
}
}
Ai::build() turns a configuration array into a provider at runtime, which is exactly the hook a multi-tenant application needs. The credential row stores the driver and an encrypted key; the lane, which Chapter 4 introduces properly, chooses the model for that driver. A merchant who switches from one provider to another changes a row, and every agent follows.
There is no fallback to a shared key in the environment, and that absence is deliberate. Our first version had one, for the tenants who had not brought their own. It meant a tenant whose stored credential was broken silently billed us instead of failing, and a test environment with a real key in its .env could spend money nobody attributed. We removed the fallback in October. A workspace without a credential now fails, every time, at the boundary.
Do this on day one. Retrofitting per-tenant credentials into a system that assumed one global key means touching every call site, every test and every cache key, usually under pressure, usually because a bill arrived.