Skip to content

phpClaw for Drupal

The Drupal adapter is a module. It adds a chat page and admin pages under Configuration, three Drush commands, two REST routes, three permissions, and up to 15 Drupal tools.


RequirementVersion
PHP8.1 or later
Drupal10 or 11
Terminal window
composer require phpclaw/phpclaw-drupal
drush en phpclaw

Enabling the module creates phpclaw_conversations, phpclaw_messages and phpclaw_memory from hook_schema(). After updating the module, run drush updb for pending updates.

Open Configuration → phpClaw AI Agent → Settings, choose a provider, enter the API key and save. Test Connection checks it.

phpClaw in a Drupal site

To update the module later:

Terminal window
composer update phpclaw/phpclaw-drupal --with-dependencies
drush updb -y
drush cr

The settings live in the phpclaw.settings config object. The Settings form has:

FieldNotes
Provider, Model, API Keythe provider to use; the key is not needed for Ollama
Base URLfor the Custom provider; used only when it is https://, or http:// on localhost, 127.0.0.1 or ::1. The form saves any http(s) URL, and the agent ignores one that breaks this rule
System Promptadded to every request
Store Messageson by default
Max Iterationsthe agent loop limit, 1 to 50 in the form; 0 or empty in config uses 20
Remote Skill URLscomma-separated HTTPS URLs of skill files; the Guide page shows which loaded
Cloud Key, Cloud Signing Secret, Disable Cloud Featuresconfigure phpClaw Cloud; cloud is used only while Store Messages is on
Fallback ProviderOff by default; lists only providers with the same tool format as Provider, never Custom
Fallback Modelempty uses the fallback provider’s default
Fallback API Keyhidden when Fallback Provider is Off or Ollama
Rate Limit (requests/min)0 to 600, default 0 (off)
Reuse the answer for an identical requestthe response cache; off by default
Response Cache TTL (seconds)shown when the response cache is on; 60 to 86400, default 3600
Max Token Budget0 to 10,000,000, default 0 (off)
Max Tools Per Turndefault 0, the automatic per-turn limit; -1 sends every tool; below -1 is rejected
Always Include Toolscomma-separated tool names offered on every turn, outside the limit; a name that is not a tool on the site is skipped, and a denied tool stays denied

Fallback Provider to Max Token Budget switch on the building blocks. The rate limit and the response cache are kept in a Drupal cache bin, so the limit is shared by every request to the site.

The two tool fields apply to Drush, the Chat page and the REST API; the MCP server’s tool list does not use them. Sites installed before these fields get them from drush updb.

Drush writes the same config:

Terminal window
drush config:set phpclaw.settings provider anthropic
drush config:set phpclaw.settings api_key sk-ant-...

These keys have no form field. Edit them with drush config:edit phpclaw.settings:

KeyDefaultNotes
shell_allowlistnot setempty uses core’s default allowlist: ls, pwd, df, wc, date, uptime, hostname, whoami
tool_deny[]tool names or groups to remove
guards[]guard class entries
hooks[]event, handler and priority entries; the handler must be callable
skills[]skill definitions
api_auth_providers[basic_auth, cookie]authentication providers accepted by the REST routes

With Store Messages off, the agent stores no message content. See Memory.


PermissionAllows
use phpclaw chatthe Chat, Analytics, Guide and About pages, the REST routes, and every Drupal tool
manage all phpclaw conversationsseeing, opening, continuing and deleting every user’s conversations, and counts on Chat and Analytics
administer phpclawthe Settings page and Test Connection

All three are marked restricted.

A tool call without the permission returns a FORBIDDEN error. Tools run from Drush, including the MCP server, skip the check.

The phpClaw menu item is attached to the Chat route, so a role with use phpclaw chat but not administer phpclaw still sees the menu. To reach it from the admin menu, the role also needs Drupal’s own Use the administration pages and View the administration theme permissions.


The adapter builds two agents with different tool sets, and the MCP server builds its own list:

WhereTools
Chat page and REST APIthe Drupal tools and core’s load_skill
Drush phpclaw:run, phpclaw:chat and the phpclaw.agent servicethe Drupal tools plus core’s shell_exec, http_request, file_read, code_search, project_info and load_skill
MCP serverthe Drush set without load_skill

So the chat page and the REST API cannot run shell commands, make HTTP requests or touch files.

file_write and file_edit are not registered anywhere: core marks them opt-in. The adapter builds shell_exec and http_request itself for the Drush set and asks core only for its default tools. A module can add one as a phpclaw.tool service (see Extending), with its constructor arguments in the service definition.

Eight are always registered:

ToolWhat it reads
drupal_entitynodes, users and taxonomy terms, filtered by bundle, status and keyword; each row passes Drupal’s own view access check, and the response reports total_before_access_filter
drupal_configa value from an allowlisted set of core configuration objects, by name and optional key
db_queryone read-only SQL SELECT
drupal_modulesinstalled modules with status, version and package
drupal_cronlast cron run, queue sizes and queue workers
drupal_rolesroles with their permissions and user counts
drupal_path_aliasespath aliases: list them, find the alias for a path, or search alias text
drupal_cachecache bins with approximate row counts; it never flushes

Seven are registered only when the module they read is installed:

ToolNeeds moduleWhat it reads
read_logdblogrecent watchdog entries with their type, severity and time, filtered by severity; message bodies only with include_messages: true; never uid, hostname, location, link or referer
drupal_menusmenu_link_contentmenus and their links, with hierarchy
drupal_mediafilefiles and media by type, with size and usage
drupal_viewsviewsViews with their displays, paths and status
drupal_blocksblockblock instances with region, plugin, theme, weight and status
drupal_moderationcontent_moderationmoderation states, pending reviews and recent changes
drupal_webformwebformwebforms with submission counts; submitted values are never returned

db_query refuses users, users_field_data, sessions, key_value, key_value_expire, flood, batch, config, watchdog, node_field_data, taxonomy_term_field_data, file_managed, webform_submission, webform_submission_data, phpclaw_conversations and phpclaw_messages. The refusal is QUERY_NOT_PERMITTED and names only the table. Some of that data is readable through other tools, within their own rules: drupal_entity for users, nodes and terms, read_log for watchdog, and drupal_config for allowlisted configuration.

tool_deny removes tools by name or group:

GroupTools
group:contentdrupal_entity, drupal_moderation, drupal_media, drupal_webform
group:systemdrupal_modules, drupal_cron, drupal_cache, drupal_config
group:navdrupal_menus, drupal_path_aliases, drupal_blocks, drupal_views

group:system here holds Drupal tools only; it does not remove db_query or the core tools. Name those individually.

Every agent is built with CliApprovalGate. In a terminal you are asked before a tool changes something; elsewhere the change is refused. See Security: approval. The MCP server does not use the approval gate; its tools/list marks tools that can change data with readOnlyHint: false, destructiveHint: true.

The gate treats any shell_exec command on the allowlist but outside core’s read-only command list as a change. A tool added as a phpclaw.tool service is also offered to the chat page, where any file_write or file_edit call is refused.

With shell_allowlist empty, shell_exec allows core’s default commands: ls, pwd, df, wc, date, uptime, hostname and whoami. The allowlist matches the first word of the command exactly, case included. Set your own list to change it.


Terminal window
drush phpclaw:run "list active modules"
drush pc "show cache bin sizes" --stream
drush phpclaw:run "and which ones are disabled?" --continue --trace
drush phpclaw:chat
drush phpclaw:mcp-server
CommandAliasDoes
phpclaw:run <message>pcsend a message; --stream prints tokens as they arrive; --conv-id, --continue and --trace as below
phpclaw:chatopen an interactive session in one conversation; --conv-id, --continue
phpclaw:mcp-serverphpclaw-mcpserve the Drush tool set over stdio; see MCP

After each reply phpclaw:run prints the provider, model, tokens, the tools that ran and the conversation id. --conv-id=<id> continues that stored conversation; an id with no stored conversation prints a warning and starts a new one, and another user’s conversation starts a new one with the line Conversation <id> is not available to the console. --continue picks the last conversation, whose id is kept in Drupal’s State API as phpclaw.cli_last_conversation. That is one value per site, so every shell user continues the same conversation, and uninstalling the module deletes it. --trace prints one line per tool call, the tool name plus a short path or command, never the result. A message a guard blocks prints the guard’s reason; any other failure prints one line naming the error type, never the exception message.

drush phpclaw:chat keeps one conversation open, streams each reply and prints the tool calls as they run. It needs a terminal: with piped input, or with -y, it refuses and exits 1. A line starting with / is a command and is never sent to the agent:

CommandWhat it does
/helpList these commands
/toolsList the active tools
/model <name>Switch the model for this session only
/provider <slug>Switch the provider for this session only
/newStart a new conversation
/convPrint the conversation id
/quietTurn the tool trace off or on
/clearClear the screen
/exitLeave; Ctrl+D also leaves

While the agent works, a Thinking... spinner shows until the first word of the reply arrives. It hides before each tool call is traced or approved and shows again while the model works after it. It is not shown when the output is not a terminal, and without the pcntl extension a still Thinking... line is printed instead of the animation.

On leaving it prints drush phpclaw:chat --conv-id=<id> to resume. /model and /provider never write phpclaw.settings; a provider that cannot be built is named and the session keeps the current one.


MethodRouteReturns
POST/api/phpclaw/sendthe reply as JSON
POST/api/phpclaw/chat/streamServer-Sent Events

Both are ordinary Drupal routes: Drupal authenticates the caller with the providers in api_auth_providers and checks use phpclaw chat before phpClaw runs. Both also carry Drupal’s _csrf_request_header_token requirement, so a browser session sends a token from /session/token as X-CSRF-Token. Drupal applies that check only to requests authenticated by a session cookie, so an HTTP Basic request needs no token.

To accept another authentication provider, add its id to the list:

Terminal window
drush config:set phpclaw.settings api_auth_providers.2 oauth2
drush cr

The list is applied to the routes when Drupal rebuilds its router, so the change takes effect after drush cr.

To call them from a script with HTTP Basic, enable Drupal’s basic_auth module and use HTTPS:

Terminal window
drush en basic_auth
curl -u 'USERNAME:PASSWORD' \
-X POST https://example.com/api/phpclaw/send \
-H 'Content-Type: application/json' \
-d '{"message": "how many articles are published?"}'

The body is JSON with message and an optional conversation_id. send returns ok, text, provider, model, iterations, tokens, conversation_id and tool_calls. Its errors return {"ok": false, "error": "..."}:

StatusWhen
400message is missing, empty or longer than 10,000 characters, or the body is not JSON
403the conversation belongs to another user
422a guard blocked the message, or the token budget was reached: “Token budget reached for this run.”
429the rate limit wait ran out, or the provider answered 429: “Rate limit reached, try again shortly.”
502the provider returned an error
500anything else failed
503the agent could not be built, for example with no provider configured

chat/stream sends tool_before, tool_after, chunk, done and error events. Its errors are error events, not JSON. A bad body (400), an agent that cannot be built (503) and another user’s conversation (403) are refused with that status before the stream starts. Once it has started, a guard block, a provider error or any other failure is an error event in a 200 response; only the token budget (422) and rate limit (429) errors also set the status.


Each conversation stores its creator’s Drupal user id in phpclaw_conversations.user_id. Conversations created from Drush store 0. A user sees and opens only their own conversations; opening another user’s returns 403. manage all phpclaw conversations shows, opens and deletes every conversation, including Drush ones. Analytics counts follow the same rule.

The Chat page’s delete button removes a conversation and its messages from the tables, through POST /admin/config/phpclaw/chat/delete; without manage all phpclaw conversations, deleting another user’s conversation returns 403.

A Drush run stores 0 whenever the Drupal console is active, whatever user the command line names. Drush reads only conversations owned by 0: --conv-id or --continue with another user’s conversation starts a new one instead. The rule is enforced in the memory driver, DrupalDbConversationMemory, which throws ConversationAccessDeniedException for another user’s conversation. On a site installed before the owner column existed, phpclaw_update_8004 adds it, so run drush updb; rows without an owner get 0, so like Drush conversations they are visible only to Drush runs and to holders of manage all phpclaw conversations.

The agent always uses the database memory driver: conversations in the conversation tables, everything else in phpclaw_memory.


$agent = \Drupal::service('phpclaw.agent');
echo $agent->send('Summarise content published today.')->text;
mymodule.services.yml
services:
mymodule.reporter:
class: Drupal\mymodule\Reporter
arguments: ['@phpclaw.agent']

phpclaw.agent has the Drush tool set; phpclaw.agent.chat has the chat tool set. The factory behind each returns null when the agent cannot be built, for example with no provider configured.


Tag your services to register them:

TagRegisters
phpclaw.toola tool
phpclaw.guarda guard
phpclaw.skilla skill
phpclaw.providera provider
phpclaw.memory_drivera memory driver
phpclaw.hook_listenera listener; set event on the tag, and the service needs a handle() method
services:
mymodule.inventory_tool:
class: Drupal\mymodule\Tool\InventoryTool
tags:
- { name: phpclaw.tool }
mymodule.audit_listener:
class: Drupal\mymodule\AuditListener
tags:
- { name: phpclaw.hook_listener, event: agent.after }

Every lifecycle event is also dispatched on Drupal’s event dispatcher as phpclaw. followed by the event name. The event object is PhpClaw\Drupal\EventBridge\PhpClawEvent, whose context property holds the event data. The event names are listed on the Hooks page.