webwerkwien / contao-ai-core-bundle
Contao 5 and 6 console commands for agent-driven CRUD operations
Package info
github.com/webwerkwien/contao-ai-core-bundle
Type:contao-bundle
pkg:composer/webwerkwien/contao-ai-core-bundle
Requires
- php: ^8.2
- contao/core-bundle: ^5.3 || ^6.0
Requires (Dev)
- contao/calendar-bundle: ^5.3 || ^6.0
- contao/comments-bundle: ^5.3 || ^6.0
- contao/faq-bundle: ^5.3 || ^6.0
- contao/manager-plugin: ^2.13
- contao/news-bundle: ^5.3 || ^6.0
- contao/newsletter-bundle: ^5.3 || ^6.0
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^10.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- terminal42/contao-changelanguage: ^3.9
Suggests
- terminal42/contao-changelanguage: Translation links: contao:language:link, contao:language:check (v1.4.0) and contao:language:versions (v1.11.0)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.13.0
- v1.12.2
- v1.12.1
- v1.12.0
- v1.11.2
- v1.11.1
- v1.11.0
- v1.10.0
- v1.9.2
- v1.9.1
- v1.9.0
- v1.8.1
- v1.8.0
- v1.7.4
- v1.7.3
- v1.7.2
- v1.7.1
- v1.7.0
- v1.6.1
- v1.6.0
- v1.5.0
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.1
- v1.3.0
- v1.2.0
- v1.1.0
- v1.0.0
- v0.28.0
- v0.27.0
- v0.26.0
- v0.25.0
- v0.24.0
- v0.23.0
- v0.22.0
- v0.21.1
- v0.21.0
- v0.20.0
- v0.19.0
- v0.18.0
- v0.17.0
- v0.16.0
- v0.15.1
- v0.15.0
- v0.14.0
- v0.13.0
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.1
- v0.8.0
- v0.7.0
- v0.6.2
- v0.6.1
- v0.6.0
- v0.5.1
- v0.5.0
- v0.4.1
- v0.4.0
- v0.2.38
- v0.2.37
- v0.2.36
- v0.2.35
- v0.2.34
- v0.2.33
- v0.2.32
- v0.2.31
- v0.2.30
- v0.2.29
- v0.2.28
- v0.2.27
- v0.2.26
- v0.2.25
- v0.2.24
- v0.2.23
- v0.2.22
- v0.2.21
- v0.2.20
- v0.2.19
- v0.2.18
- v0.2.17
- v0.2.16
- v0.2.15
- v0.2.14
- v0.2.13
- v0.2.12
- v0.2.11
- v0.2.10
- v0.2.9
- v0.2.8
- v0.2.7
- v0.2.6
- v0.2.5
- v0.2.4
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
This package is auto-updated.
Last update: 2026-10-25 12:50:01 UTC
README
Contao 5 and 6 bundle that exposes CMS operations as Symfony console commands. The agnostic operator layer for the contao-ai family — programmatic read and write access to pages, articles, news, files, members, and more, without any LLM dependency.
Stable since 1.0. Command names, options and the JSON answers follow Semantic Versioning: within 1.x they are only extended, never changed or removed. A new refusal of a value the back end refuses too counts as a fix, not a break. Read the changelog before updating.
ConpAI — the contao-ai family
ConpAI is the family name of the contao-ai packages. The story is kanpai (乾杯, Japanese for "cheers") — a toast to Contao and AI working together. The P stands for protocol: changes are recorded in Contao's own audit trail instead of bypassing it. The package names stay contao-ai-*.
| Package | What it is | When to use |
|---|---|---|
| contao-ai-core-bundle (this package) | Contao bundle exposing CMS operations as Symfony console commands. | Required as the foundation layer. Install on any Contao site you want to manage via AI. |
| contao-ai-cli | Python CLI — connects to Contao via SSH and runs commands. | For developers and agencies: manage Contao from the terminal or hand control to an AI agent. |
| contao-ai-backend-bundle | Contao backend module — browser-based AI chat interface for any model provider (Anthropic, OpenAI, OpenRouter, Ollama, any OpenAI-compatible service, more via symfony/ai). |
For editors and admins: AI directly inside the Contao backend, no SSH or terminal needed. |
What it does
contao-ai-core-bundle exposes Contao 5 and 6 CMS operations as Symfony console commands. It is the bridge layer between AI agents and the Contao CMS — used via SSH by contao-ai-cli, or called in-process by contao-ai-backend-bundle.
Requirements
- PHP ^8.2
- Contao ^5.3
Installation
composer require "webwerkwien/contao-ai-core-bundle:^1.0"
Available Commands
All commands output JSON and follow a consistent {"status":"ok", ...} / {"status":"error", ...} format. An error that comes from a defect rather than a refusal also names it: "exception": "DriverException" (since v1.3.0) — a refused input has no such field.
One caveat on reading: a
tinyintcolumn comes back astrue/false, never as a number, because Doctrine maps everytinyintto boolean and Contao casts accordingly on read. A record storingrating = 2answers{"rating": true}— the stored value is unharmed, only this reading of it is lossy. For flags (published,hide) that is correct; it bites where a project declared a number astinyint. Do not write such a value back (2returns astrueand would be stored as1); setting a field outright with--set rating=2is unaffected, as it passes a string. Declare numeric fields assmallint(5) unsigned, which is what Contao's own DCA does.
| Area | Commands |
|---|---|
| Pages | contao:page:read contao:page:create contao:page:update contao:page:delete contao:page:publish contao:page:move |
| Articles | contao:article:read contao:article:create contao:article:update contao:article:delete contao:article:move |
| Content elements | contao:content:read contao:content:create contao:content:update contao:content:delete contao:content:move |
| News | contao:news:read contao:news:create contao:news:update contao:news:delete |
| Events | contao:event:read contao:event:create contao:event:update contao:event:delete |
| FAQ | contao:faq:read contao:faq:create contao:faq:update contao:faq:delete |
| Members | contao:member:create contao:member:password contao:member:update contao:member:delete |
| Users | contao:user:update contao:user:delete |
| Files | contao:file:read contao:file:write contao:file:meta contao:file:process contao:file:delete contao:file:move contao:folder:create contao:folder:publish |
| Templates | contao:template:list contao:template:read contao:template:write contao:template:delete |
| Cache | contao:cache:clear (Symfony's cache:clear, logged as in the back end) |
| Comments | contao:comment:delete contao:comment:publish |
| Layout | contao:layout:read |
| Versions | contao:version:list contao:version:read contao:version:create contao:version:restore |
| Translation links (since v1.4.0, with terminal42/contao-changelanguage installed) | contao:language:link contao:language:check contao:language:versions (since v1.11.0) |
| Search | contao:search:query (the site's search index: pages, and never one with noindex) contao:content:find (content elements by their text, since v1.10.0) |
| Schema / Config | contao:dca:schema contao:listing:config contao:listing:data |
| Macros (since v0.2.0) | contao:record:list contao:record:clone |
| Plans (since v1.13.0) | contao:ai:apply (page, article and content writes from one JSON plan, all or nothing) |
Macro commands
contao:record:list <table>— table-agnostic listing with Doctrine-parameterised filters, DCA-validated ORDER BY, pagination, curated default columns per table. Any table with a DCA, also one withouttl_(since v1.9.2; Catalog Manager catalogs never have it). An extension that builds its DCA only in a web request — both Catalog Managers do — shows the console less or nothing.contao:page:create,contao:article:create,contao:content:createtake--after <sibling ID>instead of--pid: the new record goes directly behind the sibling, below its parent, asmove --afterplaces it (since v1.12.0). Without it, behind the last sibling.contao:content:find <text> [--limit] [--offset] [--root]— content elements whose headline, text or HTML contains the text, case-insensitive, each with article, page, root and its language;--rootkeeps one website or language. Searchestl_contentitself, so it works where the search index cannot: a page withnoindexis never indexed (since v1.10.0). With changelanguage installed each hit also names the linked versions of its page and article in the other languages (since v1.11.0).--offsetskips results for the next page whenmoreis true (since v1.12.0).contao:language:versions <table> <id>— the linked language versions of a page, article, news entry, event or FAQ, each with root and language, and the other website roots; when none is linked, a note that a version may exist there without a link (since v1.11.0, with changelanguage).contao:theme:export <id>/contao:theme:import <file> [--confirm]— the theme manager's .cto export and import on the console, Contao's own code; the import previews without--confirmand reads only fromsystem/tmp(since v1.9.0).contao:example-website:import <file> --mode=full|data|data_no_truncate [--admins=both|existing] [--confirm]— an example website as the theme manager imports it, after an automatic database backup; administrators kept by choice (since v1.9.0).contao:listing:data <id> [--limit] [--offset]— the rows a listing module shows, as JSON: the module's own table, fields, condition and sort, run server-side; sensitive fields masked (since v1.9.0).contao:record:clone <table> <id> [--recursive] [--with-recipients]— clone a container record (news archive, calendar, FAQ category, page; since v1.8.0 also form, newsletter channel, module, layout) including all cascading children in one DB transaction. Forms come with their fields, newsletter channels with their newsletters (unsent) and their recipients only with--with-recipients; modules and layouts stay in their theme; since v1.9.0 also a whole theme, with its modules, layouts, image sizes and theme elements, whose references to each other then point at the copies (references out of the theme stay) — a step Contao's back end does not offer: themes are not copyable there, only exported and imported. What Contao's copy does not take along (doNotCopy,unique) is reset, references stay and are listed inreferences. With--recursivePageCloner walks subpage trees (depth-cap 10, total-cap 50); its answer counts pages inpage_count(root included, since v1.9.1) and articles incountandarticle_count. Tagged-iterator EntityCloner registry — only registers cloners for plugins actually installed on the target site. Cloned pages and articles come out unpublished and a cloned root is never the main language (fallback) by itself, as in Contao's own copy (v1.7.4); pass{"published":"1"}or{"fallback":"1"}in--modificationsto change that on the root.
One plan, all or nothing: contao:ai:apply (since v1.13.0)
contao:ai:apply reads a JSON plan from stdin and runs its steps in order, inside one
database transaction. Each step is one of the commands above, run in the same process with
its own checks, callbacks, versions and log entries; apply adds no write path of its own.
If a step fails, every step before it is rolled back and the answer names the failed step.
{"steps": [
{"ref": "shipping", "do": "page create", "title": "Shipping", "pid": 59,
"set": {"published": "1"}},
{"do": "language link", "table": "tl_page", "id": "@shipping", "main": 92},
{"ref": "intro", "do": "content create", "type": "text", "pid": "@shipping.article", "ptable": "tl_article",
"set": {"text": "<p>Orders ship within two days.</p>"}},
{"do": "content update", "id": 771,
"set": {"text": "<a href=\"{{link_url::@shipping}}\">Shipping</a>"}}
]}
- Steps:
page,articleandcontentwithcreate,update,delete,move, andlanguage link(with changelanguage). The other keys are the command's options under their own names (title,pid,ptable,type,alias,language,after,to,table,main,id);setis an object of fields, structured values as JSON objects. @nameis the ID of the earlier step with"ref": "name"(for an update, move or link the record it touched);@name.articleis the article a page create made. It is recognised as a whole value and as the argument of an insert tag ({{link_url::@name}}, up to}}or|), nowhere else: an e-mail address or a mention in a text stays as it is.- Checked before the first write: unknown steps or keys, a reference to a later or unknown
step, a duplicate
ref, more than 500 steps — every problem is listed. Refused as well: a step whose command is not installed, and a database where one of the tables involved cannot roll back (Contao recommends InnoDB; the single commands still work there). --dry-runruns every step and rolls back. Its IDs are provisional: the database counter keeps counting after a rollback, so a real run gets higher IDs.--operatoris handed to every step. After the commit the cache tags of all steps are invalidated once more.
record_rewritelives in contao-ai-backend-bundle, not here. That command needs an LLM API key per call. Keeping core agnostic of LLM dependencies and key handling was a deliberate architecture decision.
Declaring a contract for your own command
If your extension ships a contao:* console command, contao:ai:commands --name=…
already answers its name, description, arguments and options — Symfony knows those.
What it cannot know is what the command does, and an AI agent reaching for it has
to guess. An optional attribute closes that gap.
#[AsCommand(name: 'contao:shop:confirm', description: 'Confirm a pending order')] #[\Webwerkwien\ContaoAiCoreBundle\Attribute\AiContract( writes: true, tables: ['tl_shop_order', 'tl_shop_voucher'], trace: ['tl_log'], traceWhen: 'before', irreversible: 'sends a confirmation mail to the customer', repeatable: false, answerShape: ['status', 'id'], genericPathUnsuitable: ['tl_shop_order' => 'the transitions hang on save_callbacks'], )] class ConfirmOrderCommand extends Command { /* … */ }
And it is what makes your command reachable. contao:ai:run runs commands under
contao: and commands that declare a contract. Keep your own prefix — that is the
convention, and contao: is Contao's property — and declare instead. The prefix says who
wrote a command; only the declaration says its author meant it to be driven this way.
A method may declare its own. A class that exposes several operations — one tool
class with create, update, delete and read behind four methods — puts the attribute on
each method instead. ContractReader::read($class, $method) reads the method first and
falls back to the class, so a console command keeps declaring at class level.
It costs you no dependency. PHP resolves an attribute class only on
newInstance(); this bundle reads the raw arguments and never instantiates. So the
attribute above works whether or not webwerkwien/contao-ai-core-bundle is in your
require, and an installation without this bundle is unaffected. Write the
fully-qualified name as shown and add no dependency.
The answer separates what was checked from what was claimed
A declaration nobody verifies is an assertion, and a test run only ever observes the happy path. So the three are kept apart rather than flattened into one object:
| Block | Meaning |
|---|---|
checked |
held against this installation — the named tables have a DCA here, or they do not |
checked_with_statement |
observable on the happy path, plus a statement about the rest. traceWhen describes the failure path without having to trigger one, and the retention period is read from this site's configuration, never declared |
declared |
irreversible and repeatable can never be verified from outside. They stay the command's own word, and the output says so |
trace: ['tl_log'] and trace: ['tl_version'] are not interchangeable: Contao keeps
them 7 and 90 days respectively. The period belongs to the installation, so the bundle
reads it rather than letting a command claim its own.
It describes a command, not every write path
The presented answer carries a covers field saying so. In a site bundle the riskier
write paths are usually not console commands — a status transition on a DCA
button_callback, a front-end controller handling a POST, a cron job — and none of them
can carry a contract. Collecting every contract on an installation gives a picture of its
commands, not of everything that writes.
What does not belong in it
Business rules. Lead times, seasonal notices, "a voucher covering the full amount makes the payment method unnecessary" — those belong in the command's description. A contract shaped around one consumer's domain stops being a contract.
Malformed entries are reported, not dropped
A field with the wrong type comes back under problems and is left out; the rest of the
contract still stands. A contract that silently loses a field looks complete and is not.
Secrets on the command line (v1.5.0)
contao:ai:run writes the command line it starts to the system log, before the run.
Values of options and --set fields whose name reads like a secret — password, passwd,
pass, secret, token, api key, credential — are blanked there (--set password=***). A name
that does not look like one is declared: secrets: ['pin', 'iban']. Long options only; a
short option (-p …) is not recognised. Where the line cannot be split for sure the way
the command reads it, the arguments are withheld whole instead of guessed.
That keeps the secret out of tl_log and the backups that contain it, nothing more: the
command line still reaches the server as a process argument, and from the back-end chat
the model provider. A command that needs a secret is better off reading it from a file
or the environment than taking it as an option.
Since v1.6.0 the same applies to Symfony's console error log (var/logs): when any
command fails, the line it logs is blanked the same way, and so is a value Symfony's own
message quotes (No arguments expected, got "password=…").
Who acts (v1.7.1)
A command that records who changed something — tl_version, tl_log, its own history —
defines an option --operator that takes a value. contao:ai:run --operator <user> hands
the user on to it: the back-end chat passes the signed-in user, contao-ai-cli ext run --operator the name given there. Without the option the command decides itself (usually
the shell user). Before anything runs, the line is checked for what the command would
actually receive: if that is not exactly this user — another name, an empty value, a
padded one — the run is refused; the same user written in the line passes. The back-end
chat does not let the model set it at all.
Refusals in the console log (v1.7.2)
A command refuses an input by throwing \InvalidArgumentException with the reason —
exactly that class, which contao:ai:run answers as a refusal rather than a defect. In
Symfony's console error log (var/logs) a command with an #[AiContract] that does so is
logged as a warning, Command "…" refused its input, instead of CRITICAL next to real
crashes. Contao's production log only writes from level error on (fingers crossed): a
refusal alone no longer appears there, only together with a real error in the same run.
contao:ai:run still records the run in tl_log beforehand. Any other exception, a
subclass of \InvalidArgumentException (Symfony uses them mostly for programming errors)
and any command without a contract stay CRITICAL, and so does contao:ai:run itself at
-vvv, where it rethrows to show the trace.
Audit trail
Every successful write leaves two traces.
Contao's system log (tl_log), visible in the back end under System > System log:
| Column | Value |
|---|---|
source |
CLI for console and macro-bridge writes, shown as Command line / Kommandozeile in the origin filter — Contao itself only writes BE and FE, so they are filterable on their own. When contao-ai-backend-bundle runs the same command inside a back-end request, Contao labels it BE as usual. |
action |
GENERAL for records, FILES for file, folder and template commands |
username |
the --operator when one is passed, otherwise the shell user ($_SERVER['USER']). Every write command accepts it. |
func |
the command name, e.g. contao:page:update |
text |
command name plus the JSON payload the command returned |
A version (tl_version), written by Contao's own Versions class (since v1.4.0) for
every table whose DCA keeps versions, restorable with contao:version:restore, plus a
tl_undo entry for deletions.
Failed commands are not logged — a rejected --set changed nothing.
Callbacks run as in the back end (v1.4.0)
A write runs the DCA's record callbacks where DC_Table runs them — oncreate,
onsubmit, oncut, ondelete, oncopy — and the save_callbacks of the fields it
writes. A callback that throws refuses the write, and nothing is written. Answers list the
callbacks that ran under callbacks. On the console they see the back-end user
contao-ai, which a migration creates: an admin that cannot log in, hidden and locked in
the back end, in place only while callbacks run. What a callback does with a request it
does not get on the console (POST data, FORM_SUBMIT) it does not do there.
Since v1.5.0 onbeforesubmit too: after the save_callbacks, before the write, on every
save that is not a move. Contao's own check that a stop does not lie before the start
(pages, articles, content elements, members, users) runs there — before v1.5.0 a command
stored it.
Since v1.6.0 what a callback tells the editor through Contao's messages
(Message::addError() …) comes back as messages — [{"type": "error", "text": "…"}],
with a success and with a refusal, where a callback often says why.
Virtual fields (Contao 5.7, 6.0; since v1.6.0). A DCA field without a column of its own
lives in a JSON column (jsonData). It is written and read like any field —
--set playerCaption=…, and content read shows playerCaption, not the JSON column. The
column's other values are kept, as in the back end.
Writing from your own extension (v1.4.0 – v1.6.0)
An extension that ships its own #[AiContract] commands can write through this bundle's
building blocks and get what its commands get — versions, the DCA callbacks, an undo entry
with the cascade, cache invalidation, one transaction per write. They are public API:
changes to them need a major version.
use Webwerkwien\ContaoAiCoreBundle\Service\Cache\CacheTagInvalidator; use Webwerkwien\ContaoAiCoreBundle\Service\Writer\RecordWriterInterface; // autowired in your command (type-hint the class or interface) $cacheTags->reset(); // forget what an earlier command in this request reported $id = $writer->create('tl_acme_item', ['title' => $title], $operator, saveCallbacks: true); $writer->update('tl_acme_item', $id, ['published' => 1], $operator, saveCallbacks: true); $writer->delete('tl_acme_item', $id, $operator, 0); $report = $cacheTags->drainReport(); // ['tags' => [...], 'warnings' => [...]]
RecordWriterInterface::create(),update(),delete()— values in their stored form; tables without a registered model are written row by row. WithsaveCallbacks: truethe fields'save_callbacks run whereDC_Tableruns them (on a create betweenoncreateandonsubmit). The table'sonbeforesubmit_callbacks run on every write that is not a move, flag or not (since v1.5.0); what they return is written. A field your DCA declares without a column — a gross amount the callback stores as net — may be handed over when the table has such callbacks; what is still no column afterwards is refused by name (Not a column of …: amount — no onbeforesubmit_callback took it over). Contao's virtual fields (targetColumn, 5.7/6.0) are written into their JSON column, its other values kept (since v1.6.0).DcaCallbacks— the callbacks one by one (save(),submit(), …),drainReport(), which ones ran, anddrainMessages()(since v1.6.0), the Contao messages they left — answer them asmessageslike this bundle's commands.VersionManager— versions directly, if you need them.CacheTagInvalidator::drainReport()andreset()(since v1.4.1) — what the writes invalidated, to answercacheTags/cacheWarningslike this bundle's commands (only when not empty). Callreset()when your command starts.OptionsResolver::values()andlastError()(since v1.4.2) — the values a field's widget would offer, static or from itsoptions_callback. The writer stores a value without checking it against the options; ask here to refuse what the widget would refuse.nullmeans "cannot say" (unknown field, no options, or the callback threw — thenlastError()says why, e.g. it needs a request): skip the check for that field, don't guess. The values are strings. On the console the callback runs ascontao-ai, the user the write runs as, so a permission-filtered list is not empty there.
$allowed = $options->values('tl_acme_item', 'country', ['type' => 'shop'], $id); if (null !== $allowed && !in_array((string) $value, $allowed, true)) { /* refuse */ }
All of them are services registered under their class name, autowirable by type and
present in every installation; they are private, so not available via $container->get().
The writer checks no permissions; that is your command's part.
Error reports
ErrorReportBuilder turns a Throwable into a report a user can hand to the
maintainer — versions, exception class, our own file and line, tool name and
argument keys. contao-ai-backend-bundle attaches it to failed chat turns;
contao-ai-cli builds its own equivalent for failures that never reach the server.
$report = $builder->build($e, 'backend', ['tool' => 'news_update'], [$apiKey]); $report->toMarkdown(); // summary only — nothing worth protecting $report->toMarkdown(true); // plus the masked exception message $report->toBbCode(true); // same, as vBulletin markup for the forum $report->withoutMessage(); // strip the sensitive half for good
Two properties are worth knowing before you call it:
The summary is an allow-list, not a filtered dump. Fields are named explicitly, so a value cannot arrive because nobody thought to exclude it. That is the one safeguard here that does not depend on anyone behaving well.
The message is the only sensitive half, and it is separate for that reason.
It runs through CredentialMasker, which is a pattern net rather than a
guarantee — pass any literal secret you hold (an API key, a bridge token) as the
fourth argument so it can be struck by value instead of by shape.
Nothing is sent and nothing is stored: the report is a return value. Who may see it, and whether it may be passed on, is the caller's decision — and, downstream of that, the user's.
Tests
vendor/bin/phpunit
Runs in two modes, and the difference is not cosmetic.
Without CONTAO_ROOT the framework is mocked away. Everything that only checks
argument handling and output shape runs; the 17 tests that resolve a model through
Model::findById() are skipped, because that call reaches DcaExtractor and needs
a real Symfony container and a database.
With CONTAO_ROOT pointing at a Contao installation, its kernel is booted and the
whole suite runs:
CONTAO_ROOT=/var/www/example/web vendor/bin/phpunit
The package ships without tests/ (export-ignore), so to run this mode against an
installation, copy tests/ and phpunit.xml.dist somewhere, install PHPUnit there, and
point CONTAO_ROOT at the installation.
With CONTAO_ROOT set the suite runs complete; without it, the tests that resolve
a model are skipped and say so.
License
MIT — see LICENSE.
This software is provided "as is", without warranty of any kind. The authors accept no liability for any damages arising from its use.