Skip to content

Building Blocks

Seven opt-in primitives, all off by default. Leave them out and the agent behaves exactly as before.

PrimitiveEntry pointThrows
Prompt templatesPromptTemplate::from()PromptTemplateException
Structured output$claw->sendStructured()StructuredOutputException, UnsupportedSchemaException
Provider fallbackwithFallback()AdapterException at build() for mixed tool formats
Outbound rate limitrateLimit()ProviderException with status 429
Response cacheresponseCache()nothing
Token budgetmaxTokenBudget()TokenBudgetExceededException
Durable runsdurableRuns(), withSuspendableApproval()RunSuspendedException, RunConflictException, RunStateException; AdapterException at build() without a memory driver or with storeMessages off
use PhpClaw\Claw;
$claw = Claw::builder()
->provider('openai')->model('gpt-4o-mini')
->withFallback('groq', 'llama-3.1-8b-instant')
->rateLimit(60)
->responseCache($psr16)
->maxTokenBudget(50_000)
->build();

{name} placeholders, with {{ and }} for literal braces:

use PhpClaw\Prompt\PromptTemplate;
$prompt = PromptTemplate::from('Extract the order details from: {body}')->format(['body' => $email]);

A missing value, or one that is not a scalar or Stringable, throws PromptTemplateException. variables() lists the placeholder names and partial() returns a new template with some values filled in.

No escaping is done. The rendered text still passes the guard stack when you send it.


sendStructured() returns data checked against your JSON Schema:

$order = $claw->sendStructured($prompt, [
'type' => 'object',
'properties' => ['order_id' => ['type' => 'string'], 'total' => ['type' => 'number']],
'required' => ['order_id', 'total'],
]);
$order->data['order_id'];

$order->data holds the validated data and $order->raw the AgentResponse of the run.

It uses the vendor’s native mode where phpClaw supports it: the openai preset, and the Anthropic models on AnthropicProvider’s supported list. Every other provider is asked to call one tool shaped like the schema. A reply that does not match is sent back for repair up to maxParseRetries() times (default 2), each repair firing structured.repair, then StructuredOutputException is thrown.

Supported keywords: type, properties, required, items, enum, additionalProperties (only false is enforced), minimum, maximum, minLength, maxLength, description, title. Any other keyword throws UnsupportedSchemaException before a call is made.

sendStructured() does not run your other tools; it is one request for one shaped answer.


withFallback() adds a provider tried when the one before it fails:

$claw = Claw::builder()
->provider('custom')->model('my-model')
->withFallback('ollama', 'qwen2.5:7b')
->build();

custom reads its endpoint and key from OPENAI_BASE_URL and OPENAI_API_KEY; see Providers: Custom.

The failing callFails over?
timeout or connection error (status 0)yes
429yes
any 5xxyes
401, 403 or any other 4xxno, the error is thrown

Pass a slug with a model and an optional API key (an empty key resolves from the provider’s usual environment variable), or a ready ProviderInterface. Add as many as you need; they are tried in order.

Every provider in the chain must use the same tool format, for example openai and groq. Mixing formats, such as anthropic with ollama, throws AdapterException at build(). See Providers for which providers share a format.

Each fallback fires provider.fallback, and $response->provider names the provider that answered.


$claw = Claw::builder()->rateLimit(60)->build();

rateLimit($requestsPerMinute, $maxWaitMs = 30_000, $store = null) spaces out calls to the provider with a token bucket. A call waits for a token; when the wait would pass $maxWaitMs, the call throws ProviderException with status 429 instead of sleeping.

Without $store, the bucket lives in the built agent only. Pass a PSR-16 store to share one bucket across every agent and process that uses it.

This limits calls going out to the provider. To limit incoming messages per user, use the RateLimitGuard; see Guards.


$claw = Claw::builder()->responseCache($psr16, ttl: 3600)->build();

responseCache($cache, $ttl = 3600) serves an identical request from any PSR-16 cache. Install psr/simple-cache and a store for it. A hit makes no provider call, costs no tokens and fires provider.response_cached.

The key covers the providers and their models, the system prompt, maxTokens, the thinking budget, provider-side tools such as web search, the messages and the tools, so a changed history misses.


$claw = Claw::builder()->maxTokenBudget(50_000)->build();

Before each provider call, the input and output tokens already spent in the run, plus an estimate of the messages about to be sent, are compared with the budget. When they would pass it, budget.exceeded fires and TokenBudgetExceededException is thrown. The estimate is the messages’ characters divided by four; the system prompt and the tool schemas are not part of it. So a budget below that estimate stops the run before any call, and a very short message can still make one call.

The budget is checked between calls only, so one reply can overshoot it. Compaction summaries are not counted. 0 (the default) means no budget.


use PhpClaw\Claw;
use PhpClaw\Exceptions\RunSuspendedException;
use PhpClaw\Tools\FileWriteTool;
$claw = Claw::builder()
->memory($memory)
->addTool(new FileWriteTool(workspaceRoot: '/var/www/app/storage/phpclaw'))
->withSuspendableApproval()
->build();
try {
$claw->send('Write the refund note for order 1042 to refunds/1042.txt');
} catch (RunSuspendedException $e) {
// later, in another request or worker, with the same configured Claw:
foreach ($claw->pendingApprovals() as $run) {
if ($run->runId() === $e->runId) {
$claw->approve($e->runId, $run->paused->callId());
$claw->resume($e->runId);
}
}
}

file_write changes data, so the run pauses before it writes. pendingApprovals() returns the saved runs still waiting for a decision; the one with the caught run’s id carries the paused call in paused.

durableRuns($stepBudget = 0, $deadlineSeconds = null) saves a send(), stream() or conversation run through the memory driver after every step, under the runs namespace, kept for 7 days. Once this process has run $stepBudget steps or spent $deadlineSeconds seconds, the run stops with RunSuspendedException so another process can continue it. 0 means no limit; a null deadline is half of max_execution_time when that is set.

withSuspendableApproval() turns durable runs on and pauses the run at a tool that changes data instead of running it. pendingApprovals() lists the paused runs, approve() or deny() records the decision, and resume($runId) continues the run in any process: an approved call runs, a denied one is reported to the model. resume() saves the run as running before it does anything, so a second process resuming the same run is refused rather than repeating its work. cancel() stops a run. A conversation run adds its turn to the conversation when it finishes.

To finish saved runs without tracking their ids, call resumeDue() from a cron job or a worker. It resumes up to 5 due runs: suspended on a budget, paused with a decision recorded, or left running for 15 minutes by a process that stopped. It starts no further run once 20 seconds have passed; the second argument changes that, and 0 means no limit. A third argument wraps each resume, for example to act as the run’s owner. From cron, vendor/bin/phpclaw-resume --bootstrap=claw.php does the same, where claw.php returns your configured Claw; --limit and --seconds set the run count and the time budget.

Durable runs need a memory driver with storeMessages on; build() throws AdapterException otherwise. Each pause, resume, decision and cancel fires a run.* event.


With durable runs off, a stream() call with no tools sends the message straight to the provider’s stream. That path is not budgeted, not cached and not retried. A streamed run with tools, or any streamed run with durable runs on, goes through the full loop, and all three apply.

A registered skill counts as a tool, because the agent adds load_skill whenever a skill is registered.


EventFires when
provider.fallbacka provider failed over to the next
provider.response_cacheda response was served from the cache
budget.exceededthe budget stopped the run
structured.repaira structured reply was sent back for repair

See Hooks.


  • Pipelines and Graphs: chain steps, or run nodes that loop, pause for a human and fan out
  • Providers: provider names and tool formats
  • Memory: how many memory entries are recalled per message
  • Each adapter page: the settings that switch these on without code