Skip to content

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 matched skills are added to the message
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, duration

Steps 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.


MessageAugmenter builds the text the model receives. It adds, in order:

  1. up to 3 entries from the default memory namespace that share words with the message, under [Context from memory]
  2. the content of up to 3 matching skills, under [Skill context]

Conversation history is not added here. On sendInConversation() it is passed alongside the message as real prior turns.

See Memory and Skills.

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.

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. This happens once, before the loop, so the tool set is fixed for the whole run.

See Tools: which tools a turn sees.

repeat up to maxIterations:
ask the model
if it answered → done
if it asked for tools → for each call:
approval gate (only for mutating tools)
run the tool
sanitise the output
cap the output
add the result to the history
SettingBuilder methodDefault
iteration capmaxIterations()20
provider retries on a transient failuremaxRetries()0, a single attempt
tool result cap, in estimated tokensmaxToolResultTokens()2500
history compaction by message countmaxHistoryLength()0, off
history compaction by estimated tokensmaxHistoryTokens()0, off
tools offered per turnmaxToolsPerTurn()0, derived from the model

When the cap is reached without an answer, MaxIterationsException is thrown. Settings are fixed when you call build(); send() takes no per-call options.

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.

Tools that implement MutatingToolInterface pass through the approval gate before they run, when one is installed. Core installs none; every adapter installs CliApprovalGate.

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.

CallTerminalResult
file_readnoneruns
shell_exec with ls -lanoneruns, read-only command
shell_exec with rm -rf xnonerefused
file_writenonerefused

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. No other adapter tool is marked mutating.

Tools called through the MCP server run directly and do not pass through this gate.

See Tools: human approval.

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 is composed once, at build(). When any tools are registered, Claw::AGENTIC_DOCTRINE is placed first and your systemPrompt() follows it. With no tools, only your prompt is sent. Memory and skill text never goes into the system prompt; it is added to the message in step 1.

Core has nine tools. Seven are marked as defaults: shell_exec, http_request, file_read, file_write, file_edit, code_search and project_info. db_query needs a PDO connection and zip_package is opt-in, so neither is a default. A plain Claw::builder()->build() registers no tools at all; the adapters add the seven defaults with ToolCatalogue::instantiateDefaults(), plus their own platform tools, such as Laravel’s six and WordPress’s twelve, with ten more when WooCommerce is active. See Tools and each adapter’s page.

OutputSanitiser runs on the final answer. It is on by default.

Found in the answerReplaced withHook
<?php, <?=, ?>[PHP_REMOVED]guard.output_php_tag_removed
eval(, system(, exec(, shell_exec(, passthru(, proc_open(, pcntl_exec([REDACTED](guard.output_function_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.

$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.


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.

See Memory: conversations.


stream() streams tokens only when no tools are registered. With tools, it runs the loop above and then replays the final answer in small pieces. See Providers: streaming.


Events fire at each step. The groups:

PrefixStep
agent.*start, each iteration, end, error, iteration cap
provider.*each model request, response, retry, error and streamed token
tool.*before, after and failure of each tool call
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
shell.*shell commands run or refused
skill.*skills registered and matched
job.*queued runs

A listener that throws is logged and does not stop the run. See Hooks.


To addImplementPage
a toolToolInterface, with your adapter’s binding traitTools
a guardGuardInterfaceGuards
a hookHookInterface, or any callableHooks
a memory driverMemoryInterfaceMemory
a skillSkillInterfaceSkills
a providerProviderInterfaceProviders
an approval policyApprovalGateInterfaceTools
an MCP transportTransportInterface in phpclaw/phpclaw-mcpMCP
an adapter for a framework or CMSno base class; wire the registries into the platform’s own bootstrapBuilding an Adapter

phpclaw/phpclaw-cloud builds on the same contracts, with CloudScanGuard and CloudWebhookHook, which it registers only when a cloud key is set.