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 and 85 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:
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() is only reachable because verify() throws ToolException on an incomplete infrastructure result.

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.


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

A binding trait supplies all three. That is the only structural difference between a core tool and an adapter tool.

Core ships HasCoreToolBinding:

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 a trait of the same name that pulls in core’s and binds its own platform. Drupal’s:

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.


Seven register automatically. db_query and zip_package are declared default: false and register only when you add them yourself.

ToolTool nameCatalogue keyAuto-registersMutatingNeeds config
ShellToolshell_execshellyesper invocationallowlist
HttpToolhttp_requesthttpyesnonone
FileReadToolfile_readfile_readyesnoworkspaceRoot
FileWriteToolfile_writefile_writeyesyesworkspaceRoot, allowPhpWrite
FileEditToolfile_editfile_edityesyesworkspaceRoot
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.

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 is truncated at 8192 bytes of stdout and 10000 bytes of stderr, with a 5 second process timeout and a 10 MB hard read ceiling.

The default allowlist is ls, pwd, df, cat, head, tail, grep, wc, date, uptime, hostname and whoami. Those twelve are also the commands the approval gate treats as read-only, even when you pass a different 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.

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 and directories, but not sensitive file names, so it will write .htaccess or id_rsa.

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.

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

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 you can read with shell. 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.

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
Laravel6phpclaw.chat, phpclaw.manage-allLaravelConsole::isInteractive()
Symfony2ROLE_PHPCLAW_CHAT, ROLE_PHPCLAW_MANAGE_ALLconsoleContext()?->isConsole()
core9phpclaw_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.

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 nineconsole, 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 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 nine 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 decides which calls need a human:

Tool implementsBehaviour
PerInvocationMutabilityInterfaceisMutating($input) decides per call
MutatingToolInterfacealways requires approval
neitherpasses silently

The gate 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 every mutating call is refused this way.

ShellTool is the per-invocation case: a command whose name is in the read-only allowlist is treated as non-mutating, anything else 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 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.

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. Two independent 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
fullunlimited (0)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.

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 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.
  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. Ties break on tool name, never on declaration order.

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.

Set maxToolsPerTurn(ToolRouter::UNLIMITED) to disable the cap entirely.


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{ 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
ConfigurableToolInterfaceconfigure(), isConfigured()if it needs runtime config
ResettableInterfacereset()if it holds per-run state

All nine core tools implement ToolInterface, AuthorizableToolInterface and ToolRoutingInterface. 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