Skip to content
Back to Writing

Building an Agentic Assistant in Laravel with the Anthropic PHP SDK: Tools, Model Lanes, Prompt Caching, and Per-Tenant Metering

Byte Smith · · 13 min read

A chat box is not an assistant. A model that can only talk about your business is a very expensive help page. What makes the assistant in Zenpresa useful is that it can do things: book the appointment, send the invoice, find the lapsed customers, apply the theme. Every one of those actions is a tool, and the design of the tool contract turned out to matter more than the choice of model, the prompt, or anything else we spent time on.

This post is the engineering account of that assistant. It runs on Laravel 12 and the official Anthropic PHP SDK, sits inside the database-per-tenant architecture described in the previous post, and has to be safe, cheap, and fast for many businesses at once. Prompts, exact limits, and model identifiers are deliberately left out; the patterns are the point.

The tool contract

Every action the assistant can take implements one PHP interface. Slightly simplified, with invented names:

interface AssistantTool
{
    /** snake_case name exposed to the model */
    public function name(): string;

    /** Prescriptive: "Call this when the user wants to…" */
    public function description(): string;

    /** JSON Schema for the input */
    public function inputSchema(): array;

    /** Module that must be enabled for the tenant, or null */
    public function moduleKey(): ?string;

    /** Is this tool usable in this deployment at all? */
    public function isAvailable(): bool;

    /** Does this action need explicit human confirmation? */
    public function requiresConfirmation(): bool;

    /** May this user run it? Re-checked on every call. */
    public function authorize(User $user): bool;

    /** Returns summary, data, link, and an optional diff */
    public function execute(array $input, User $user): array;
}

Two things about this shape have held up over dozens of tools.

First, the result is uniform. Every tool returns a short human-readable summary (shown to the user and fed back to the model), a data array (returned to the model only), an optional deep link into the app, and an optional diff with before/after state. The chat UI renders those four fields the same way for every tool, so adding a tool never means touching the frontend. The diff in particular has been the single most trusted piece of UI in the product: when the assistant changes a service price, the user sees old and new side by side.

Second, the description is prescriptive, not descriptive. “Books an appointment” is a description. “Call this when the user wants to put a specific customer into a specific time slot for a specific service; call get_available_slots first if the time is uncertain” is an instruction. The second kind produces dramatically better tool selection, and it is the reason our tool descriptions read like a runbook.

A registry gated by subscription

Tools live in a registry keyed by name. When a conversation starts, the registry builds the list the model will see by filtering on three things: isAvailable() (is the platform configured for it), moduleKey() (is that module in the tenant’s subscription), and authorize() for the current user. A tenant without the marketing module never sees a “send campaign” tool. A staff member without invoicing permissions never sees “create invoice”.

This is not just tidiness. The model cannot call a tool it was never told about, and a tool it was not told about cannot leak in its description that the feature exists. The registry is the single place where “what can this user do through the assistant” is decided, and it reads the same feature flags and permission checks the rest of the app uses.

Authorize on every call, server-side

The registry filtering happens once per conversation. Authorization happens on every tool execution, again, on the server. Permissions can change mid-conversation, a conversation can be resumed hours later, and the model’s transcript is not a source of truth about anything.

Inside execute(), the input is validated with the same Laravel validation rules the equivalent HTTP controller uses. We did not write a separate, looser validator for the assistant. If the API says a service price must be a non-negative integer in minor units, the tool says the same, and a ValidationException is turned into a structured error the model can read and correct. The model never carries authority; it carries a request that the server evaluates exactly as if it had come from a button click.

Model lanes pinned per conversation

Not every message needs the same model. “What was my busiest day last week?” needs a fast, cheap model that is good at calling tools correctly. “Rewrite my homepage hero copy to sound warmer” needs a model with more range. We call these lanes: a standard lane and a creative lane, each with its own model and its own reasoning settings, set explicitly rather than inherited from a default.

The lane is chosen once, at the start of a conversation, by a small classifier over the first message. It is then pinned for the life of the conversation. The reason is prompt caching: cached prefixes are scoped to a model, so switching models mid-conversation throws away the cache and re-bills the entire system prompt and tool list on the next turn. Pinning means the second and every subsequent turn hits the cache. The cost of occasionally being in the “wrong” lane for one message is far smaller than the cost of a cache miss on every message.

Prompt-cache breakpoints that actually hit

Anthropic’s prompt caching bills cached input tokens at a fraction of the normal rate, but only if the prefix is byte-identical between requests. The request we send is laid out so that the expensive, stable parts come first and the volatile parts come last:

  1. System prompt, identical for every tenant.
  2. Tool definitions, generated from the registry, stable for a given set of enabled modules.
  3. A cache breakpoint with a long time-to-live after the tools.
  4. Tenant-specific context and the conversation so far.
  5. A second cache breakpoint with a short time-to-live on the message tail.

The first breakpoint is the one that pays: every tenant with the same set of enabled modules shares the same cached prefix, because nothing before that breakpoint is tenant-specific. The second breakpoint makes multi-turn conversations cheap, because each turn only adds the newest messages after a prefix that was cached on the previous turn.

The invariant “nothing before the first breakpoint is tenant-specific” is enforced by a test. It builds the prefix for two different tenants and asserts the bytes are identical. It has failed exactly once, when someone put the business name into the system prompt for a “friendlier” tone, and it caught the regression before the cache-hit rate dropped in production.

Streaming and the tool loop in PHP

PHP is not the first language people reach for when streaming an agent loop, and it works fine. The assistant endpoint returns a Laravel StreamedResponse with the text/event-stream content type and writes Server-Sent Events as the SDK’s streaming iterator yields them. Text deltas go straight to the client. When the model emits a tool call, the loop executes the tool, appends the result to the transcript, and issues the next request, all within the same HTTP response.

The loop is bounded. A conversation turn can only go around the tool loop a fixed number of times before the server stops and tells the user what it has done so far. Without that bound, a confused model with a failing tool can loop indefinitely at your expense.

Two things had to be verified rather than assumed. First, the reverse proxy must not buffer: the app sets the header that disables proxy buffering, and we tested end to end that tokens arrived incrementally in the browser, because a proxy that buffers turns streaming into a very long pause followed by everything at once. Second, on the client, the browser’s built-in EventSource cannot send headers or a request body, so the frontend uses fetch with a ReadableStream reader and parses SSE frames itself. That is about forty lines of TypeScript and it has not needed changing since.

Metering per tenant

Every request records input tokens, cached input tokens, output tokens, and the model that served them. Cost is computed from a pricing table and stored in micro-dollars as an integer, because floating-point currency at scale is how you end up with a reconciliation project.

Per-tenant caps are expressed in tokens, not dollars. This was a deliberate choice: if we route a conversation to a cheaper model, the tenant gets more turns out of the same allowance, and the platform’s margin improves at the same time. A dollar cap would make model routing invisible to the customer; a token cap makes it a feature.

The last metering detail is the one that took longest to get right. When a user closes the tab mid-response, the stream aborts. The tokens have already been generated and billed by the provider. So usage is persisted in a finally block that runs whether the stream completed, errored, or the client disconnected, and the persisted record carries a flag noting the disconnect. Before that change, the meter under-counted whenever users were impatient, which is to say often.

Teaching the model what has no tool

The best bug report we received about the assistant was from a customer who typed “book a session” and got, correctly, an appointment booked. Except their business called sessions something else, and what they meant was “add a Book a Session button to my contact page”. The assistant had a tool for booking and no tool for editing that specific page, so it did the thing it could do.

The fix was not another tool. It was a capability reference: a document the model reads describing every tool it has, every task it does not have a tool for, and what to do in each of those cases (explain, link the user to the right settings page, or ask a clarifying question). The assistant now says “I can’t edit that page directly, but here’s the link and here’s what to change” instead of guessing.

The document is kept honest by a test: it enumerates the registry and fails CI if any registered tool is missing from the reference. Documentation that a test enforces is documentation that stays true.

What we’d do differently

Start with the diff field. We added the before/after diff to the tool result after launch, and it is the single feature that most increased trust. It should have been in the contract on day one.

Pin lanes from the beginning. We initially re-classified every message and watched the cache-hit rate stay stubbornly low. Pinning was a one-line change with an outsized effect on cost.

Write the capability reference before the tools. Describing what the assistant cannot do forced clearer thinking about what it should do.

Meter in the finally from day one. Every under-counted token is money you have already spent.

If you are building something similar, the evaluation harness post on this site covers how to score tool selection and safety before deploying, and the comparison of the OpenAI Agents SDK with the Responses API is a useful contrast if you are choosing between provider SDKs. Everything about rate limits and budget enforcement in front of the model lives in the LLM cost control post.

Next in the series: Agent Tool Safety: Prompt Injection, Out-of-Band Confirmation Codes, and Idempotency Keys for Money-Moving Tools

FAQ: agentic assistants in Laravel

Can you build a streaming AI agent loop in PHP?

Yes. Laravel’s StreamedResponse with the text/event-stream content type, fed by the Anthropic PHP SDK’s streaming iterator, delivers token deltas as Server-Sent Events. The tool loop runs inside the same response: execute the tool, append the result, request the next completion, up to a fixed bound. The two things to verify are that your reverse proxy does not buffer the response and that the client uses fetch with a ReadableStream rather than EventSource, which cannot send headers or a body.

How do you keep prompt caching effective across many tenants?

Order the request so that everything stable and tenant-independent comes first: the system prompt and the tool definitions, followed by a long-lived cache breakpoint. Put tenant context and conversation history after it with a short-lived breakpoint on the tail. Pin the model for the whole conversation, because cached prefixes are model-scoped. Enforce the “tenant-free prefix” invariant with a test that builds the prefix for two tenants and asserts byte equality.

Why cap AI usage in tokens instead of dollars?

Because it rewards routing. If a cheaper model handles a conversation, a token cap gives the tenant more turns from the same allowance while the platform’s cost drops. A dollar cap hides that benefit from the customer. Internally, cost is still tracked in integer micro-dollars for reconciliation.

How do you stop the model calling tools the user is not allowed to use?

Twice. The tool registry filters what the model can see by platform availability, the tenant’s enabled modules, and the user’s permissions. Then every tool execution re-runs authorization and applies the same validation rules as the equivalent HTTP endpoint. The model’s transcript is never treated as evidence of permission.

What does the model do for tasks that have no tool?

It reads a capability reference describing every tool, every task without a tool, and the right response for each: explain, link to the relevant settings page, or ask. A CI test enumerates the tool registry and fails if any registered tool is missing from the reference, so the document cannot drift from the code.