SDCs, Canvas, and the agent skills that build with them
My DrupalCon Rotterdam talk moved from MCP to the CLI. How Canvas Tools and agent skills let a coding agent build Canvas pages through Drush, and what's still rough.
On September 30th, I presented "SDCs, Canvas, and the Agent That Builds With Them" at DrupalCon Rotterdam. The slides are available as a PDF (1.8 MB).
I submitted that title in March. By the time I gave the talk, two parts of it were out of date. "The agent" meant one agent, set up through an agent-specific Markdown file. And that agent was going to reach the Drupal site over MCP. Both changed as I built it. What I demoed was a coding agent in a terminal, running Drush, guided by skills shipped inside the modules it used. Hence the new title.
A week before the conference, I wrote Define the capability once; call it from anywhere, about how I came around on the Tool API once I stopped treating MCP as the center. It ended with what the demo would show: an agent creating an SDC, modeling a Recipe content type, building a content template that binds to the node's fields, and publishing a page, all through drush tool:run. This post covers how that went and what it didn't cover: the skills that tell the agent how to use those tools.
From MCP to the CLI
In March, I planned for the agent to reach the site over MCP. The plan split the work into two parts. The agent writes SDCs on the filesystem, because components are files you commit and review. It reaches the running site over MCP, because pages, templates, and content are stored in a database. Most of my early design effort went into the MCP side: the server, the auth, the tool descriptions.
The split was right. The transport was not. A coding agent already works in a terminal, and that terminal already has Drush pointed at the site. A Drush call is a tool call, with no server to stand up, no transport to configure, and no auth to wire. The agent doesn't need a new protocol. It needs to know how to use the CLI it already has, and that is what skills are for. MCP only matters when the site is not on your machine.
Give agents a way into Canvas
The point of SDCs is that you can write them without being a backend Drupal developer. A component is a folder in a theme's components/ directory with a .component.yml and a Twig file. Its props are JSON Schema, so it is typed. That also makes SDCs a good fit for agents. An agent edits files all day; that half needs nothing new.
The other half is putting those components on a page. A Canvas page is state inside a running site, with no file to edit.
I built Canvas Tools to close that gap. It wraps Canvas page building once, keeps all the coupling to Canvas in two services (CanvasManager and AutoSavePublisher), and exposes 21 operations as Tool API plugins. As that earlier post argued, one #[Tool] plugin serves Drush, MCP, and the AI module.
In the DrupalCon Rotterdam keynote, Dries called the Tool module "one of the most important modules for Drupal developers to watch." He also pointed out that several groups had worked on the same problem and converged on the Tool API. Canvas Tools is one of them. I built it before the keynote, not because of it.
Canvas Tools uses ordinary tool plugins, one class per operation. Dries's demo used the attribute-on-methods approach I'm working on for the Tool API. Either way, you expose a capability you own as tools, and every caller gets it.
composer require 'drupal/canvas_tools:^1.0@beta'
drush pm:enable canvas_tools
# tool:run runs as anonymous unless you pass --uid.
drush tool:run canvas_list_targets --uid=7 --input='{"target_type":"page"}'
drush tool:run canvas_list_components --uid=7
Pages, page templates (page variants), and content templates are all component trees. The same tools drive all three. You pass a target type and an id.
Agents get no special path
Every write goes through Canvas's auto-save store, the same drafts the editor uses. The tools are not a second way into Canvas. They are the editor's own path, called from somewhere else.
Auto-save is keyed by the object being edited, not by who is editing it. That has three useful results:
- There is no back door. The tools check the entity's own access, so
hook_entity_access()and per-entity access modules apply. - An editor left open on a page sees the agent's changes land as it works.
- What the agent builds is an ordinary Canvas draft. Nothing goes live until you publish, and you choose what to publish.
Each plugin declares whether it is destructive, so an agent or a harness knows before it calls:
#[Tool(
id: 'canvas_add_component',
// ...
operation: ToolOperation::Write,
// Writes to a draft. Discard it or publish it; nothing is lost.
destructive: FALSE,
// ...
)]
Eighteen of the 21 tools write to a draft. Three are marked destructive: TRUE: canvas_delete_page, canvas_delete_component, and canvas_discard_auto_save. If you want to gate an agent, gate those three. You don't need to know anything about Canvas to do it.
From one agent file to skills
The tools give an agent typed inputs. They don't tell it which values are valid on this site, or what order to call things in. In March I would have put that in a Markdown file for one agent. What I ended up with is Agent Skills that ship in the modules themselves.
There are two kinds.
The mechanics. The Tool module ships use-drupal-tools, plus create-tool-plugins for people writing their own tools. use-drupal-tools is not about Canvas. It teaches an agent how to call any Tool API operation: find it, read its contract, run it. The main rule is to never copy input names out of an example.
drush tool:search "canvas page" --format=json
drush tool:info canvas_add_component --format=json # input_schema is the contract
drush tool:run canvas_add_component --uid=7 --input='{...}' --json
The workflows. Canvas Tools ships four skills, one per job:
author-sdc-component: write an SDC into the theme, then verify Canvas accepts it.build-content-template: lay out how a bundle renders and bind props to the entity's fields.build-canvas-page: build a standalone landing page and publish it.build-page-template: build the full-page template and make it the site default.
author-sdc-component matters most, because its second step is the one an agent skips. A component is only usable in Canvas if its props schema matches a shape Canvas understands. You find that out after a cache rebuild, not before. So the skill says write, then verify:
drush cache:rebuild
drush tool:run canvas_list_components --uid=7 | grep callout # sdc.<theme>.callout
drush tool:run canvas_get_component_schema --uid=7 \
--input='{"component_id":"sdc.<theme>.callout"}' # props, enums, slots
If the component is missing, or a prop didn't come through, Canvas rejected that shape. The agent fixes the YAML and runs it again instead of assuming it is fine.
These skills live in each module's .agents/skills/ directory. AI Best Practices discovers skills from Composer packages and syncs them into your project's .agents/skills/ on composer install. Require the module, and the agent gets the workflow, not just the tools. And because it is a plain folder, it doesn't matter which agent reads it. Claude Code, Cursor, and opencode all pick it up.
You can write your own alongside the shipped ones. Tool Belt ships the content-modeling tools but no skill, so I wrote a model-content skill in the demo project to create a content type, add fields, and seed nodes.
That was the biggest change between the proposal and the talk. I stopped configuring one agent and started writing skills for the tools.
The demo
The demo started with a design. I used Claude Design to produce a coffee and baking shop: a welcome page, a recipe page, the components behind both, and a handoff document. The handoff listed every component with its props and allowed values, the page trees, and the content fields the recipe page implies. The design side never touched Drupal.
I built it with Claude Code in four steps, pointing it at the handoff document rather than screenshots. The clips in the talk are recordings of real runs. One change for recording: I patched Canvas to check for unpublished changes every 200ms instead of every 10 seconds, so the editor updates quickly on screen.
Author the SDCs. The agent built all eight components from the handoff, then audited them. For the audit, I used the Drush commands proposed in Canvas #3585531, patched into the demo site. canvas:component:status reports the incompatible components and exits non-zero, so the same command can gate CI. Without being asked, the agent also rendered each component with its optional props empty to catch Twig errors, and listed what the design left unspecified.
Model the content. The Recipe content type and fourteen fields, through Tool Belt over the same tool:run surface. Each field is two operations: storage, then instance. No batch operation exists, so the agent wrote a shell loop. Then it created the Stroopwafels sample recipe in one entity_create call.
Build the display. A content template for node.recipe.full. The hero's title, intro, and image bind to the node's fields. Canvas refused two bindings in the design. The difficulty level is an enum prop, and an enum takes a static value, never a field. The callout's required title and body would not bind to optional fields, because an optional field can't promise a value. The agent is on the editor's path, so it inherits the editor's rules. It set static values and kept going.
Build a page. The welcome page used the same operations against a different target. A landing page has no host entity, so every value is a literal from the handoff. Partway through, canvas_get_component_schema failed on components that have no slots. The agent said so, read the .component.yml files directly instead, and kept building. That bug is fixed now in #26: a newer Tool API release started validating tool outputs, and an empty PHP array failed the check for a required map. Nothing changed on the front end until canvas_publish_auto_saves ran.
Then I opened the site with no agent involved. The recipe page rendered through the content template the agent built. The welcome page was a normal Canvas page. Both open in the editor, and a site builder can keep going from there.
MCP is still there
Everything in the demo is also available over MCP, with no extra code. Enable MCP Server and MCP Server Tool Bridge, and Canvas Tools ships an mcp_tool_config entity for each operation. Over MCP, canvas_create_page comes back as tool_api__canvas_create_page.
So the split from March holds, with a different default. SDC work belongs on a developer's machine, because it produces files someone reviews and commits. Page building against a site you have a shell on goes over Drush. Page building against a site you don't have a shell on goes over MCP.
Remote MCP is not a five-minute setup yet. You have to say who is calling. Over HTTP, that means OAuth, and mcp_server_oauth is still in alpha. That is a decision for each team, and plenty of teams won't want that endpoint in production.
What is still rough
Canvas has no public API, so Canvas Tools is tied to Canvas versions on purpose. The Canvas 1.11.0 upgrade proved it: page templates replaced theme regions, and the bridge had to follow. Keeping that coupling in two services is the trade-off. Canvas Tools absorbs each Canvas release, so the sites, scripts, and agents calling the tools don't have to. The alternative was waiting for an API that doesn't exist yet.
An agent can't clear an optional prop yet. A new component keeps its example values, and canvas_update_component_props can only set values, not remove them. In the demo, a heading section kept its example body text on the published page. The agent noticed and asked me to clear it in the editor. That is #27.
The MCP tool configs live in config/optional, which only imports when a module is installed. Tools added in a later release have to be imported by hand. That bit my demo site twice.
The Tool API and MCP Server are both in beta. The Tool API is headed for core, at least in part. The skills will move with it, which is the reason they ship next to the code instead of in my agent's configuration.