CakePHP AI is an ecosystem for building AI features in CakePHP applications. The center of the ecosystem is the crustum/cakephp-ai package, which provides agents, tools, structured output, conversation memory, streaming, queueing, and broadcasting through one provider independent API. The surrounding packages cover adjacent concerns and share the same conventions: an MCP plugin that exposes application behavior to external agents, Ignis that supplies project guidelines and skills to coding agents, Panifex that runs autonomous coding sessions, an Interaction plugin for chat turns and slash commands, a RAG pipeline for grounding answers in application data, a Nexus debugger for trying agents in the browser, and an Observability dashboard for traces and cost that should be published soon. Day to day monitoring of agent runs is additionally available through Speculum, whose AI watcher records agent, tool, generation, store, file, and failover events. The present article covers the center only, using a single running example from first prompt to production habits.
Agents: One Class per Job
An agent is a dedicated PHP class that encapsulates everything one job needs: the instructions, the conversation context, the available tools, and optionally the output schema. The class is configured once and prompted from any layer of the application, whether that is a controller, a queued job, or a console command. The class is scaffolded with Bake, and the instructions are written as a normal method that returns the system prompt.
// bin/cake bake agent TripHelper
namespace App\Ai\Agents;
use Crustum\Ai\Contracts\Agent;
use Crustum\Ai\Trait\PromptableTrait;
class TripHelper implements Agent
{
use PromptableTrait;
public function instructions(): string
{
return 'You help plan short trips. Use the booking and weather tools. '
. 'Keep replies short.';
}
}
The prompting operation for the agent runs the full loop and returns a response object that carries the final text together with the steps, tool calls, and usage behind it.
$response = (new TripHelper)->prompt('What should I pack for Berlin tomorrow?');
return $response->text;
Provider and model selection works on two levels. A single call accepts overrides for provider, model, and timeout, which is convenient for experiments and per tenant routing. When the choice is fixed for the agent, it is pinned on the class with attributes instead, so call sites remain unchanged.
use Crustum\Ai\Attributes\Model;
use Crustum\Ai\Attributes\Provider;
use Crustum\Ai\Contracts\Agent;
use Crustum\Ai\Enums\Lab;
use Crustum\Ai\Trait\PromptableTrait;
#[Provider(Lab::Anthropic)]
#[Model('claude-sonnet-5')]
class TripHelper implements Agent
{
use PromptableTrait;
}
Anonymous agents cover the ad hoc case. They accept inline instructions, messages, and tools through the constructor and run the same loop without requiring a new file, which suits probes, evaluations, and one off scripts.
Tools: Giving Agents Real Capabilities
Tools extend what an agent can do beyond generating text. Each tool is a class with a description, a JSON schema for its arguments, and a handle method that performs the work. The model decides during the run whether a tool fits the prompt, the plugin executes it, and the result flows back into the conversation so the model can continue with fresh facts.
The weather tool below illustrates the complete contract against a real use case. It resolves the city through a geocoding API, fetches tomorrow's forecast over HTTP, and returns one line the model can quote or reason over. No API key is required, and failures degrade to readable text instead of exceptions, so the run continues.
// bin/cake bake tool Weather
namespace App\Ai\Tools;
use Cake\Http\Client;
use Crustum\Ai\Contracts\Tool;
use Crustum\Ai\Tools\Request;
use Crustum\JsonSchema\Contracts\JsonSchema;
class Weather implements Tool
{
public function description(): string
{
return 'Get tomorrow forecast for a city. Reports max temperature and rain probability.';
}
public function handle(Request $request): string
{
$city = $request->string('city');
$http = new Client();
$geo = $http->get('https://geocoding-api.open-meteo.com/v1/search', [
'name' => $city,
'count' => 1,
])->getJson();
if (empty($geo['results'][0])) {
return "Unknown city [{$city}].";
}
$place = $geo['results'][0];
$forecast = $http->get('https://api.open-meteo.com/v1/forecast', [
'latitude' => $place['latitude'],
'longitude' => $place['longitude'],
'daily' => 'temperature_2m_max,precipitation_probability_max',
'forecast_days' => 2,
])->getJson();
$max = $forecast['daily']['temperature_2m_max'][1];
$rain = $forecast['daily']['precipitation_probability_max'][1];
return "Tomorrow in {$city}: max {$max}C, rain probability {$rain}%.";
}
public function schema(JsonSchema $schema): array
{
return [
'city' => $schema->string()
->description('The city to get the forecast for')
->required(),
];
}
}
The agent exposes its tools through a single method. The example agent offers a booking lookup against the reservations table and a live weather forecast for autonomous use, plus an email tool that requires human approval before it runs. Because tools are declared per agent, each agent carries a narrow capability set, which keeps token usage predictable and the model selection accurate.
use Crustum\Ai\Contracts\Agent;
use Crustum\Ai\Contracts\Conversational;
use Crustum\Ai\Contracts\HasTools;
use Crustum\Ai\Trait\PromptableTrait;
use Crustum\Ai\Trait\RemembersConversationsTrait;
class TripHelper implements Agent, Conversational, HasTools
{
use PromptableTrait;
use RemembersConversationsTrait;
public function instructions(): string
{
return 'You help plan short trips. Use the booking and weather tools '
. 'instead of answering from memory. Keep replies short.';
}
public function tools(): iterable
{
return [
new LookupBooking(),
new Weather(),
(new SendEmail())->requireApproval('Sending email on your behalf.'),
];
}
}
The booking tool follows the same contract against the application database. It loads the reservation through the ORM and renders one factual line, so the model answers from stored data rather than inventing details.
namespace App\Ai\Tools;
use Cake\ORM\TableRegistry;
use Crustum\Ai\Contracts\Tool;
use Crustum\Ai\Tools\Request;
use Crustum\JsonSchema\Contracts\JsonSchema;
class LookupBooking implements Tool
{
public function description(): string
{
return 'Find a trip booking by reference and report dates, hotel, and status.';
}
public function handle(Request $request): string
{
$reference = $request->string('reference');
$booking = TableRegistry::getTableLocator()
->get('Bookings')
->find()
->where(['reference' => $reference])
->first();
if (!$booking) {
return "No booking found for reference [{$reference}].";
}
return "Booking {$booking->reference}: {$booking->hotel}, "
. "from {$booking->check_in->format('Y-m-d')} "
. "to {$booking->check_out->format('Y-m-d')}, status {$booking->status}.";
}
public function schema(JsonSchema $schema): array
{
return [
'reference' => $schema->string()
->description('The booking reference from the confirmation email')
->required(),
];
}
}
Tool arguments can additionally be validated inside the handle with the request validate method, which accepts CakePHP validation rules. When validation fails, the messages return to the model as the tool result, so the model can correct the arguments and retry without application code intervening.
Tool sets also adapt per call without touching the agent class. The withTools method replaces the declared tools for one run, accepting either a fixed list or a closure over the declared set, which covers per tenant toolboxes and feature flagged capabilities.
$response = (new TripHelper)
->withTools(fn (array $tools) => [...$tools, new LookupBooking()])
->prompt('Which hotel did I book?');
When the model invents a tool name that does not exist, the RepairToolCalls attribute returns the failure to the model together with the available tool names instead of failing the run, so the model can correct the call within an added step. For filesystem work the FileStorage factory provides scoped read only or full read write tool sets for a configured disk, which keeps file agents declarative rather than hand wired.
Tools are not limited to agent code. Tools served over MCP join the same list through the McpServerTool wrapper from the Crustum\Mcp package, so local or external server behavior reaches the model like any other tool, approvals included.
Structured Output: Typed Answers Instead of Prose
Chat answers suit screens, whereas application logic usually needs predictable data. Agents that implement HasStructuredOutput declare a schema alongside their instructions, and the plugin constrains generation to JSON that matches it, so scores, labels, and extracted records arrive typed and ready to persist. The trip agent below returns a packing score with a short justification, and the caller reads both values as array entries with the declared types.
use Crustum\Ai\Contracts\Agent;
use Crustum\Ai\Contracts\HasStructuredOutput;
use Crustum\Ai\Trait\PromptableTrait;
use Crustum\JsonSchema\Contracts\JsonSchema;
class TripScore implements Agent, HasStructuredOutput
{
use PromptableTrait;
public function instructions(): string
{
return 'You rate packing lists for short trips. Be strict about rain gear.';
}
public function schema(JsonSchema $schema): array
{
return [
'score' => $schema->integer()->min(1)->max(10)->required(),
'feedback' => $schema->string()->required(),
];
}
}
$response = (new TripScore)->prompt('Rate this list: jacket, boots, umbrella.');
$score = $response['score'];
$feedback = $response['feedback'];
Schemas compose further without changing the calling pattern. Nested objects describe grouped values such as per day forecasts, arrays of objects describe repeating items such as packing entries, and the response keeps array access at every level. Because the contract lives on the agent class, the same agent serves both free text experiments and strict production consumers depending on how the caller reads the response.
Conversation Memory: Chats That Continue
Trip planning spans multiple turns, and users expect the agent to retain context between them. The RemembersConversationsTrait provides database backed conversation storage: it persists each turn and reloads the history automatically on the next run, so follow up questions resolve against the earlier exchange.
A new conversation starts with forUser, which binds the turn to the given participant, while continue resumes a stored conversation by id for the same participant. The controller endpoint below follows the working demo exactly. It reads the message with an optional conversation id, selects the matching mode, prompts, and returns the answer text with the id that the client must send back on the next request.
// In src/Controller/ChatController.php
public function message()
{
$message = (string)$this->request->getData('message');
$conversationId = $this->request->getData('conversation_id');
$identity = $this->request->getAttribute('identity');
$agent = new TripHelper();
$agent = $conversationId
? $agent->continue($conversationId, as: $identity)
: $agent->forUser($identity);
$response = $agent->prompt(trim($message));
return $this->response
->withType('application/json')
->withStringBody(json_encode([
'text' => $response->text,
'conversation_id' => $response->conversationId,
]));
}
Participants are not limited to users. Conversations can belong to any ORM entity through forParticipant, which allows team scoped or project scoped histories with identical semantics. A continueOrStart helper covers screens that prefer a single path for both fresh and resumed chats. Ownership checks remain application responsibility, and the conversation store exposes helpers to verify that a conversation belongs to a participant before continuing it from a client supplied id.
Streaming: Answers as They Arrive
Model runs take seconds, and streaming determines how the application feels under that latency. The plugin delivers tokens to the browser as server sent events without changing the agent definition. In CakePHP the streamable response converts directly into a controller response, which keeps the action compact and the transport standard.
// In src/Controller/ChatController.php
public function chat(): \Cake\Http\Response
{
return (new TripHelper)
->continue(
(string)$this->request->getData('conversation_id'),
as: $this->request->getAttribute('identity')
)
->stream((string)$this->request->getData('message'))
->toResponse();
}
A then callback on the stream runs when delivery completes and receives the full streamed response, including text, events, and usage, which is the natural place for logging or post processing. Streams can alternatively be iterated manually event by event when custom handling is required.
Beyond request scoped streams, agents support background and realtime delivery through the same run model. Queued runs process in a worker while streamed events can additionally be broadcast to frontend channels, so slow generations never block a request and live screens stay current. Both paths reuse the same memory, tools, and approvals described above, and the documentation carries the worker and channel setup.
Clients that already implement a chat protocol need no separate agent implementation. The same agent serves AG-UI clients by converting the incoming request into a chat object, continuing the thread it names, and streaming back through the client protocol, which preserves memory, tools, and approvals across transports. Frontends built on the Vercel AI SDK are covered the same way: the stream switches to the Vercel data protocol, the chat helper translates the incoming request with its history and newest message, and stored turns convert back to UI messages for screens that reload, so existing chat components work without custom event handling.
Human Tool Approval: Review Before Side Effects
Read only tools run freely, whereas tools with side effects require a human decision first. The email tool in the trip agent demonstrates the boundary: checking weather never pauses, while sending email always pauses for review.
Approvability is declared on the tool itself through the Approvable contract with its trait, optionally refined per call by a needsApproval method that inspects the arguments. The agent adjusts the requirement at registration time with requireApproval or withoutApproval, including a reason that the reviewer will see.
namespace App\Ai\Tools;
use Crustum\Ai\Contracts\Approvable;
use Crustum\Ai\Contracts\Tool;
use Crustum\Ai\Tools\Request;
use Crustum\Ai\Trait\InteractsWithApprovalsTrait;
use Crustum\JsonSchema\Contracts\JsonSchema;
class SendEmail implements Approvable, Tool
{
use InteractsWithApprovalsTrait;
public function description(): string
{
return 'Send an email on behalf of the user. Requires the user to approve the send.';
}
public function handle(Request $request): string
{
return 'Email sent to ' . $request->string('to');
}
public function schema(JsonSchema $schema): array
{
return [
'to' => $schema->string()->required(),
'subject' => $schema->string()->required(),
'body' => $schema->string()->required(),
];
}
}
When the model selects an approvable tool, the run pauses before execution and reports each pending call with its id, tool name, chosen arguments, and approval reason. The application renders this list for review rather than executing anything.
$response = (new TripHelper)
->forUser($user)
->prompt('Email me the Berlin packing list.');
if ($response->hasPendingApprovals()) {
foreach ($response->pendingApprovals as $approval) {
// $approval->id, $approval->tool, $approval->arguments, $approval->reason
}
}
Resumption continues the same conversation with a decisions object that maps every pending call id to an outcome. Approvals execute the tool, rejections with a message return that message to the model so it can respond around the refusal, and rejections without a result stop the loop after recording the refusal. The matching is strict by design: unknown, missing, or already resolved ids raise an exception instead of guessing, and the conversation should be authorized before resuming since paused turns match by conversation rather than by participant. Approval pauses behave identically across prompt, stream, and queue runs, so all delivery paths share one review flow.
use Crustum\Ai\Approvals\Decision;
use Crustum\Ai\Approvals\Decisions;
$response = (new TripHelper)
->continue($conversationId, as: $user)
->prompt(Decisions::from([
'call_abc' => Decision::approve(),
'call_def' => Decision::reject('Send it tomorrow morning instead.'),
]));
Middleware: One Job per Step
Cross cutting behavior belongs in middleware rather than duplicated across agents. Agents that implement HasMiddleware expose a middleware list, and each entry observes every generation step of the run: a turn that calls three tools invokes the chain three times, with each step delivered as a PendingStep that the middleware may inspect and replace. Logging, redaction of sensitive fields, cost guards, and summarization of long histories all fit naturally at this point, because the step carries everything about to be sent together with the completed steps and their usage.
// bin/cake bake agent_middleware LogPrompts
namespace App\Ai\Middleware;
use Cake\Log\Log;
use Closure;
use Crustum\Ai\PendingStep;
class LogPrompts
{
public function handle(PendingStep $step, Closure $next)
{
Log::info('Prompting agent', ['model' => $step->model]);
return $next($step);
}
}
Modification returns a copy of the step, and the copies chain fluently, which keeps each adjustment explicit and scoped to the current generation. Strong middleware gives every step exactly one job. The example below forces the booking lookup on the first step with narrowed tools and focused instructions, withdraws the lookup once it has served, and strips all tools on the final step so the model answers instead of calling again.
use Closure;
use Crustum\Ai\PendingStep;
use Crustum\Ai\Support\ToolChoice;
class GuideTripLookup
{
public function handle(PendingStep $step, Closure $next)
{
if ($step->isFinalStep) {
return $next($step->withTools([]));
}
if ($step->isFirstStep()) {
$step = $step
->onlyTools('LookupBooking')
->withToolChoice(ToolChoice::tool('LookupBooking'))
->withInstructions(
'Resolve the booking reference with LookupBooking before anything else. '
. 'Return exactly one tool call, not prose.'
);
} else {
$step = $step->withoutTools('LookupBooking');
}
return $next($step);
}
}
The agent enables its middleware through one method.
public function middleware(): array
{
return [
new LogPrompts(),
new GuideTripLookup(),
];
}
Middleware can also answer a step without contacting the provider at all by returning its own step response, which is the mechanism behind cached answers, policy refusals, and test doubles. A then hook observes the model answer of the step before tool execution, so timing, token counts, and content checks apply equally to synchronous and streamed runs.
Provider Tools: Capabilities Inside the Provider
Certain capabilities execute inside the provider infrastructure rather than in application code. Web search grounds answers in current events, web fetch reads specific pages, file search queries provider hosted document stores, and code execution runs calculations in the provider sandbox. These provider tools are returned from the same tools method as local tools, and the agent loop treats them uniformly, so the trip agent can combine a local weather lookup with a provider web search for travel advisories in one run.
use Crustum\Ai\Providers\Tools\CodeExecution;
use Crustum\Ai\Providers\Tools\WebSearch;
public function tools(): iterable
{
return [
new Weather(),
(new WebSearch)->max(5)->allow(['visitberlin.de']),
new CodeExecution(),
];
}
Provider support varies by capability, and the documentation carries the exact matrix, since each vendor exposes a different subset. When an agent accumulates many local tools, sending every definition on each request wastes tokens and weakens the model tool selection. The ToolSearch wrapper defers rarely used tools so the provider loads their definitions only when the prompt calls for them, while frequently used tools stay inline. The wrapped tools require no modification and are invoked like any other tool once loaded.
use Crustum\Ai\Providers\Tools\ToolSearch;
public function tools(): iterable
{
return [
new Weather(),
new ToolSearch(tools: [
new SearchInvoices(),
new RefundOrder(),
]),
];
}
Classification: Decisions With Probabilities
Some decisions are not writing tasks. Routing a ticket to the correct team, flagging a message, or scoring urgency must return a comparable value rather than prose, and such answers should carry a probability so the application can threshold them. Classification addresses exactly this class of problem by asking a fixed set of questions and returning a typed answer per question. The boolean answer reports the probability of yes, the choice answer reports the selected option with every option scored, and the graded answer reports a position across ordered levels. The capability is marked experimental, and its API may still evolve between minor releases.
use Crustum\Ai\Classification;
use Crustum\Ai\Classification\Boolean;
use Crustum\Ai\Classification\Choice;
use Crustum\Ai\Classification\Score;
$result = Classification::of($ticket->body)->questions([
'is_urgent' => new Boolean('Does this message convey urgency?'),
'department' => new Choice('Which team should handle this?', [
'billing' => 'Payments, invoicing, refunds',
'technical' => 'Bugs, outages, integrations',
'sales' => 'Pricing, plans, upgrades',
]),
'frustration' => new Score('How frustrated is the customer?', [
'Calm, stating facts',
'Frustrated but civil',
'Very angry',
]),
])->classify();
$isUrgent = $result['is_urgent']->isTrue(threshold: 0.8);
$team = $result['department']->choice;
$probabilities = $result['department']->probabilities;
$level = $result['frustration']->score;
Single yes or no questions have a shorter path through the text helper, which returns a plain boolean with an optional certainty threshold, while choosing one item from a collection uses the collection helper with the same semantics. Classification runs on its own provider defaults, currently centered on TypeSafe with OpenAI and OpenRouter alternatives, and the provider plus model can be pinned per call like everywhere else.
Media: Images, Audio, and Transcription
Beyond text, the package covers the standard media capabilities through dedicated builders that mirror the agent options for providers, timeouts, queueing, and storage. Image generation supports aspect and quality controls with reference attachments, audio synthesis supports voice selection with delivery instructions, and transcription supports speaker diarization for multi voice recordings. Generated artifacts stored through the configured filesystem with the same calls used elsewhere in CakePHP applications.
use Crustum\Ai\Audio;
use Crustum\Ai\Image;
use Crustum\Ai\Transcription;
$image = Image::of('A small sketch of Berlin rooftops at dawn')
->landscape()
->generate();
$path = $image->store();
$audio = Audio::of('Pack light. Berlin looks rainy.')
->female()
->generate();
$audioPath = $audio->store();
$transcript = Transcription::fromStorage('memo.mp3')
->diarize()
->generate();
Media generation queues with then callbacks following the same pattern as agents, which keeps slow renders out of the request cycle. The full option matrix lives in the documentation, since provider support varies by capability.
Usage and Failover: Cost Control and Resilience
Every response carries a usage object with the token counts reported by the provider. Input and output totals aggregate their subcategories, so cached and reasoning tokens are included in the totals they belong to, and the detailed object breaks them out for pricing: cache reads, cache writes, and reasoning can be billed at different rates, and missing measurements stay null rather than zero so absent data is never mistaken for free data.
$response = (new TripHelper)->prompt('What should I pack for Berlin tomorrow?');
$input = $response->usage->inputTokens;
$output = $response->usage->outputTokens;
$total = $response->usage->totalTokens();
$cached = $response->usage->cacheReadInputTokens;
$reasoning = $response->usage->reasoningTokens;
Providers occasionally fail with rate limits, overload, or exhausted credit, and such transient failures should not surface to users when a backup is available. Passing a provider list enables automatic failover: the plugin tries each entry in order and continues with the first healthy response. Particular models per provider use the associative form keyed by provider value, and failover triggers only on retryable provider errors, never on validation or request errors that a retry could not fix.
use Crustum\Ai\Enums\Lab;
$response = (new TripHelper)->prompt(
'What should I pack for Berlin tomorrow?',
provider: [Lab::OpenAI, Lab::Anthropic],
);
Events: Observing Every Run
Every significant moment of a run is observable through the framework event system. The plugin dispatches CakePHP events across the whole lifecycle: prompting and completion, each model step, tool invocation and failure, approval requests and resolutions, failover between providers, streaming milestones, and media generation. Listeners attach through the standard event manager, so logging, metrics, and audit rows reuse familiar application code instead of a separate instrumentation API.
use Cake\Event\EventInterface;
use Cake\Event\EventManager;
use Crustum\Ai\Event\AgentPrompted;
EventManager::instance()->on(AgentPrompted::eventName(), function (EventInterface $event) {
$response = $event->getData('response');
// Record usage, duration, or outcome.
});
Event names follow the Ai.<name> convention, and each class documents the payload keys it carries, from invocation ids and prompts to failing tools and failover pairs. This is the same event stream that Speculum consumes for its AI monitoring, which means custom listeners and the monitoring dashboard observe identical data.
Testing: Offline Suites Without Provider Keys
The testing layer favors offline suites. Fakes exist for agents, images, audio, transcriptions, embeddings, reranking, classification, files, and vector stores, each paired with assertions over what was prompted, generated, queued, stored, or classified. Structured output fakes can additionally derive sample payloads from the agent schema, and approval continuations can be asserted through the recorded decisions, so the trip flow including its email gate is fully testable without provider credentials.
Outlook: Where the Ecosystem Goes Next
The agent loop described here carries the majority of application AI work: a focused class, a narrow tool set, typed output, persistent memory, responsive delivery, gated side effects, adjustable steps, deferred loading, grounded classification, light media support, measured usage with failover, observable events, and offline tests. The remaining capabilities extend this foundation without changing it. Sub agents delegate specialized tasks to dedicated agents, similarity search grounds answers in application tables, provider file stores enable document scoped retrieval, skills bundle task instructions with reference files, and prompt caching trims repeated prefix cost. The outer ecosystem builds on the same core from different directions: the MCP plugin, Ignis, Panifex, the Interaction plugin, and the Nexus debugger, and each of these subjects warrants a dedicated article.
Need help with this in your own CakePHP app?
Talk to a CakePHP expertGet the CakeDC newsletter
One email a month: releases, security advisories and one article worth reading.