Skip to content

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;

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
MethodJobReturns
plan()authorise, normalise, validatethe input the rest of the run uses, plus an optional finished result that short-circuits everything after it
perform()do the workan 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 envelopethe 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.

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.


Besides the four lifecycle methods, HasToolExecutionContract leaves three methods abstract, because core cannot answer them alone:

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


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().

ToolTool nameCatalogue keyDefault (instantiateDefaults())MutatingNeeds config
ShellToolshell_execshellnoper invocationallowlist
HttpToolhttp_requesthttpnononone
FileReadToolfile_readfile_readyesnoworkspaceRoot
FileWriteToolfile_writefile_writenoyesworkspaceRoot, allowPhpWrite
FileEditToolfile_editfile_editnoyesworkspaceRoot
CodeSearchToolcode_searchcode_searchyesnoworkspaceRoot
ProjectToolproject_infoproject_infoyesnoprojectRoot
DatabaseQueryTooldb_querydb_querynononone
ZipPackagerToolzip_packagezip_packagenoyesworkspaceRoot

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.

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.

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.

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:

InputMeaning
offsetfirst line to return, counting from 1
limitnumber of lines to return
line_numberstrue 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 }
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).

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.

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.

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

new DatabaseQueryTool($pdo);
new DatabaseQueryTool($pdo, '/var/www/app/storage/phpclaw'); // where large results are written

Read-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:

QueryResult
SELECT ... UNION SELECT ...allowed
WITH x AS (SELECT ...) SELECT ...allowed
WITH x AS (SELECT ...) DELETE ...refused: DELETE
SELECT 1; SELECT 2refused: only a single statement; one trailing ; is fine
any --, # or /* */ commentrefused
INTO OUTFILE, LOAD_FILE, INFORMATION_SCHEMA, FOR UPDATErefused

It also refuses any query naming password, passwd, secret, private_key, api_key, api_token, access_token, secret_key or auth_token.

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.


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.

AdapterToolsCapabilityConsole test
WordPress22phpclaw_use_chatdefined('WP_CLI') && WP_CLI
Drupal15use phpclaw chatDrupalConsole::isActive()
PrestaShop14AdminPhpClawDebugPHPCLAW_PS_CONSOLE
Magento10PhpClaw_Magento::phpclaw_chatidentity()->runningInConsole()
OpenCart10accessPHPCLAW_OC_CONSOLE
Joomla6phpclaw.chat.useapplication is a ConsoleApplication
Laravel7phpclaw.chat, phpclaw.manage-allLaravelConsole::isInteractive()
Symfony3ROLE_PHPCLAW_CHAT, ROLE_PHPCLAW_MANAGE_ALLconsoleContext()?->isConsole()
core9, plus load_skillphpclaw_use_toolsthe 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.

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().

ToolsisEligibleForRouting()Effect
every adapter toolalways truealways offered; a caller without the capability gets FORBIDDEN when the call runs
core’s toolsconsole, or the caller holds the capabilityoffered 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.

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.


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 implementsBehaviour
PerInvocationMutabilityInterfaceisMutating($input) decides per call
MutatingToolInterfacealways requires approval
neitherpasses 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();

Sending every tool on every turn wastes context and confuses smaller models, so phpClaw narrows the set. Three stages do this.

ToolProfileResolver::resolve() maps provider and model to a profile, and maxTools() turns that into a number:

ProfileBudgetWhen
minimal5provider is ollama or groq, and the model is not a medium one
standard8provider is ollama or groq, and the model matches a medium pattern
full0: no profile cap, so the router derives the budget from the model idevery 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.

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:

  1. Pinning. A tool the message names is kept regardless of score. Both use ShellTool and shell_exec are detected, and matching is substring in both directions, so a partial mention still pins. load_skill is always pinned.
  2. Ranking. Everything else is scored on six weighted signals.
  3. Slicing. The budget is filled with pinned tools first, then the highest-ranked remainder.
  4. Sorting. The result is sorted by name, so an identical selection is byte-identical across messages.
SignalWeightSource
intent16ToolRoutingMetadata::$intents
domain8ToolRoutingMetadata::$domains
tag4ToolRoutingMetadata::$tags
example2ToolRoutingMetadata::$examples
name1the tool name
description1the 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.

With no explicit budget, the router derives one from the model id. Fragments are tried in this order and the first match wins:

OrderModel id containsBudget
1haiku6
2flash6
3gpt-3.55
4mini8
5gemini15
6sonnet12
7gpt-4o15
8opus20
anything else10

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.

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.

ProfileToken budgetTool descriptions sent
minimal3,000first line only
standard6,000full
fullnonefull

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.


Before a tool runs, the agent cleans the arguments the model sent, against the tool’s inputSchema():

ArgumentWhat 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 valueskept
requiredpassed through as sent, even when null
integer or number outside the schema’s minimum or maximumset to that bound
not in the schema’s propertiespassed 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.


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();

ToolRegistry::schemas() formats each tool for the target provider and caches the result per provider name.

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


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.


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.


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.

ContractDeclaresRequired
ToolInterfacename(), description(), inputSchema(), execute()yes
AuthorizableToolInterfacewithAuthorizer()for core-style tools
ToolRoutingInterfaceisEligibleForRouting(), routingMetadata()to be routable
MutatingToolInterfacenothing, it is a markerif the tool always changes state
PerInvocationMutabilityInterfaceisMutating($input)if it depends on the input
ConfigurableToolInterfacestatic settingsFields(), configure(), isConfigured()if it needs runtime config
ResettableInterfacereset()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';
}
}
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'];

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.

These live on the core trait, so a tool does not reimplement them.

HelperReturnsError code
guardCapability(string $subject)null when allowed, else a finished error envelopeFORBIDDEN
rejectUnknownArguments(array $input, array $allowed)null when every key is allowedUNKNOWN_ARGUMENT
validatePaging(array $input, int $maxLimit, int $maxOffset)null when paging is in rangeINVALID_LIMIT, INVALID_OFFSET
success(array $data, array $meta, array $warnings = [])the success envelopen/a
error(string $code, string $message, array $context = [])the failure envelopeyours
recover(ToolException $e, int $attempt)`array{result: stringnull}: a finished result, or [‘result’ => null]` to retry
encodeResult(array $result)JSON, throwing ToolException on encode failuren/a

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": []
}
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.

  • the four lifecycle methods, and no execute() of your own
  • your adapter’s binding trait, not core’s
  • guardCapability() first in plan()
  • const ALLOWED_KEYS plus rejectUnknownArguments()
  • 'additionalProperties' => false in the input schema
  • every query bounded, and every collection sorted by a stable secondary key
  • MutatingToolInterface if it changes state, or PerInvocationMutabilityInterface if that depends on the input
  • routingMetadata() 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