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.
Install
Section titled “Install”| Requirement | Version |
|---|---|
| PHP | 8.1 or later |
| Drupal | 10 or 11 |
composer require phpclaw/phpclaw-drupaldrush en phpclawEnabling 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.

To update the module later:
composer update phpclaw/phpclaw-drupal --with-dependenciesdrush updb -ydrush crSettings
Section titled “Settings”The settings live in the phpclaw.settings config object. The Settings form has:
| Field | Notes |
|---|---|
| Provider, Model, API Key | the provider to use; the key is not needed for Ollama |
| Base URL | for 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 Prompt | added to every request |
| Store Messages | on by default |
| Max Iterations | the agent loop limit, 1 to 50 in the form; 0 or empty in config uses 20 |
| Remote Skill URLs | comma-separated HTTPS URLs of skill files; the Guide page shows which loaded |
| Cloud Key, Cloud Signing Secret, Disable Cloud Features | configure phpClaw Cloud; cloud is used only while Store Messages is on |
| Fallback Provider | Off by default; lists only providers with the same tool format as Provider, never Custom |
| Fallback Model | empty uses the fallback provider’s default |
| Fallback API Key | hidden when Fallback Provider is Off or Ollama |
| Rate Limit (requests/min) | 0 to 600, default 0 (off) |
| Reuse the answer for an identical request | the response cache; off by default |
| Response Cache TTL (seconds) | shown when the response cache is on; 60 to 86400, default 3600 |
| Max Token Budget | 0 to 10,000,000, default 0 (off) |
| Max Tools Per Turn | default 0, the automatic per-turn limit; -1 sends every tool; below -1 is rejected |
| Always Include Tools | comma-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:
drush config:set phpclaw.settings provider anthropicdrush config:set phpclaw.settings api_key sk-ant-...These keys have no form field. Edit them with drush config:edit phpclaw.settings:
| Key | Default | Notes |
|---|---|---|
shell_allowlist | not set | empty 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.
Permissions
Section titled “Permissions”| Permission | Allows |
|---|---|
use phpclaw chat | the Chat, Analytics, Guide and About pages, the REST routes, and every Drupal tool |
manage all phpclaw conversations | seeing, opening, continuing and deleting every user’s conversations, and counts on Chat and Analytics |
administer phpclaw | the 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.
Where each tool is available
Section titled “Where each tool is available”The adapter builds two agents with different tool sets, and the MCP server builds its own list:
| Where | Tools |
|---|---|
| Chat page and REST API | the Drupal tools and core’s load_skill |
Drush phpclaw:run, phpclaw:chat and the phpclaw.agent service | the Drupal tools plus core’s shell_exec, http_request, file_read, code_search, project_info and load_skill |
| MCP server | the 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.
The Drupal tools
Section titled “The Drupal tools”Eight are always registered:
| Tool | What it reads |
|---|---|
drupal_entity | nodes, 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_config | a value from an allowlisted set of core configuration objects, by name and optional key |
db_query | one read-only SQL SELECT |
drupal_modules | installed modules with status, version and package |
drupal_cron | last cron run, queue sizes and queue workers |
drupal_roles | roles with their permissions and user counts |
drupal_path_aliases | path aliases: list them, find the alias for a path, or search alias text |
drupal_cache | cache bins with approximate row counts; it never flushes |
Seven are registered only when the module they read is installed:
| Tool | Needs module | What it reads |
|---|---|---|
read_log | dblog | recent 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_menus | menu_link_content | menus and their links, with hierarchy |
drupal_media | file | files and media by type, with size and usage |
drupal_views | views | Views with their displays, paths and status |
drupal_blocks | block | block instances with region, plugin, theme, weight and status |
drupal_moderation | content_moderation | moderation states, pending reviews and recent changes |
drupal_webform | webform | webforms 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.
Denying tools
Section titled “Denying tools”tool_deny removes tools by name or group:
| Group | Tools |
|---|---|
group:content | drupal_entity, drupal_moderation, drupal_media, drupal_webform |
group:system | drupal_modules, drupal_cron, drupal_cache, drupal_config |
group:nav | drupal_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.
Approval and PHP files
Section titled “Approval and PHP files”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.
drush phpclaw:run "list active modules"drush pc "show cache bin sizes" --streamdrush phpclaw:run "and which ones are disabled?" --continue --trace
drush phpclaw:chat
drush phpclaw:mcp-server| Command | Alias | Does |
|---|---|---|
phpclaw:run <message> | pc | send a message; --stream prints tokens as they arrive; --conv-id, --continue and --trace as below |
phpclaw:chat | open an interactive session in one conversation; --conv-id, --continue | |
phpclaw:mcp-server | phpclaw-mcp | serve the Drush tool set over stdio; see MCP |
One-shot options and the chat session
Section titled “One-shot options and the chat session”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:
| Command | What it does |
|---|---|
/help | List these commands |
/tools | List the active tools |
/model <name> | Switch the model for this session only |
/provider <slug> | Switch the provider for this session only |
/new | Start a new conversation |
/conv | Print the conversation id |
/quiet | Turn the tool trace off or on |
/clear | Clear the screen |
/exit | Leave; 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.
REST API
Section titled “REST API”| Method | Route | Returns |
|---|---|---|
| POST | /api/phpclaw/send | the reply as JSON |
| POST | /api/phpclaw/chat/stream | Server-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:
drush config:set phpclaw.settings api_auth_providers.2 oauth2drush crThe 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:
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": "..."}:
| Status | When |
|---|---|
| 400 | message is missing, empty or longer than 10,000 characters, or the body is not JSON |
| 403 | the conversation belongs to another user |
| 422 | a guard blocked the message, or the token budget was reached: “Token budget reached for this run.” |
| 429 | the rate limit wait ran out, or the provider answered 429: “Rate limit reached, try again shortly.” |
| 502 | the provider returned an error |
| 500 | anything else failed |
| 503 | the 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.
Conversations and ownership
Section titled “Conversations and ownership”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.
Using the agent in code
Section titled “Using the agent in code”$agent = \Drupal::service('phpclaw.agent');echo $agent->send('Summarise content published today.')->text;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.
Extending
Section titled “Extending”Tag your services to register them:
| Tag | Registers |
|---|---|
phpclaw.tool | a tool |
phpclaw.guard | a guard |
phpclaw.skill | a skill |
phpclaw.provider | a provider |
phpclaw.memory_driver | a memory driver |
phpclaw.hook_listener | a 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 }Events
Section titled “Events”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.