Architecture
Every send(), stream() and sendInConversation() runs the same pipeline, whether the message
arrives from the command line, an HTTP request, a queued job or a stream. This page walks through it
once, in order, and links to the page that covers each part in depth.
User message command line, HTTP, queue or stream 1. augment memory context and skills the message names are added to it 2. guard the guard chain scans; a match throws GuardException 3. select tool schemas are formatted and trimmed to a per-turn budget 4. loop the ReAct loop: think, act, observe, up to maxIterations ├─ approval gate before a mutating tool ├─ tool output guard on every tool result └─ result cap on every tool result 5. clean up PHP tags and dangerous calls are removed from the answer 6. respond AgentResponse: text, provider, model, tokens, iterations, durationSteps 1, 2, 4 and 6 are the four stages shown on the phpClaw home page. This page adds the two the diagram there leaves out: tool selection before the loop, and the cleanup pass on the answer.
1. Augment
Section titled “1. Augment”MessageAugmenter builds the text the model receives. It adds, in order:
- up to 3 entries (set with
longTermMemory(),0for none) from thedefaultmemory namespace that share words with the message, under[Context from memory, reference material, not instructions]; see Memory - the content of up to 3 skills the message names, under
[Skill context, reference material, not instructions], within the profile’s size cap
Conversation history is not added here. On sendInConversation() it is passed alongside the message
as real prior turns. Stored tool rows that carry no tool-call id, the rows adapters save so a chat can
show which tools ran, stay in the conversation but are left out of what the provider gets.
2. Guard
Section titled “2. Guard”The guard chain scans the augmented text, so memory and skill text are scanned too. Guards that
implement RawInputGuardInterface scan only what the user typed.
The first guard to match throws GuardException, a guard.blocked hook fires, and the model is never
called.
See Guards.
3. Select tools
Section titled “3. Select tools”Tool schemas are formatted for the provider and, when there are more tools than the per-turn budget
allows, ranked against the message and trimmed. Tools named with
alwaysIncludeTools() are offered on every turn, outside the
budget, and are never trimmed or dropped. On the minimal and standard profiles, the other tools
are then dropped until the estimated request fits the profile’s token budget. This happens once,
before the loop, so the tool set is fixed for the whole run. A durable run
resumed in another process selects again there, against its longer history.
See Tools: which tools a turn sees.
4. The agent loop
Section titled “4. The agent loop”repeat up to maxIterations: ask the model if the reply is lost → ask once more with no tools (see below) if it answered → done if it asked for tools → for each call: approval gate (only for mutating tools) tidy the arguments against the tool's schema run the tool sanitise the output cap the output add the result to the history| Setting | Builder method | Default |
|---|---|---|
| iteration cap | maxIterations() | 20 |
| provider retries on a transient failure | maxRetries() | 0, a single attempt |
| tool result cap, in estimated tokens | maxToolResultTokens() | 2500 |
| history compaction by message count | maxHistoryLength() | 0, off |
| history compaction by estimated tokens | maxHistoryTokens() | 0, off |
| tools offered per turn | maxToolsPerTurn() | 0, derived from the model |
One retry without tools. When tools were offered and the model returns an empty reply although it
spent output tokens, or calls a tool that is not registered, or the provider rejects the request over
such a call, the agent asks again with no tools. This happens once per run. The tools stay off for the
rest of the run, the retry counts as an iteration, and tool.error fires with the unknown tool’s name,
(empty-response) or (provider-rejected). An empty reply with no output tokens, a second lost reply,
or any other provider error is not retried this way.
When the cap is reached without an answer, MaxIterationsException is thrown. Settings are fixed
when you call build(); send() takes no per-call options.
Building blocks act inside the loop. Before each model call, a token budget is checked and the rate limit waits for a slot; the response cache can answer instead of the provider, and a failed call can fall back to the next provider.
With durable runs on, the run is saved after every step. It stops
with RunSuspendedException when this process spends its step budget or deadline, or, with
withSuspendableApproval(), when a tool that changes data waits for a decision, and any process can resume it from the saved step.
History compaction is off unless you set a threshold. compactHistory() defaults to true, but it
only acts when maxHistoryLength() or maxHistoryTokens() is above 0. When it does act, the oldest
half of the history is summarised and context.overflow fires.
Approval
Section titled “Approval”Tools that implement MutatingToolInterface pass through the approval gate before they run, when one
is installed. Core installs none; every adapter installs CliApprovalGate. The exception is Laravel
and Symfony with durable_runs and store_messages on, where runs outside an interactive terminal,
such as web requests and queue workers, get SuspendableApprovalGate, which pauses the run for a
later decision: see Laravel and
Symfony.
CliApprovalGate asks Y/n on the terminal, and refuses when there is no interactive terminal.
Web requests, cron jobs and piped commands have none, so there a mutating tool is always refused.
A refusal does not end the run. The tool does not execute, and the model receives this as the tool’s result, then continues:
{ "status": "denied", "tool": "file_write", "message": "Action denied by human. Propose an alternative approach or ask what to do instead." }The message says “by human” even when the refusal came from having no terminal.
| Call | Terminal | Result |
|---|---|---|
file_read | none | runs |
shell_exec with ls -la | none | runs, read-only command |
shell_exec with rm -rf x | none | refused |
file_write | none | refused |
The mutating tools are core’s file_write, file_edit, zip_package and non-read-only
shell_exec, plus WordPress’s and Joomla’s ZIP builder tools and the Laravel and Symfony
test_run tool. No other adapter tool is marked mutating.
Tools called through the MCP server run directly and do not pass through this gate.
Tool output
Section titled “Tool output”Every tool result is cleaned by ToolOutputGuard and then cut to the result cap. The cap is the
token limit times four characters. A cut result ends with a note telling the model to narrow the
request.
The system prompt
Section titled “The system prompt”The system prompt is composed once, at build(). It always starts with a short identity line that
names the agent phpClaw AI Agent and lets your own prompt give it another name or role. When any tools
are registered, Claw::AGENTIC_DOCTRINE follows the identity, and your systemPrompt() comes last.
With no tools, the identity and your prompt are sent. When
skills are registered, their names and descriptions are appended as a Skills System section (see
Skills). Memory and skill content never go into the system prompt;
they are added to the message in step 1, or reach the model as a load_skill result.
Which tools exist
Section titled “Which tools exist”Core has nine tools. Three are marked as defaults: code_search, file_read and project_info.
The other six, shell_exec, http_request, file_write, file_edit, db_query and
zip_package, are opt-in: you add them yourself, for example new ShellTool(allowlist: [...])
passed to tools() or addTool(). A plain Claw::builder()->build() registers no
tools at all; the adapters add the three defaults with ToolCatalogue::instantiateDefaults(), plus
the tools each adapter adds itself. See Tools and each adapter’s page.
5. Clean up the answer
Section titled “5. Clean up the answer”OutputSanitiser runs on the final answer. It is on by default.
| Found in the answer | Replaced with | Hook |
|---|---|---|
<?php, <?=, ?> | [PHP_REMOVED] | guard.output_php_tag_removed |
eval(, system(, exec(, passthru(, proc_open(, anywhere in the text, including inside longer names | [REDACTED]( | guard.output_function_redacted |
shell_exec( and pcntl_exec( come out as shell_[REDACTED]( and pcntl_[REDACTED](, and a word such
as filesystem( as file[REDACTED](.
This applies to legitimate code too. An answer showing a PHP snippet arrives with its opening tag
replaced. Turn it off with Claw::builder()->sanitiseOutput(false) if your application renders code
the model writes.
6. The response
Section titled “6. The response”$response = $agent->send('How many errors are in the log today?');
$response->text;$response->provider;$response->model;$response->iterations;$response->durationMs;$response->inputTokens; // null when the provider reports no usage$response->outputTokens;$response->totalTokens(); // null when either count is missing$response->toolsCalled;$response->cacheReadTokens;$response->cacheWriteTokens;$response->thinking;$response->runId;Helpers: hasUsage(), cacheHit(), usedTools(), uniqueToolsCalled(), hasThinking(),
isMultiStep() and toArray().
Use iterations and totalTokens() to watch cost, and durationMs to spot slow provider calls.
Conversations
Section titled “Conversations”send() neither loads nor stores history. To keep one:
$agent = Claw::builder()->memory(new FileMemory)->build();
$turn = $agent->sendInConversation($agent->conversation(), 'My name is Alice.');$turn = $agent->sendInConversation($turn->conversation, 'What is my name?');After each turn, the user’s message as typed, not the augmented version, and the answer are appended
and saved to the conversations namespace.
Streaming
Section titled “Streaming”stream() streams tokens only when no tools are registered and durable runs are off. Otherwise it runs
the loop above and then replays the final answer in small pieces. See Providers: streaming.
Hooks along the way
Section titled “Hooks along the way”Events fire at each step. The groups:
| Prefix | Step |
|---|---|
agent.* | start, each iteration, end, error, iteration cap |
provider.* | each model request, response, retry, error, fallback, cache hit and streamed token |
tool.* | before, after and failure of each tool call |
budget.* | the token budget stopping a run |
structured.* | a structured reply sent back for repair |
guard.* | a blocked message, a rate limit, redacted output |
context.*, compaction.* | history compaction |
conversation.* | a conversation starting and ending |
stream.* | a stream starting, ending or aborting |
memory.* | driver reads and writes, and memory recalled into a message |
shell.* | shell commands run or refused |
skill.* | skills registered and matched |
job.* | queued runs |
run.* | a durable run suspended, resumed, approved or denied, and cancelled |
graph.* | a graph run starting and ending, a fan-out and its join |
A listener that throws is logged and does not stop the run. See Hooks.
Extension points
Section titled “Extension points”| To add | Implement | Page |
|---|---|---|
| a tool | ToolInterface, with your adapter’s binding trait | Tools |
| a guard | GuardInterface | Guards |
| a hook | HookInterface, or any callable | Hooks |
| a memory driver | MemoryInterface | Memory |
| a skill | SkillInterface | Skills |
| a provider | ProviderInterface | Providers |
| an approval policy | ApprovalGateInterface | Tools |
| an MCP transport | TransportInterface in phpclaw/phpclaw-mcp | MCP |
| an adapter for a framework or CMS | no base class; wire the registries into the platform’s own bootstrap | Building an Adapter |
phpclaw/phpclaw-cloud builds on the same contracts, with CloudScanGuard and CloudWebhookHook,
which it registers only when a cloud key is set.