Building Blocks
Seven opt-in primitives, all off by default. Leave them out and the agent behaves exactly as before.
| Primitive | Entry point | Throws |
|---|---|---|
| Prompt templates | PromptTemplate::from() | PromptTemplateException |
| Structured output | $claw->sendStructured() | StructuredOutputException, UnsupportedSchemaException |
| Provider fallback | withFallback() | AdapterException at build() for mixed tool formats |
| Outbound rate limit | rateLimit() | ProviderException with status 429 |
| Response cache | responseCache() | nothing |
| Token budget | maxTokenBudget() | TokenBudgetExceededException |
| Durable runs | durableRuns(), 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();Prompt templates
Section titled “Prompt templates”{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.
Structured output
Section titled “Structured output”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.
Provider fallback
Section titled “Provider fallback”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 call | Fails over? |
|---|---|
| timeout or connection error (status 0) | yes |
| 429 | yes |
| any 5xx | yes |
| 401, 403 or any other 4xx | no, 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.
Outbound rate limit
Section titled “Outbound rate limit”$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.
Response cache
Section titled “Response cache”$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.
Token budget
Section titled “Token budget”$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.
Durable runs
Section titled “Durable runs”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.
The stream fast path
Section titled “The stream fast path”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.
Events
Section titled “Events”| Event | Fires when |
|---|---|
provider.fallback | a provider failed over to the next |
provider.response_cached | a response was served from the cache |
budget.exceeded | the budget stopped the run |
structured.repair | a structured reply was sent back for repair |
See Hooks.
See also
Section titled “See also”- 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