Search by

webwerkwien / contao-ai-backend-bundle

webwerkwien

Contao 5.7 and 6 back-end page with an in-browser AI agent for editors and admins, powered by symfony/ai and contao-ai-core-bundle

Package info

github.com/webwerkwien/contao-ai-backend-bundle

Type:contao-bundle

pkg:composer/webwerkwien/contao-ai-backend-bundle

Statistics

Installs: 230

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-10-24 09:14 UTC

README

In-browser AI agent for the Contao 5.7 and 6 backend. Editors and admins chat with an AI agent (any provider symfony/ai supports) that can read and modify Contao content through a curated set of tools — no SSH, no CLI. Plus an HTTPS bridge endpoint that lets contao-ai-cli trigger bulk macro operations from the terminal without switching to the browser.

Stable since 1.0. What stays compatible until 2.0 is listed under Stability: routes, bridge JSON, user fields, tool names, the stream's error kinds and the extension points. symfony/ai is itself pre-1.0; none of its types are part of that list, so its updates stay inside the bundle.

You bring your own model access. Each backend user enters their provider's credentials — an API key, or an endpoint for a self-hosted model (see Providers) — in their profile (System → Users → AI agent). Without a key, the chat module is disabled for that user. The bundle does not ship with a service-level key.

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 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 (this package) 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-backend-bundle is the browser client for AI-powered Contao content management. Authentication, session, CSRF and permissions ride on top of the existing Contao backend — each backend user brings their own API key. A second entry point exposes the macro tools (record_clone, record_rewrite) over HTTPS for the contao-ai-cli bridge workflow.

Requirements

  • PHP ^8.2 with ext-fileinfo (since v1.0.0, for the file check of attachments)
  • Contao 5.7 or 6.x (^5.7 || ^6.0)

symfony/ai needs Symfony 7.3 or later, which rules out Contao 5.3 (Symfony 6.4). Up to v0.9.3 the requirements said ^5.3, but the bundle could never actually be installed there. Contao 5.4 to 5.6 are not supported either; on those, Composer keeps v0.9.3.

Also installed: webwerkwien/contao-ai-core-bundle and symfony/ai-*.

Installation

composer require "webwerkwien/contao-ai-backend-bundle:>=0.1 <2.0"
vendor/bin/contao-console contao:migrate           # adds ai_platform, ai_model, ai_base_url, ai_api_key, ai_cli_token to tl_user

contao-ai-cli bundle install backend does both steps, contao:migrate included since CLI v1.7.0.

The Contao Manager auto-discovers the bundle via the contao-manager-plugin entry.

Per-user setup

In System → Users → (user), these fields appear in the AI agent legend:

Field Required Notes
Platform yes See Providers below.
API key depends Stored in plain text — see Security model. Cloud providers need one; a self-hosted provider takes an endpoint instead.
Endpoint URL depends Administrators only — not editable under Personal data, because it decides where the server sends requests. Only for providers without a fixed endpoint: Ollama, LM Studio, or any OpenAI-compatible service. Empty means "use the provider default".
Model depends Optional for Anthropic and OpenAI: empty means the installation's default, shown as the field's placeholder. Required elsewhere. A model entered here is that user's default in the chat.
CLI bridge token optional Only for bridge clone / bridge rewrite of contao-ai-cli from the terminal — the chat does not need it. Click Generate / Rotate to mint a token. Cleartext is shown once with a Copy token button and, beneath it, the command that completes the setup with this site's address (contao-ai-cli --session <name> bridge configure --url https://… --test, which asks for the token with hidden input); only the password_hash is stored in the database. Delete revokes.

Providers

Installed with the bundle:

Provider Credentials Covers
Anthropic (Claude) API key 27 models
OpenAI (GPT) API key 66 models
OpenRouter API key ~538 models across many vendors, one key
Ollama endpoint, no key models running on your own machine
Generic (OpenAI-compatible) endpoint, key optional any service speaking /v1/chat/completions

To add another, install its package — that is the whole procedure:

composer require "symfony/ai-mistral-platform:^0.14 || ^0.15"

The list in the Platform select is derived from the installed symfony/ai-*-platform packages, read from each bridge's own factory signature. Nothing to register, no code to change. composer suggests lists further ones.

Choosing the model in the chat (v0.20.0)

Below the input, next to the provider, the chat offers the models of the user's provider; the first is preselected, or the one in the user's profile. The server accepts only these — a model name from the browser is checked. Which models are offered is the installation's choice:

# config/config.yaml
contao_ai_backend:
    models:
        anthropic: [claude-sonnet-5-5, claude-haiku-5-5]   # the first is the default
        openai: [gpt-5.5]

Without it, Anthropic offers claude-sonnet-5-5, claude-haiku-5-5, claude-opus-5-5 and OpenAI gpt-5.5, gpt-5.4-mini, and a model in the user's profile is offered and preselected too. With a list, the list is the limit, for the profile as well: a model in a profile that is not on it is neither offered nor run — every editor can fill in that field under personal data, so only a list keeps the installation from paying for a model it did not choose. symfony/ai (up to v0.15) does not list the Claude 5.5 models yet; the bundle adds them to the catalog until it does. Since v0.23.0 the chat asks Claude for up to 8192 output tokens; symfony/ai's own default of 1000 cut long answers and tool calls with long texts. A model name with its own options (claude-sonnet-5-5?max_tokens=16000) is passed as it is.

Grant the AI Chat module under "Allowed modules" to enable the chat entry. The CLI bridge does not require the module mount but still respects the same per-record permission voters.

The chat sits in the "System" group of the back-end menu, at /contao/ai-chat. Its template is @Contao/backend/ai_chat.html.twig and can be overridden in templates/backend/ai_chat.html.twig like any Contao template.

Since v0.14.0 answers are shown as Markdown (headings, lists, tables, code, links) and the chat follows the back end's light and dark scheme. The renderer lives in the template and builds every element as text, so HTML in an answer is never parsed; links open only for http(s) and for paths on this host. An overridden template keeps whatever renderer it was copied with — compare it after an update.

Enter sends a message, Shift+Enter breaks the line (v1.0.0).

Files in the chat

Since v1.0.0 the paperclip next to Send attaches files to a message: up to five, and a message may consist of files alone. The model reads them together with that message.

Kind Extensions How the model gets it
PDF pdf as a document, if the chosen model reads PDF and the provider carries it (below)
Images jpg, jpeg, png, gif, webp as an image, if the chosen model reads images
Plain text txt, md, csv as text, up to 100 000 characters (Windows-1252 is converted to UTF-8)

The limits come from where they are already set:

  • Which types: the file picker offers what Contao's upload types (System → Settings) allow and the chat reads. Whether the chosen model reads it is checked on sending — the model can be switched below the input, so a refusal names the model.
  • PDF needs a connection that carries it: Anthropic, OpenAI and OpenAI Responses (compatible), the connections in symfony/ai that send a document. With any other provider (OpenRouter, the generic OpenAI-compatible connection, Ollama, …) a PDF is refused before the request; images and text work there.
  • How large: Contao's maximum upload size per file, and PHP's post_max_size for the whole request. The web server's own body limit (nginx: client_max_body_size, 1 MB by default) may be lower; the chat then shows the too-large message too, after sending.
  • Content: the file's content has to match its extension (finfo); a renamed file is refused. A text over 100 000 characters is refused with its length, not cut.

A refused file ends the turn with the error kind attachment_refused before the model is asked, with a message that names the file and the reason, and without an error report.

What it does not do: a file is not stored, neither in the file manager nor in the conversation. The history keeps a line Anhang: <name>, so the next message knows a file was there but not what it said; attach it again to ask more. SVG is not read.

The log records name, type and size of every attachment, never its content.

Available tools

Group Tool names
News news_create, news_update, news_delete, news_read
Events event_create, event_update, event_delete, event_read — module calendar
FAQ faq_create, faq_update, faq_delete, faq_read — module faq
Image sizes image_size_create, image_size_update, image_size_delete, image_size_read, and the same four as image_size_item_* for the variants (v0.19.0) — module themes
Page page_create, page_update, page_delete, page_read, page_publish
Article article_create, article_update, article_delete, article_read
Content content_create, content_update, content_delete, content_read, content_find (by its text, with page and language; core-bundle ≥ 1.10.0, v0.20.0; with changelanguage and core-bundle ≥ 1.11.0 also the linked versions of each hit's page and article, v0.21.0; offset for the next page, core-bundle ≥ 1.12.0, v0.22.0)
Meta dca_schema, dca_palette, dca_options (v0.16.0), listing_config, search_query, record_list
Macros record_clone (cascade), record_rewrite (server-side LLM loop)
Extensions ext_list, ext_run — console commands other extensions declare with #[AiContract]
Translation links language_link, language_check — only with terminal42/contao-changelanguage and core-bundle ≥ 1.4.0; language_versions (a record's linked versions in other languages; core-bundle ≥ 1.11.0, v0.21.0)

page_create, article_create and content_create take after (an existing record's ID) instead of pid: the new record goes directly behind it, below the same parent; an article also takes its column unless one is given (core-bundle ≥ 1.12.0, v0.22.0).

Extension commands are offered per user: a command appears when the user's module rights reach every table its contract names; a command that names no tables is admin-only. One whose contract declares something irreversible — an effect the database cannot undo, such as a published package release, a renewed token or a sent mail — does not run in the chat at all, for admins neither (since v0.22.1): ext_list names it under notInChat with the reason, and it remains for the command line (contao-ai-cli ext run). What a command does without declaring it cannot be seen from the chat; the contract is the extension author's word. Package updates, cache, backups and migrations have no chat tool, and the core's own contao: commands are not reachable through ext_run.

Permissions inherit from Contao's existing module rights. Admins see everything. Non-admins only see tools whose backing module they are allowed to use. Every delete asks the user before it runs. The deletes of news, pages, articles and content are additionally admin-only regardless of module membership; deleting an event, an FAQ entry, an image size or a variant is left to Contao's voters. (Until v0.19.1 the event and FAQ tools reached admins only, by mistake.) Per-record checks ask Contao's own data container voters before each call (isGranted('contao_dc.<table>', …), since v0.18.0), with the same create, read, update or delete action the back end builds, for admins too. So the chat follows the back end on page mounts and page rights, news archives, calendars and FAQ categories, allowed page and element types, the page hierarchy, error pages only below a root, articles only on pages with an article column, element nesting, the create and delete rights of the user group, and voters that extensions add. Content elements are accepted below articles, news and events, the parents Contao's voters judge; other parent tables stay with admins. Every write also respects the user's field rights ("Allowed fields" in the user group), exactly as the back-end form does: a field the user may not edit there is refused in the chat too, and when creating a record only a value other than the field's default needs the right (since v0.17.0). A refusal names the right that is missing — on Contao 5.7 often the create/update/delete right of the table (cud), the page rights (chmod) or an allowed page or element type (since v0.19.3). Taking a live page offline asks first, and since v0.19.3 so does taking a live article offline.

CLI bridge — terminal access for admins and agents

Editors use the chat module above. Developers and admins live in the terminal — and switching to a browser for bulk LLM jobs ("translate all news in archive 5", "clone this page tree with all children") is a workflow break.

The bundle exposes a HTTPS endpoint at POST /_ai_cli/macro that the contao-ai-cli Python client (contao-ai-cli bridge ...) calls with a Bearer token. The macro tools (record_clone, record_rewrite) execute server-side with the full voter pipeline + atomic tl_version audit — same code path as the chat module, just a different transport.

Why /_ai_cli/macro and not /contao/...?

The contao_backend firewall would 302-redirect any unauthenticated request to /contao/login before our Bearer auth runs. Routing the bridge outside /contao/* lets it fall through to the frontend (anonymous) firewall, where the controller does its own auth.

Security model

Two things decide how you deploy this. Everything else is implementation detail and lives in the changelog.

⚠️ API keys are stored in plain text in tl_user.ai_api_key — Contao 5 has no field encryption. Treat read access to the database as equivalent to holding every key in it, and grant it accordingly. That includes copies of it:

  • Every database backup holds the keys — contao:backup:create, the backup Contao makes before each migration, any mysqldump.
  • So does the version history of tl_user. A key you replaced stays in the older versions. After changing a key, remove those versions before a backup leaves the server, or treat the old key as known and revoke it.
  • The back end does not show the key (since v0.17.0): the user form shows ******** for a stored key, and Contao leaves the field out of the show view, the version diff, the undo view and "edit multiple"; a duplicated user does not get it. Leave the mask to keep the key, enter a new one to replace it, clear the field to disable the AI module for the user. Saving the form records a new version of the user, with the key in it.

Encrypting the column with a key from the environment was considered and not done: on a server, whoever reads the environment also reads the database credentials, so it would only protect a backup that travels without the files.

What a leaked key costs, and how to cap it. A provider key opens no door to the site; it lets someone use the model on your bill — and, depending on the provider, reach files or assistants stored under that key's project. Use one key per installation, in its own workspace or project with a low spending limit, so a leaked key is capped and revoking it affects only that site. Revoking and replacing a key in the provider's console takes a minute.

⚠️ Content the agent reads can try to instruct it. Tool output is wrapped, truncated and declared as untrusted data, destructive operations need a real user turn, and an injected instruction can never exceed the rights of the user whose session is running — but none of that is isolation. A change made this way stays within that user's rights and is versioned in tl_version under their name, so it is visible and revertible. Be deliberate about pointing the agent at tables that take input from outside the editorial team: form submissions, comments, imported feeds.

Beyond that: the agent acts as the logged-in backend user and re-checks that user's permissions per record and per field ("Allowed fields", since v0.17.0), every delete asks the user first and those of news, pages, articles and content are admin-only, write tools accept only an explicit allow-list of fields, and every write is attributed to the acting user in tl_version and the system log.

Streaming

Chat responses arrive as text/event-stream (SSE-style frames over fetch-ReadableStream, since EventSource cannot POST). Events:

  • start — model id
  • message — content chunk (currently emitted once per response; chunked streaming will be added when the underlying platform bridge supports it)
  • done — successful completion
  • error — kind: access_denied | tool_refused | output_cut | attachment_refused | tool_failed | agent_failed

The error kinds split into two groups, and the split matters:

kind what happened report field
access_denied a permission worked as designed —
tool_refused the command ran, understood the request and declined (e.g. "News-Eintrag 42 nicht gefunden") —
output_cut the model's answer reached its output limit; that step did not run (v0.23.0) —
attachment_refused an attached file was not taken, or the model cannot read it (v1.0.0) —
tool_failed a tool broke ✓
agent_failed the agent broke ✓

The first four are answers and carry no report — offering to report them would train users to send noise and bury the two cases that are genuinely defects. The last two carry report, a ready-to-forward Markdown block.

A request above PHP's post_max_size is answered with HTTP 413 and a JSON message before any event, since PHP drops such a body entirely.

Since v0.12.0 a crash inside a core command is tool_failed rather than tool_refused (with contao-ai-core-bundle v1.3.0 or later, which marks it). Two mistakes of the model go back to the model, which calls again, instead of ending the chat: arguments that do not fit a tool (a missing parameter, a value the tool's types reject) and a tool name that does not exist.

Since v0.15.0 a refusal carries what the DCA callbacks told the editor through Contao's messages (with contao-ai-core-bundle v1.6.0 or later): … — Contao meldet: Fehler: …. A refusing callback often gives its reason only there.

Who sees what is decided server-side, at the moment the event is emitted. An admin receives the full report including the (masked) exception message; anyone else receives the summary with the message stripped, so the guarantee — the raw exception never reaches the client — keeps holding unchanged for the audience it was written for. The browser only expands the block; it never fetches more.

Stability

From 1.0.0 on, the following changes only with a major version:

  • Routes: the chat page /contao/ai-chat, the stream POST /contao/ai-chat/stream and its JSON body (csrfToken, message, model, attachments: [{name, type, data}]), the bridge POST /_ai_cli/macro and its JSON.
  • User fields: ai_platform, ai_model, ai_base_url, ai_api_key, ai_cli_token in tl_user, and the configuration keys under contao_ai_backend.
  • Tools: the tool names in Available tools and their parameters. New tools and new optional parameters come in minor versions.
  • Stream: the event names and the error kinds above. A new kind comes in a minor version only if it carries no report.
  • Extension points: AttachmentConverterInterface and its tag contao_ai_backend.attachment_converter (priority included), ChatAttachment, AttachmentContent, ChatAttachments and AttachmentRefusedException.
  • Template: the name @Contao/backend/ai_chat.html.twig and its blocks. The markup inside a block may change in minor versions; compare an overridden copy after updates.

No symfony/ai type is part of this list. What the chat sends to a provider, prompts included, is not either: it follows the models.

Adding file formats

Another bundle can teach the chat more file types. Implement AttachmentConverterInterface; with autoconfigure on, the tag is set by the interface:

use Webwerkwien\ContaoAiBackendBundle\Attachment\AttachmentContent;
use Webwerkwien\ContaoAiBackendBundle\Attachment\AttachmentConverterInterface;
use Webwerkwien\ContaoAiBackendBundle\Attachment\ChatAttachment;

final class OdtConverter implements AttachmentConverterInterface
{
    public function extensions(): array
    {
        return ['odt'];
    }

    public function acceptsMime(string $extension, string $mime): bool
    {
        return 'application/vnd.oasis.opendocument.text' === $mime;
    }

    public function convert(ChatAttachment $file): array
    {
        return [AttachmentContent::text(\sprintf("Anhang „%s“:\n\n%s", $file->name, $this->plainText($file->bytes)))];
    }
}

The chat offers the extension once Contao's upload types allow it. One converter per extension: the highest tag priority wins, the bundle's own converters have 0. With a priority above 0 a bundle replaces a free converter for the same extension. To refuse a file, throw AttachmentRefusedException('my_key', [$file->name]); the chat shows $GLOBALS['TL_LANG']['ai_chat']['my_key'] from the bundle's own language file, with the parameters for its %s. During the message, tools reach its files through ChatAttachments.

Development

composer install
vendor/bin/phpstan analyse src tests --level=6
vendor/bin/phpunit

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.