Tools
A tool is a PHP class that gives the agent a real capability: run a command, read a file, query the database. The agent reads each tool’s name and description, picks the one that fits the task, and calls it.
phpClaw ships nine tools in core, load_skill for skills, and 87 more across the eight adapters.
Every one of them is built the same way, and this page describes that way first, because the rest
follows from it.
Register as many tools as you need; the agent decides which to call:
use PhpClaw\Claw;use PhpClaw\Tools\DatabaseQueryTool;use PhpClaw\Tools\ShellTool;
$agent = Claw::builder() ->tools([ new ShellTool(allowlist: ['df', 'uptime', 'hostname']), new DatabaseQueryTool($pdo), ]) ->build();
echo $agent->send('How many users signed up today, and what is the server uptime?')->text;The lifecycle
Section titled “The lifecycle”You never write execute(). HasToolExecutionContract owns it and calls four methods you do
write:
execute($input) ├─ plan($input) → ['input' => array, 'result' => ?string] │ a non-null result returns immediately ├─ perform($plan['input']) → array ├─ verify($execution, $in) → ['result' => ?string] │ a non-null result returns immediately └─ complete($execution, $in) → string
on ToolException from perform(), verify() or complete(): recover($exception, $attempt) retried while $attempt < maxRecoveryAttempts(), then rethrown| Method | Job | Returns |
|---|---|---|
plan() | authorise, normalise, validate | the input the rest of the run uses, plus an optional finished result that short-circuits everything after it |
perform() | do the work | an internal array, unformatted |
verify() | check the raw result before the model sees it | ['result' => null] to continue, or a finished result |
complete() | build the response envelope | the JSON string the model receives |
Two consequences worth knowing. Authorisation lives in plan(), so a caller without the
capability never reaches perform(). And recover() runs only for a ToolException thrown by
perform(), verify() or complete(); one thrown by plan() is never recovered.
Retries differ between core and adapter tools
Section titled “Retries differ between core and adapter tools”maxRecoveryAttempts() returns 1 on the contract trait. Core’s own binding trait overrides it
to 0, so an infrastructure failure in a core tool surfaces to the caller unchanged instead of
being retried once.
Response encoding differs too
Section titled “Response encoding differs too”The contract trait’s encodeResult() runs html_entity_decode recursively over every string in
the envelope. Core’s binding trait overrides it to encode verbatim, because decoding entities in
file bytes or process output would hand back content the source never held.
Binding traits
Section titled “Binding traits”Besides the four lifecycle methods, HasToolExecutionContract leaves three methods abstract, because
core cannot answer them alone:
| Abstract | Answers |
|---|---|
requiredCapability() | which platform capability this tool needs |
runningInConsole() | whether this request came through the platform’s console |
callerHasCapability() | whether the current caller holds a capability |
Core’s binding trait supplies all three. An adapter’s trait supplies runningInConsole() and
callerHasCapability(), and each adapter tool declares its own requiredCapability().
Core ships HasCoreToolBinding (shortened here; it also sets core’s retry count and result encoding):
trait HasCoreToolBinding{ use HasToolExecutionContract;
public function requiredCapability(): string { return ToolAuthorizerInterface::CAPABILITY; // 'phpclaw_use_tools' }
public function isEligibleForRouting(): bool { return $this->runningInConsole() || $this->callerHasCapability($this->requiredCapability()); }
protected function runningInConsole(): bool { return $this->authorizer !== null && $this->authorizer->runningInConsole(); }
protected function callerHasCapability(string $capability): bool { return $this->authorizer === null || $this->authorizer->allows(); }}Each adapter ships its own HasToolExecutionContract trait that pulls in core’s
HasToolExecutionContract and binds its own platform. Drupal’s, shortened:
namespace PhpClaw\Drupal\Tools\Concerns;
use PhpClaw\Drupal\DrupalConsole;use PhpClaw\Tools\Concerns\HasToolExecutionContract as CoreToolExecutionContract;
trait HasToolExecutionContract{ use CoreToolExecutionContract;
protected function runningInConsole(): bool { return DrupalConsole::isActive(); }
protected function callerHasCapability(string $capability): bool { return (bool) \Drupal::currentUser()->hasPermission($capability); }}An adapter tool imports its own adapter’s trait, never core’s directly.
The nine built-in tools
Section titled “The nine built-in tools”Three are declared default: true: ToolCatalogue::instantiateDefaults() creates code_search,
file_read and project_info, and the adapters that call it register them. The other six are
declared default: false. Claw::builder() registers no tool by itself, so add every tool you want,
these three included, with tools() or addTool().
| Tool | Tool name | Catalogue key | Default (instantiateDefaults()) | Mutating | Needs config |
|---|---|---|---|---|---|
ShellTool | shell_exec | shell | no | per invocation | allowlist |
HttpTool | http_request | http | no | no | none |
FileReadTool | file_read | file_read | yes | no | workspaceRoot |
FileWriteTool | file_write | file_write | no | yes | workspaceRoot, allowPhpWrite |
FileEditTool | file_edit | file_edit | no | yes | workspaceRoot |
CodeSearchTool | code_search | code_search | yes | no | workspaceRoot |
ProjectTool | project_info | project_info | yes | no | projectRoot |
DatabaseQueryTool | db_query | db_query | no | no | none |
ZipPackagerTool | zip_package | zip_package | no | yes | workspaceRoot |
Tool name is what name() returns: the name the model calls, the name a deny list matches, and
the name the router pins. Catalogue key is the name in the tool’s #[Tool] attribute. They
differ for two tools, so use shell_exec and http_request anywhere you refer to a tool by name.
Core has one more tool outside the catalogue: load_skill (LoadSkillTool). Claw registers it
by itself when at least one skill is registered, and it reads a skill’s full instructions for the
model. It follows the same lifecycle and capability as the nine. See Skills.
shell_exec
Section titled “shell_exec”new ShellTool(allowlist: ['ls', 'pwd', 'df', 'date', 'uptime']);Executes commands from an explicit allowlist. Anything outside the allowlist is refused, and a separate hard-blocked list is refused even if an adapter adds it to the allowlist. Shell metacharacters are rejected rather than escaped.
Output over 8192 bytes of stdout, or over 10000 bytes of stderr when a failed command printed nothing to stdout, returns a preview and the path of a temp file holding the full output. The process has a 5 second timeout and a 10 MB hard read ceiling.
The default allowlist is ls, pwd, df, wc, date, uptime, hostname and whoami. The
approval gate treats twelve commands as read-only: those eight plus cat, head, tail and
grep. That read-only list stays the same whatever allowlist you pass, and a command on it still
runs only when it is also on the allowlist.
The hard-blocked commands, the metacharacter rule, blocked system paths and output cleaning are listed under Security: shell.
http_request
Section titled “http_request”Outbound GET and POST only, behind SSRF validation. Responses truncate at 8192 bytes, with a 10 second timeout. The address checks and redirect handling are described under Security: HTTP.
file_read
Section titled “file_read”new FileReadTool(workspaceRoot: '/var/www/app/storage/phpclaw');Reads one file from the sandboxed workspace, which defaults to storage/phpclaw under the current
working directory. Truncates at
16384 bytes and sniffs the first 8192 bytes to refuse binaries. It refuses sensitive extensions,
file names and directories from BlockedPaths. The three file tools do not apply identical lists;
see Security: files.
Three optional inputs read part of a long file, past the first 16384 bytes:
| Input | Meaning |
|---|---|
offset | first line to return, counting from 1 |
limit | number of lines to return |
line_numbers | true prefixes each line with its number and a tab |
With any of them the result also carries next_offset, the first line not returned, or null at the
end of the file; the model passes it back as offset to read on. The 16384-byte cap still applies to
the slice. An offset or limit below 1, or not a whole number, is refused; an offset past the end
returns an empty slice. Without them the output is unchanged.
{ "path": "routes/web.php", "offset": 1, "limit": 2, "line_numbers": true }file_write
Section titled “file_write”new FileWriteTool(workspaceRoot: '/var/www/app/storage/phpclaw', allowPhpWrite: true);Writes into the same sandbox, up to 10 MB, through an atomic write. PHP files (php, phtml,
phar) are refused unless the tool is constructed with allowPhpWrite: true. It always refuses
executable extensions (sh, bash, exe, bat, cmd, ps1) and the core, system and sysext
folders, as well as sensitive extensions, directories and file names (.htaccess, id_rsa,
wp-config.php) and the build files file_edit protects (composer.json, .gitignore).
file_edit
Section titled “file_edit”Replaces one unique string in a workspace file, so an edit that matches nothing or matches twice
fails instead of guessing. Caps files at 2 MB and additionally blocks .github, .git and
.circleci, plus build files such as composer.json and package.json.
It refuses to edit a file that file_read has not read during the same run. Its description tells the
model to copy old_str from the file text only, never the line-number prefix file_read adds.
code_search
Section titled “code_search”Searches a pattern across workspace files. Scans at most 1000 rows, pages at 8192 bytes, skips
files over 1 MB and skips vendor, node_modules, .git and .idea. It also skips the secret files
file_read refuses (.env files, sensitive extensions, file names and directories), and refuses a
path inside a sensitive directory such as .ssh; see Security: files.
It returns file and line for each match, with total and truncated in the payload.
context_lines adds the matched line and that many lines around it. is_regex: true treats the
pattern as a complete PCRE, delimiters included: pass /beta|gamma/, since a bare beta|gamma is
refused as an invalid pattern. A truncated result carries
next_offset, which the model passes back as offset for the next page. The tool’s description
names the workspace root, so the model does not guess paths.
project_info
Section titled “project_info”new ProjectTool(projectRoot: '/var/www/app');Detects the framework or CMS, lists Composer packages and prints the file tree, skipping
vendor, node_modules, .git, .idea, storage and cache. It recognises WordPress, Joomla,
OpenCart, PrestaShop, Laravel, Symfony, Magento and Drupal from marker files such as wp-config.php,
artisan and bin/console. depth sets the tree depth, default 2, clamped into 1 to 4 rather than refused. Its description tells the
model to call it first on a new task. Without a projectRoot it inspects the process’s working
directory, and it refuses to run with a ToolException when that root is the filesystem root, as it
does when the folder does not exist. Each adapter passes its own site or app root (base_path() on
Laravel, the kernel project folder on Symfony, ABSPATH, JPATH_ROOT, DRUPAL_ROOT, BP, the
OpenCart store folder and _PS_ROOT_DIR_), so project_info works over MCP even when the process
starts at /.
db_query
Section titled “db_query”new DatabaseQueryTool($pdo);new DatabaseQueryTool($pdo, '/var/www/app/storage/phpclaw'); // where large results are writtenRead-only SELECT against the live database, taking the query in sql, capped at 100 rows and 8192
bytes of output.
Results over the byte cap return a preview plus a spill_path, a JSON Lines file the model can read
with shell_exec when head or grep is on its allowlist (neither is in the default). Off by
default.
What the query check allows and refuses:
| Query | Result |
|---|---|
SELECT ... UNION SELECT ... | allowed |
WITH x AS (SELECT ...) SELECT ... | allowed |
WITH x AS (SELECT ...) DELETE ... | refused: DELETE |
SELECT 1; SELECT 2 | refused: only a single statement; one trailing ; is fine |
any --, # or /* */ comment | refused |
INTO OUTFILE, LOAD_FILE, INFORMATION_SCHEMA, FOR UPDATE | refused |
It also refuses any query naming password, passwd, secret, private_key, api_key,
api_token, access_token, secret_key or auth_token.
zip_package
Section titled “zip_package”Zips a workspace folder into {output_name}.zip in the temp directory, for CMS installation. Every
file sits under a top-level folder named output_name, the layout a CMS uploader expects. It needs
PHP’s zip extension. Off by default.
Who may run a tool
Section titled “Who may run a tool”Every tool declares a capability, and guardCapability() decides in two steps:
if ($this->runningInConsole()) { return null; // allowed}
if ($this->callerHasCapability($this->requiredCapability())) { return null; // allowed}
return $this->error('FORBIDDEN', ...);The console is checked first and unconditionally. A CLI caller reaches every registered tool without any capability check at all. That is deliberate: the console is already an authenticated administrative context on every one of these platforms.
Through the web, the capability decides. Within an adapter every tool uses the same capability,
except the database tool on Laravel and Symfony, so the tool set is not split into tiers: a user
who holds the capability reaches the same tools as any other user who holds it. Which roles or
groups hold it is set per adapter; see the page for your adapter. Laravel checks phpclaw.chat only
when a Gate defines it, and Symfony checks ROLE_PHPCLAW_CHAT only when the chat role is required;
otherwise any authenticated user passes.
| Adapter | Tools | Capability | Console test |
|---|---|---|---|
| WordPress | 22 | phpclaw_use_chat | defined('WP_CLI') && WP_CLI |
| Drupal | 15 | use phpclaw chat | DrupalConsole::isActive() |
| PrestaShop | 14 | AdminPhpClawDebug | PHPCLAW_PS_CONSOLE |
| Magento | 10 | PhpClaw_Magento::phpclaw_chat | identity()->runningInConsole() |
| OpenCart | 10 | access | PHPCLAW_OC_CONSOLE |
| Joomla | 6 | phpclaw.chat.use | application is a ConsoleApplication |
| Laravel | 7 | phpclaw.chat, phpclaw.manage-all | LaravelConsole::isInteractive() |
| Symfony | 3 | ROLE_PHPCLAW_CHAT, ROLE_PHPCLAW_MANAGE_ALL | consoleContext()?->isConsole() |
| core | 9, plus load_skill | phpclaw_use_tools | the authorizer’s runningInConsole() |
Laravel and Symfony are the two that use a second, higher capability: their database tool requires the manage-all ability or role, the rest require the chat one.
PrestaShop’s database and OpenCart’s db_query are the exceptions to the console rule. Each reads
the flag its entry point passes (OpenCart falls back to PHPCLAW_OC_CONSOLE when none is passed), so
the send command skips their checks while the mcp-server command, which builds its tools as
non-interactive, keeps them.
Offered versus allowed
Section titled “Offered versus allowed”These are two separate decisions.
Offered is whether the model sees the tool in its schema list, decided by
isEligibleForRouting(). Allowed is whether a call succeeds, decided by guardCapability() in
plan().
| Tools | isEligibleForRouting() | Effect |
|---|---|---|
| every adapter tool | always true | always offered; a caller without the capability gets FORBIDDEN when the call runs |
| core’s tools | console, or the caller holds the capability | offered only to callers who would be allowed |
Because no authorizer is bound, core’s check also always resolves to true in practice, so every
tool in every adapter is offered to every caller, and the capability is enforced at call time.
Core’s tools have no active gate
Section titled “Core’s tools have no active gate”The mechanism is fully built. All nine core tools and load_skill implement
AuthorizableToolInterface, and two places would bind an authorizer: ToolRegistry’s constructor,
and ToolCatalogue when the config array carries an authorizer key.
Neither is supplied. Claw constructs its registry as new ToolRegistry with no argument, no
adapter passes an authorizer config key, and no class outside core’s own test suite implements
ToolAuthorizerInterface. With no authorizer bound, callerHasCapability() short-circuits to
true:
return $this->authorizer === null || $this->authorizer->allows();So phpclaw_use_tools is a capability nothing evaluates, and core’s tools allow every caller
that can reach them. Adapter tools are unaffected: they resolve capabilities through their own
platform and are gated normally.
Human approval for mutating tools
Section titled “Human approval for mutating tools”Core installs no gate: with a plain builder, mutating tools run without asking. (Every adapter
installs one.) Install the built-in gate with withHumanApproval(), or your own with
approvalGate():
$agent = Claw::builder()->withHumanApproval()->build();When a gate is set, it is consulted before each tool call. CliApprovalGate, and the
durable runs gate SuspendableApprovalGate, decide which calls need
a human by the same rule, ToolMutability::isApprovalRequired():
| Tool implements | Behaviour |
|---|---|
PerInvocationMutabilityInterface | isMutating($input) decides per call |
MutatingToolInterface | always requires approval |
| neither | passes silently |
CliApprovalGate is fail-closed. With no interactive terminal it throws HumanDeniedException
rather than allowing the call. The agent catches that, skips the tool, and hands the model a
{"status": "denied"} result, so the run continues. Web requests have no terminal, so in a web chat
with this gate every mutating call is refused this way. SuspendableApprovalGate pauses the run
instead, until someone approves or denies the call; that pause needs a durable run, and outside one
the call is denied with a result saying the run cannot pause.
ShellTool is the per-invocation case: a command whose name is in the read-only list needs no
approval, and neither does a command the tool refuses anyway (a shell metacharacter, a hard-blocked
program or one not on the allowlist), which is refused straight away. Any other allowed command needs
approval, and an empty command counts as mutating.
Among core’s tools, file_write, file_edit and zip_package are always mutating, and shell_exec
decides per call.
In a terminal, answering n denies the call the same way: the model gets the denied result and the
run continues.
The gate is checked inside the agent loop, not through a hook, so a hook listener cannot block a
tool call. The MCP server calls tools directly and never consults a gate. Its tools/list marks every
tool that can change data with readOnlyHint: false and destructiveHint: true.
A custom gate implements one method; throw to deny, return to allow:
use PhpClaw\Agent\Contracts\ApprovalGateInterface;use PhpClaw\Exceptions\HumanDeniedException;use PhpClaw\Tools\Contracts\ToolInterface;
final class PolicyGate implements ApprovalGateInterface{ public function check(string $toolName, array $toolInput, ?ToolInterface $tool = null): void { if ($toolName === 'file_write') { throw new HumanDeniedException($toolName, $toolInput); } }}
$agent = Claw::builder()->approvalGate(new PolicyGate)->build();Which tools a turn sees
Section titled “Which tools a turn sees”Sending every tool on every turn wastes context and confuses smaller models, so phpClaw narrows the set. Three stages do this.
Stage 1: the profile sets a budget
Section titled “Stage 1: the profile sets a budget”ToolProfileResolver::resolve() maps provider and model to a profile, and maxTools() turns that
into a number:
| Profile | Budget | When |
|---|---|---|
minimal | 5 | provider is ollama or groq, and the model is not a medium one |
standard | 8 | provider is ollama or groq, and the model matches a medium pattern |
full | 0: no profile cap, so the router derives the budget from the model id | every other provider |
Medium patterns are 30b, 32b, 34b, 35b, 65b, 70b and 72b, matched as substrings of
the model id.
That number is passed to the builder as maxToolsPerTurn, which becomes the router’s budget. All
eight adapters wire both halves.
Note that ToolProfileResolver::filter() applies the deny list only. The profile budget travels
separately, through maxTools(), and is enforced by the router.
Stage 2: the router ranks and slices
Section titled “Stage 2: the router ranks and slices”ToolRouter runs once per call, before the iteration loop, against the user’s message. The tool
set it produces is then fixed for the whole run.
It returns early and filters nothing when the number of tools, not counting
always-included tools, is already within budget, so on a
full profile with few tools the router never engages.
When it does engage:
- Pinning. A tool the message names is kept regardless of score. Both
use ShellToolandshell_execare detected, and matching is substring in both directions, so a partial mention still pins.load_skillis always pinned. - Ranking. Everything else is scored on six weighted signals.
- Slicing. The budget is filled with pinned tools first, then the highest-ranked remainder.
- Sorting. The result is sorted by name, so an identical selection is byte-identical across messages.
| Signal | Weight | Source |
|---|---|---|
intent | 16 | ToolRoutingMetadata::$intents |
domain | 8 | ToolRoutingMetadata::$domains |
tag | 4 | ToolRoutingMetadata::$tags |
example | 2 | ToolRoutingMetadata::$examples |
name | 1 | the tool name |
description | 1 | the tool description |
Message words shorter than three characters and a list of stopwords are dropped before matching.
A word longer than three characters that ends in s is cut to its singular, and ies becomes y,
on both the message and the tool’s metadata, so “editors” matches the tag editor and “categories”
matches category. The rule is not a dictionary: status becomes statu, on both sides, so it still
matches.
Ties break on tool name, never on declaration order.
With three or more tools, each matched word counts by how rare it is across the tools: a word only one tool carries counts more than a word most tools carry. A word repeated in the message counts less, so a pasted document does not outrank the request around it.
On the minimal profile, a ranked tool scoring below 20% of the best score is left out even when
budget slots remain.
The per-turn budget
Section titled “The per-turn budget”With no explicit budget, the router derives one from the model id. Fragments are tried in this order and the first match wins:
| Order | Model id contains | Budget |
|---|---|---|
| 1 | haiku | 6 |
| 2 | flash | 6 |
| 3 | gpt-3.5 | 5 |
| 4 | mini | 8 |
| 5 | gemini | 15 |
| 6 | sonnet | 12 |
| 7 | gpt-4o | 15 |
| 8 | opus | 20 |
| anything else | 10 |
So gpt-4o-mini gets 8 (mini before gpt-4o) and gemini-3.5-flash-lite gets 6 (flash before
gemini). Fragments match on token boundaries, not as bare substrings, because a plain substring
test resolves every non-Flash Gemini id as mini.
maxToolsPerTurn(ToolRouter::UNLIMITED) turns the cap off, so every tool is offered. Any other
negative value is clamped to 0, and the budget is then derived from the model.
Tools offered on every turn
Section titled “Tools offered on every turn”alwaysIncludeTools(['file_read', 'file_edit']) names tools the router offers on every turn,
whatever the message says. They do not count against the budget: with a budget of 6 and two
always-included tools, the model is offered 8. Names are matched without regard to case, and a name
that is not a registered tool is skipped, so a tool removed by the deny list stays removed.
Stage 3: the request fits the token budget
Section titled “Stage 3: the request fits the token budget”After the router, tools are dropped one at a time until the estimated request fits the profile’s token budget. The estimate is the system prompt, the history including the current message and the tool schemas, at four characters per token. At least one routed tool is always kept, and an always-included tool is never dropped.
| Profile | Token budget | Tool descriptions sent |
|---|---|---|
minimal | 3,000 | first line only |
standard | 6,000 | full |
full | none | full |
When the router ranked this turn, the lowest-ranked tools go first. When it did not, because the tool count was already within the per-turn budget, tools are dropped from the end of the registration order, and that includes a tool the message names.
Arguments the agent tidies
Section titled “Arguments the agent tidies”Before a tool runs, the agent cleans the arguments the model sent, against the tool’s inputSchema():
| Argument | What happens |
|---|---|
optional, sent as null, a blank string, * or % | dropped, so the tool sees it as absent |
optional * or % that is one of the property’s enum values | kept |
| required | passed through as sent, even when null |
integer or number outside the schema’s minimum or maximum | set to that bound |
not in the schema’s properties | passed through as sent |
A numeric string for an integer property becomes an integer. Only arguments the schema describes are
tidied, so an unknown argument still reaches your plan() and can be refused there.
Tool result size
Section titled “Tool result size”Every tool result is sent again on each later turn, so one oversized result costs many times. A
single result is cut at an estimated 2500 tokens (10,000 characters) and ends with a note telling the
model it was cut and to narrow the request or page with offset. Change or disable the ceiling on the builder; 0 disables the cut:
$agent = Claw::builder()->maxToolResultTokens(10000)->build();Provider schema shapes
Section titled “Provider schema shapes”ToolRegistry::schemas() formats each tool for the target provider and caches the result per
provider name.
| Providers | Shape |
|---|---|
openai, groq, gemini, mistral, ollama, deepseek, custom | { type: 'function', function: { name, description, parameters } } |
| everything else, including unknown names | { name, description, input_schema } |
An unknown provider gets the Anthropic-native shape as a safe default.
One fix happens on the way out: a tool whose properties is an empty PHP array would serialise as
[], which providers reject, so it is swapped for an object and emitted as {}.
Tools implementing ToolRoutingInterface are excluded from the schema list when
isEligibleForRouting() returns false.
Remote tool profiles
Section titled “Remote tool profiles”A remote profile selects among tools already installed locally. It cannot add a tool, and cannot cause code to be downloaded or executed.
$agent = Claw::builder() ->withRemoteToolProfile('https://example.com/profile.json') ->build();Call withRemoteToolProfile() more than once to apply several profiles in order.
{ "profile": "support", "tools": ["file_read", "code_search"], "max_tools_per_turn": 6 }The fetch is constrained: HTTPS only, SSRF-validated with the resolved address pinned, no redirects followed, 5 second timeout, 64 KB response cap, and a one hour file cache. Any failure logs a warning and leaves the tool list untouched.
A profile’s max_tools_per_turn applies only when no explicit budget was set in code.
Denying tools
Section titled “Denying tools”The builder has no deny setter. Remove tools from the list before you pass it in, with
ToolProfileResolver::filter(). Entries are tool names, or group references that expand to names:
use PhpClaw\Tools\ToolProfileResolver;
$tools = ToolProfileResolver::filter( $tools, deny: ['shell_exec', 'group:writes'], groups: ['group:writes' => ['file_write', 'file_edit']],);
$agent = Claw::builder()->tools($tools)->build();This is the path every adapter uses: each reads its configured deny list and filters its tool roster this way before building the agent.
ToolRegistry::register() also accepts $deny and $groups, and on that path it does log a warning
for a group member matching no tool. The engine does not use it: Claw registers the already
filtered list with no deny arguments.
Writing a tool
Section titled “Writing a tool”The simplest tool
Section titled “The simplest tool”ToolInterface alone is enough. Write execute() yourself and return a string:
use PhpClaw\Tools\Contracts\ToolInterface;
final class ServerTimeTool implements ToolInterface{ public function name(): string { return 'server_time'; // unique, snake_case }
public function description(): string { return 'Return the current server time.'; // the model reads this to decide }
public function inputSchema(): array { return ['type' => 'object', 'properties' => []]; // sent to the provider as {} }
public function execute(array $input): string { return json_encode(['time' => date(DATE_ATOM)]); }}
$agent = Claw::builder()->tools([new ServerTimeTool])->build();A tool with parameters describes them in the schema:
public function inputSchema(): array{ return [ 'type' => 'object', 'properties' => [ 'key' => ['type' => 'string', 'description' => 'The cache key to look up.'], 'store' => ['type' => 'string', 'description' => 'Cache store name. Defaults to "default".'], ], 'required' => ['key'], ];}The rest of this section builds the fuller, lifecycle-based kind used by core and the adapters.
1. Implement the contracts you need
Section titled “1. Implement the contracts you need”| Contract | Declares | Required |
|---|---|---|
ToolInterface | name(), description(), inputSchema(), execute() | yes |
AuthorizableToolInterface | withAuthorizer() | for core-style tools |
ToolRoutingInterface | isEligibleForRouting(), routingMetadata() | to be routable |
MutatingToolInterface | nothing, it is a marker | if the tool always changes state |
PerInvocationMutabilityInterface | isMutating($input) | if it depends on the input |
ConfigurableToolInterface | static settingsFields(), configure(), isConfigured() | if it needs runtime config |
ResettableInterface | reset() | if it holds per-run state |
All nine core tools and load_skill implement ToolInterface, AuthorizableToolInterface and
ToolRoutingInterface. Of the nine, four are mutating, two are resettable, and one is per-invocation.
A tool that always changes state adds the marker:
final class SendInvoiceTool implements ToolInterface, MutatingToolInterface{ // ...}A tool that changes state only for some inputs decides per call; return true when unsure:
final class CacheTool implements ToolInterface, PerInvocationMutabilityInterface{ public function isMutating(array $input): bool { return ($input['action'] ?? '') !== 'status'; }}2. Use your adapter’s binding trait
Section titled “2. Use your adapter’s binding trait”final class DrupalCacheTool implements ToolInterface, ToolRoutingInterface{ use HasToolExecutionContract; // the Drupal trait
private const REQUIRED_CAPABILITY = 'use phpclaw chat'; private const MAX_LIMIT = 500; private const ALLOWED_KEYS = ['schema', 'limit', 'offset'];3. Write plan() in the canonical order
Section titled “3. Write plan() in the canonical order”Authorise, normalise, validate:
protected function plan(array $input): array{ $forbidden = $this->guardCapability('read Drupal cache bin sizes');
if ($forbidden !== null) { return ['input' => $input, 'result' => $forbidden]; }
$input = InputNormaliser::flattenArrayValues($input);
return ['input' => $input, 'result' => $this->validate($input)];}4. Throw from verify() so recover() can run
Section titled “4. Throw from verify() so recover() can run”protected function verify(array $execution, array $input): array{ if ($execution['type'] === 'query' && ! is_array($execution['payload']['cache_bins'] ?? null)) { throw new ToolException('DrupalCacheTool returned an incomplete cache bin result.'); }
return ['result' => null];}Only a ToolException wrapping a previous exception is treated as retryable infrastructure
failure. Anything else is rethrown immediately.
What the lifecycle gives you
Section titled “What the lifecycle gives you”These live on the core trait, so a tool does not reimplement them.
| Helper | Returns | Error code |
|---|---|---|
guardCapability(string $subject) | null when allowed, else a finished error envelope | FORBIDDEN |
rejectUnknownArguments(array $input, array $allowed) | null when every key is allowed | UNKNOWN_ARGUMENT |
validatePaging(array $input, int $maxLimit, int $maxOffset) | null when paging is in range | INVALID_LIMIT, INVALID_OFFSET |
success(array $data, array $meta, array $warnings = []) | the success envelope | n/a |
error(string $code, string $message, array $context = []) | the failure envelope | yours |
recover(ToolException $e, int $attempt) | `array{result: string | null}: a finished result, or [‘result’ => null]` to retry |
encodeResult(array $result) | JSON, throwing ToolException on encode failure | n/a |
The envelope
Section titled “The envelope”success():
{ "success": true, "data": { }, "meta": { }, "warnings": [] }error() adds error and fixes two fields:
{ "success": false, "error": { "code": "FORBIDDEN", "message": "..." }, "data": null, "meta": { "mode": "error" }, "warnings": []}Helping the router find your tool
Section titled “Helping the router find your tool”public function routingMetadata(): ToolRoutingMetadata{ return new ToolRoutingMetadata( domains: ['cache', 'performance'], tags: ['cache', 'bin', 'flush'], intents: ['inspect cache', 'clear cache'], examples: ['how big is the render cache'], );}intents carries sixteen times the weight of the tool name, so spend the effort there.
Checklist
Section titled “Checklist”- the four lifecycle methods, and no
execute()of your own - your adapter’s binding trait, not core’s
guardCapability()first inplan()const ALLOWED_KEYSplusrejectUnknownArguments()'additionalProperties' => falsein the input schema- every query bounded, and every collection sorted by a stable secondary key
MutatingToolInterfaceif it changes state, orPerInvocationMutabilityInterfaceif that depends on the inputroutingMetadata()if the tool should be routable- every input validated before use, and nothing secret in the output
- user-supplied paths kept inside a sandbox, and outbound URLs limited to known hosts
- failures thrown as
ToolException