A tool in phpClaw is a PHP class with four methods. The agent hands the model a menu of those classes’ JSON schemas, the model picks one, phpClaw executes it, and the string it returns goes back into the conversation as the next turn’s input. That is the entire mechanism — there is no planner, no sub-agent, no orchestration layer between the model’s choice and your execute() method.

Which means the interesting questions are all concrete: what ships in the box for your platform, what happens when the model sends garbage arguments, and how much of this you have to write yourself. Answers below, per adapter, with the entrypoint file for each.

The demo that makes it real

Ask a WordPress agent to build you a plugin. It writes the PHP into a sandboxed workspace with file_write, then calls wp_zip_plugin — the WordPress adapter’s own packaging tool, at wordpress/src/Tools/WpZipBuilderTool.php:48.

That tool does three checks before it packages anything: a plugin-header check, a forbidden-function scan (eval, system, exec, passthru, shell_exec), and a real php -l syntax lint executed through proc_open. Then it writes a ZIP to the system temp directory and returns:

{
  "zip": "maintenance-notice.zip",
  "path": "/tmp/maintenance-notice.zip",
  "size": 4213,
  "install": "Install via wp-admin → Plugins → Add New → Upload Plugin."
}

The keys are the tool’s; the values are illustrative of one run.

Read that install value carefully, because it is the honest edge of this demo: the tool does not install anything. It calls no installer API. It builds a validated, installable artifact and then tells a human where to upload it. You get a real ZIP that passes a real syntax check and did not exist sixty seconds ago — and then you drag it into wp-admin yourself.

The Joomla adapter (5/6, one registration) has the direct equivalent, joomla_zip_extension, at JoomlaZipBuilderTool.php:52. Same shape, one extra key, same manual last step:

{
  "zip": "mod_notice.zip",
  "path": "/tmp/mod_notice.zip",
  "size": 3980,
  "extension_type": "plugin",
  "install": "Install via System → Install → Extensions → Upload Package File."
}

One caveat that matters more than it looks. On WordPress, writing a .php file requires allowPhpWrite, and that flag is true only on the CLI path (wordpress/src/Engine/EngineFactory.php:161) — it is false at :190, which covers admin chat and the MCP surface. So the plugin-building demo runs here:

# WordPress site root, via WP-CLI — the only surface where allowPhpWrite is true.
# Check the exact argument form for your version with: wp help phpclaw send
wp phpclaw send "Write a plugin called Maintenance Notice that shows an admin banner, then package it."

Run the same prompt from the admin chat box and the agent will write the code and fail to save the .php file. That is deliberate: an agent that can drop arbitrary PHP into a web-reachable process from a browser form is a remote code execution feature. Joomla’s equivalent gate was not confirmed for this article — check it on your install before you assume symmetry.

What a tool actually is

ToolInterface (core/src/Tools/Contracts/ToolInterface.php:13) is four methods and they are frozen:

public function name(): string;              // the identifier the model calls
public function description(): string;       // what the model reads to decide
public function inputSchema(): array;        // JSON Schema, sent to the provider
public function execute(array $input): string; // your code; returns a string

execute() returns a string. Not an array, not a DTO — a string that gets appended to the conversation as a tool result and shipped back to the provider on the next request. If you want structure, you return JSON in that string, which is what every shipped tool does.

The core catalogue is seven utility tools present on all eight adapters — file_read, file_write, file_edit, http_request, shell_exec, code_search, project_info — plus zip_package (core/src/Tools/ZipPackagerTool.php:15-21), which ships default: false and is the only packaging tool in core. wp_zip_plugin and joomla_zip_extension are adapter-specific wrappers, not core.

Those seven are not as open as their names suggest. file_write and file_edit are confined to a sandbox (storage/phpclaw/), with .php, .phtml and .phar blocked unless allowPhpWrite is on. shell_exec runs through proc_open with an argv array — no shell — against a default allowlist of 12 read-oriented commands, with roughly 73 binaries hard-blocked and a 5-second kill timeout. http_request resolves through an SSRF validator and pins the vetted IP via CURLOPT_RESOLVE.

How the tool list is built, and shaped per provider

ToolRegistry keeps tools in a name-keyed map. Registering the same name twice silently replaces the first (core/src/Tools/ToolRegistry.php:25-31) — there is no namespacing and no version suffix, so if your custom tool is called db_query you have just replaced your platform’s db_query.

At request time the registry emits every tool’s schema in the shape the target provider expects (ToolRegistry.php:13, :128-172). Exactly five slugs get the OpenAI function shape: openai, groq, gemini, mistral, ollama. Everything else gets Anthropic’s native tool-schema shape. That is one class handling two wire formats, not a negotiation layer.

There is a sharp edge in that rule worth knowing before you pick a backend: deepseek and custom build an OpenAI-compatible provider but sit outside the OpenAI-shape list, so they receive Anthropic-shaped schemas against an OpenAI-shaped endpoint. That code-path mismatch was found by reading the source, not by executing a live call against either endpoint — treat it as a reason to test tool calling on DeepSeek or a custom endpoint before you depend on it. More on backend selection in Providers.

One side effect to know about: registering any tool at all auto-prepends phpClaw’s AGENTIC_DOCTRINE instruction block ahead of your own system prompt (core/src/Claw.php:33-42). It is prompt text, not enforcement, but it is in front of yours.

The model chooses. phpClaw does not.

There is no selection heuristic in phpClaw that picks a tool. The registry presents schemas, the provider returns tool calls, phpClaw executes what came back. Every “the agent decided to…” sentence about phpClaw is really a sentence about the model you configured.

That has a consequence people trip over: models invent tool names. phpClaw handles it in one specific way (core/src/Agent/Agent.php:186-201, :387-398, :538-546) — the first unregistered name strips the tool schemas and retries the turn once without them; a repeat throws. One retry, not a recovery loop.

Nobody validates the arguments except your tool

This is the single most important thing to know before you write a tool.

There is no JSON-Schema validation anywhere in the agent loop. inputSchema() is sent to the model as a hint and never enforced on the way back — a grep for a JSON-Schema library across the packages returns zero results (core/src/Agent/Agent.php:436, :549; ToolInterface.php:38). Whatever array the provider returns lands in your execute() unexamined.

So the first lines of every execute() are yours to write:

public function execute(array $input): string
{
    $postId = (int) ($input['post_id'] ?? 0);

    if ($postId <= 0) {
        throw new ToolException('post_id is required and must be a positive integer.');
    }
    // ...
}

Throwing is the correct move, and the next section explains why it is safe.

Execution, results, and what happens when a tool throws

Tool calls from one model turn run sequentially in a foreach, and the whole batch comes back as one message inside one loop iteration (Agent.php:429-464, :204). There is no concurrency — no curl_multi, no fibers. Ten tool calls in one turn is ten round trips, in order.

Error handling is narrow and deliberate:

  • A tool that throws ToolException is caught, fires the tool.error lifecycle event, and its message is handed back to the model as the tool result (Agent.php:548-556). The run continues. The model gets to read “post_id is required and must be a positive integer” and try again.
  • Only ToolException is caught. Any other Throwable ends the run. A TypeError in your tool is not a recoverable turn — it is the end of the conversation.

The practical rule: catch your own exceptions inside execute() and re-throw them as ToolException with a message written for a model to read, not for a log file.

The database tools are the reference implementation of that pattern. All nine db_query-class tools across core and the adapters route the model’s SQL through one shared fail-closed validator (core/src/SqlGuard/SqlReadOnlyGuard.php:12-29, :62-76) that accepts a single SELECT or WITH statement and nothing else. A rejected query throws ToolException, the agent catches it, and the run continues with the refusal as the tool result.

Tool output gets one more pass before the model sees it: ToolOutputGuard redacts matched patterns and always continues — it never blocks a turn. That, the approval gate, and ShellTool’s metacharacter rejection (which is a tool-level check, not a GuardInterface guard) are all covered in Guard.

Two filters, in this order

Between “here are all your registered tools” and “here is what the model sees this turn” there are two independent filters. They are frequently conflated. They are not the same thing and they run in sequence.

Filter 1 — ToolProfileResolver (core/src/Tools/ToolProfileResolver.php:22-32) caps the tool count by provider and model size:

ProfileCapApplies to
MINIMAL5ollama and groq on a small model
STANDARD8ollama and groq on a medium model
FULLunlimited (0)every other provider

The local-provider set is exactly ['ollama', 'groq']. “Medium” is a substring match on the model name against 30b, 32b, 34b, 35b, 65b, 70b, 72b — so llama3:70b gets 8 tools and llama3:8b gets 5.

This is a feature, and it is worth being blunt about why. A 7B model handed 20 tool schemas does two things reliably: it burns its context window on the menu, and it hallucinates tool names that were never on it. Capping the local tier at 5 is what makes small self-hosted models usable for tool calling at all. Point a cloud provider at the same registry and the cap disappears.

Filter 2 — ToolRouter (core/src/Tools/ToolRouter.php:18-28, :52, :126-136) then applies a per-turn relevance filter with its own, different limits — keyed to the model, not the provider:

Model familyTools sent
haiku, flash6
gpt-3.55
mini8
sonnet12
gpt-4o15
opus20
default10

Two rules override the ranking: tool names mentioned in the user’s message are pinned and win first, and file_read is always included.

So a run on ollama with llama3:8b is capped to 5 by the profile, and then routed per turn within that 5. A run on claude-3-opus has no profile cap at all and is routed to the 20 most relevant tools per turn. If you registered 30 tools and the model claims one doesn’t exist, this table is where to look first.

The eight adapters

Every one of the eight adapters ships fully wired tools. One — Laravel — ships most of its platform tools deliberately disabled. Counts below are adapter-native tools; where the final total after the core-catalogue merge was independently confirmed, it is stated as a total.

WordPress — 20 tools, 30 with WooCommerce

Entrypoint: buildTools() at wordpress/src/Engine/EngineFactory.php:396.

11 WordPress-native tools plus the 7 core utilities plus zip_package and wp_zip_plugin = 20. Activate WooCommerce and the factory swaps in a 21-tool WordPress+WooCommerce set, adding 10 WooCommerce tools (EngineFactory.php:414-437) — both ProductTool and ShippingTool are present, so the commonly repeated figure of 8 is wrong.

The native query tool is wp_query. The WooCommerce product tool is wc_get_products, and it returns exactly four fields — id, name, stock_status, stock_qty (wordpress/src/WooCommerce/Tools/ProductTool.php:34, :79-104). It reads. It has no write branch, and neither does any other adapter tool on any of the eight platforms.

wp phpclaw send "How many published products are out of stock?"

Joomla (5/6, one registration) — 14 tools

Entrypoint: builtinTools() at joomla/component/src/Engine/ToolBuilder.php:64.

5 Joomla-native tools + 7 core + zip_package + joomla_zip_extension = 14. The named native tools include joomla_database_query and joomla_categories. Every joomla_* data tool runs a read-only contract with no write branch at all (JoomlaArticleTool.php:279-290) — joomla_categories exposes read filters, no parent setter, no nested-set rebuild.

There is deliberately no menu tool and no workflow tool. Anything in that space is reachable only as a hand-written SELECT through joomla_database_query.

Joomla 5 and 6 are one adapter, one registration, one tool list — there is no JVERSION or version_compare branching anywhere in the adapter’s business logic.

Laravel — 7 by default, 13 maximum

Entrypoint: resolveTools() at laravel/src/Engine/EngineFactory.php:98.

The 7 core tools auto-load. The Laravel-specific tools do not: config/phpclaw.php:41-52 ships all 10 entries commented out. Four of those ten are core tools already auto-loading, so uncommenting everything is a net addition of 6 — db_query, read_log, route_list, config_get, cache_inspect, queue_status — for a maximum of 13.

This is a security default, not an oversight. An agent running inside a Laravel application has framework-level access to your database, logs, routes, config, cache and queue. phpClaw’s position is that you turn that on with a deliberate edit, not by installing a package.

php artisan vendor:publish --tag=phpclaw-config

Then uncomment the FQCNs you want in the published config/phpclaw.php.

Symfony (6.4+, 7.x) — 7 by default, 9 maximum

Entrypoint: create() at symfony/src/PhpClawFactory.php:89, wired through DI at config/services.yaml:45.

The core 7 auto-load. DatabaseTool (db_query) and LogTool (read_log) carry no #[Tool(default:true)] attribute, so they must be named in the phpclaw.tools config to be present — the same opt-in posture as Laravel, arrived at through a different mechanism.

One asymmetry to plan for: DatabaseTool silently no-ops without a Doctrine DBAL connection. It does not throw and it does not warn. If db_query is returning nothing on Symfony, check the DBAL connection before you debug the SQL.

Drupal (10/11) — 15 Drupal-specific tools

Entrypoints: create() at drupal/src/PhpClawServiceFactory.php:54 and createChat() at :97. The shared list is built in buildCommonTools() at :232-248.

The two entrypoints do not hand out the same set: the agent path adds ShellTool and HttpTool; the chat path gets the 15 Drupal tools only.

Drupal’s db_query adds a layer the other adapters don’t have — a blocked-tables policy (drupal/src/Tools/BlockedTablesPolicy.php:29-36) that runs on top of the shared read-only SQL validator. Two of its tools are named more aggressively than they behave: drupal_cache does not flush the cache and drupal_cron does not run cron. Both report.

drush phpclaw:run "Which content types have no nodes created in the last 90 days?"

Magento 2.4+ — 12 Magento-wired tools

Entrypoint: Console/Command/PhpClawRunCommand.php, wired at magento/etc/di.xml:15; the MCP surface at di.xml:21.

8 base tools plus 4 ACL-gated ones — magento_orders, magento_customer, db_query, shell — merged with the core catalogue.

Understand what “ACL-gated” means on the CLI, because it is not what it sounds like: those four tools always pass in CLI context. The area-code check throws, the throw is caught, and a caught throw is treated as allowed. The ACL gate is a web-context control. If a Magento CLI run is something a less-trusted operator can trigger, the gate is not what is stopping them from reading orders.

magento_cache, like Drupal’s, reports cache status rather than flushing it.

bin/magento phpclaw:run "Summarise orders placed in the last 24 hours by status."

OpenCart 3 and 4 — 10 OpenCart-native tools

Entrypoints diverge by version, tools do not. OC3: upload/admin/controller/extension/module/phpclaw.php:26. OC4: upload-oc4/admin/controller/module/phpclaw.php:21. CLI on both: upload/cli/phpclaw.php:161, which exposes send, memory and mcp-server subcommands.

The tool classes and their schemas are identical across OC3 and OC4. Only controller routing, permission handling and class names differ. A tool you write for OC3 works unchanged on OC4.

PrestaShop 8.2 and 9 — 14 PrestaShop-specific tools

Three entrypoints: admin at prestashop/phpclaw.php:135, REST at controllers/front/api.php:24, CLI at cli/phpclaw.php:83.

12 commerce, catalog and system tools plus DatabaseTool and LogTool, merged with the core defaults. It is the largest store-native read surface of the eight adapters.

There is no PS8/PS9 divergence anywhere in the tool layer — every tool goes through PsDbAdapter, which wraps Db::getInstance() and presents an identical API on both versions.

On the REST surface, a blocked turn surfaces as HTTP 422 with {"code":"phpclaw_guard", ...}.

Writing your own tool

Two mechanisms, both fully documented in source: a config array (Laravel) and a tagged service (Drupal). The tool class itself is identical in both.

A note on the code below: the four method signatures are verified from ToolInterface, but the vendor namespace prefix on the use statements is not confirmed here — take it from vendor/phpclaw/core/src/Tools/Contracts/ToolInterface.php in your own install. That is why these two class blocks are labelled as pseudocode; everything else in this article is verified as written.

The tool

<?php
// Pseudocode — verify against your version (namespace prefix only; method
// signatures are the frozen ToolInterface contract).
// Laravel: app/PhpClaw/Tools/StaleDraftsTool.php
// Drupal:  modules/custom/my_module/src/PhpClaw/StaleDraftsTool.php

namespace App\PhpClaw\Tools;

use PhpClaw\Core\Tools\Contracts\ToolInterface;
use PhpClaw\Core\Tools\Exceptions\ToolException;

final class StaleDraftsTool implements ToolInterface
{
    public function name(): string
    {
        return 'stale_drafts';
    }

    public function description(): string
    {
        return 'List draft posts not updated in the last N days. '
             . 'Returns JSON: {"count": int, "items": [{"id": int, "title": string, "updated_at": string}]}.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'days' => [
                    'type' => 'integer',
                    'description' => 'How many days without an update counts as stale. 1-365.',
                ],
            ],
            'required' => ['days'],
        ];
    }

    public function execute(array $input): string
    {
        // Nothing validated this for you. Validate it here.
        $days = (int) ($input['days'] ?? 0);

        if ($days < 1 || $days > 365) {
            throw new ToolException('days must be an integer between 1 and 365.');
        }

        try {
            $rows = $this->findStaleDrafts($days);
        } catch (\Throwable $e) {
            // Re-throw as ToolException so the run survives and the model can retry.
            throw new ToolException('Could not read drafts: ' . $e->getMessage());
        }

        return json_encode(['count' => count($rows), 'items' => $rows], JSON_THROW_ON_ERROR);
    }
}

Three things that example is doing on purpose. The description names the exact JSON shape it returns, because the model reads the description to decide whether to call it and reads nothing else. The input is validated in the first three lines, because nothing upstream did. And the catch-all re-throws as ToolException, because any other Throwable ends the run rather than the turn.

Register it on Laravel

php artisan vendor:publish --tag=phpclaw-config

Then add the FQCN to the tools array in the published config/phpclaw.php — the same array whose 10 shipped entries are commented out at lines 41-52:

<?php
// Pseudocode — verify against your version (confirm the array key name in
// your published config/phpclaw.php).
// config/phpclaw.php

return [
    'tools' => [
        // \PhpClaw\Laravel\Tools\DatabaseTool::class,   // shipped, commented out
        // \PhpClaw\Laravel\Tools\LogTool::class,        // shipped, commented out

        \App\PhpClaw\Tools\StaleDraftsTool::class,       // yours
    ],
];

Register it on Drupal

Drupal picks up external tools from any service tagged phpclaw.tool. No config edit, no factory change:

# modules/custom/my_module/my_module.services.yml
services:
  my_module.phpclaw.stale_drafts:
    class: Drupal\my_module\PhpClaw\StaleDraftsTool
    arguments: ['@database']
    tags:
      - { name: phpclaw.tool }
drush cache:rebuild
drush phpclaw:run "Which drafts have been sitting untouched for over 30 days?"

The same tagged-service pattern is how Drupal exposes every third-party extension point. On the other adapters the mechanism differs — Symfony uses DI config, Magento uses observers, the CMS adapters use their native event or filter systems — but the tool class never changes.

Limitations

Stated plainly, because every one of these will find you eventually.

No argument validation at the core loop. inputSchema() is a hint to the model and nothing more. There is no JSON-Schema validator in the tree. Every tool must validate its own execute() input or it will eventually receive a string where it expected an integer.

Build is not install. wp_zip_plugin and joomla_zip_extension validate and package. Neither calls an installer API; both return an upload instruction for a human. This was verified for WordPress and Joomla specifically — no other adapter has an equivalent packaging tool in its verified tool list, so do not generalise the capability.

Two filters, and they compound. ToolProfileResolver caps by provider and model size; ToolRouter then filters by per-turn relevance against a different per-model table. A tool can be registered, survive the profile cap, and still not be visible to the model on a given turn. Debugging “the model says my tool doesn’t exist” means checking both, in that order.

Only ToolException is recoverable. Every other Throwable from a tool ends the run, not the turn.

No parallel execution. Tool calls from one turn run sequentially. Six slow tools in one turn is six serial round trips.

Laravel starts smaller than the CMS adapters. A fresh Laravel install has 7 tools where a fresh WordPress install has 20. That is a deliberate difference in default trust, not a maturity gap — but if you are comparing adapters on tool count out of the box, compare 13 to 20, not 7 to 20.

Tool names are a flat namespace. Registering a name that already exists silently replaces it. There is no warning.

Approval only exists in a terminal. The approval gate prompts for mutating tool calls on the CLI. Outside a terminal, mutating tools are auto-denied and there is no in-admin approval UI to permit them. See Guard.

Every adapter tool is read-only. Not one adapter tool, on any of the eight platforms, can modify CMS or store content. A write-pattern grep across all nine tool directories returns zero hits. The only write-capable tools in the product are the core sandbox utilities — file_write, file_edit, http_request and zip_package — which write to a filesystem workspace and the system temp directory, never to posts, products, articles, categories, menus, users or orders. If you need writes, you write the tool.

  • Guard — what runs before a tool call and what happens to its output afterwards: the seven default guards, the approval gate, and the tool-output redaction pass that sits outside the guard registry entirely.
  • Providers — the eight provider slugs, the API-key auto-detect cascade, and why choosing ollama changes your tool budget before you have written a line of code.