Search by

webwerkwien / contao-ai-core-bundle

webwerkwien

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

Statistics

Installs: 475

Dependents: 2

Suggesters: 1

Stars: 1

Open Issues: 0

v1.13.0 2026-10-25 12:06 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 tinyint column comes back as true/false, never as a number, because Doctrine maps every tinyint to boolean and Contao casts accordingly on read. A record storing rating = 2 answers {"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 as tinyint. Do not write such a value back (2 returns as true and would be stored as 1); setting a field outright with --set rating=2 is unaffected, as it passes a string. Declare numeric fields as smallint(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 without tl_ (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:create take --after <sibling ID> instead of --pid: the new record goes directly behind the sibling, below its parent, as move --after places 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; --root keeps one website or language. Searches tl_content itself, so it works where the search index cannot: a page with noindex is 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). --offset skips results for the next page when more is 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 --confirm and reads only from system/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 in references. With --recursive PageCloner walks subpage trees (depth-cap 10, total-cap 50); its answer counts pages in page_count (root included, since v1.9.1) and articles in count and article_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 --modifications to 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, article and content with create, update, delete, move, and language 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); set is an object of fields, structured values as JSON objects.
  • @name is the ID of the earlier step with "ref": "name" (for an update, move or link the record it touched); @name.article is 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-run runs every step and rolls back. Its IDs are provisional: the database counter keeps counting after a rollback, so a real run gets higher IDs.
  • --operator is handed to every step. After the commit the cache tags of all steps are invalidated once more.

record_rewrite lives 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. With saveCallbacks: true the fields' save_callbacks run where DC_Table runs them (on a create between oncreate and onsubmit). The table's onbeforesubmit_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, and drainMessages() (since v1.6.0), the Contao messages they left — answer them as messages like this bundle's commands.
  • VersionManager — versions directly, if you need them.
  • CacheTagInvalidator::drainReport() and reset() (since v1.4.1) — what the writes invalidated, to answer cacheTags/cacheWarnings like this bundle's commands (only when not empty). Call reset() when your command starts.
  • OptionsResolver::values() and lastError() (since v1.4.2) — the values a field's widget would offer, static or from its options_callback. The writer stores a value without checking it against the options; ask here to refuse what the widget would refuse. null means "cannot say" (unknown field, no options, or the callback threw — then lastError() 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 as contao-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.