AI Chat 

Extension key

nr_mcp_agent

Package name

netresearch/nr-mcp-agent

Version

0.20.0

Language

en

Author

Netresearch DTT GmbH

License

This document is published under the GPL-2.0-or-later license.

Rendered

Sun, 25 Oct 2026 10:28:45 +0000


AI Chat integrates a conversational AI assistant into the TYPO3 backend. It runs on the agent runtime of nr-llm and works through the tools nr-llm registers: its builtin set, plus any Model Context Protocol (MCP) server configured there. Backend users manage content through natural language.

Getting started 

Installation 

Install the extension and its requirements.

Configuration 

Configure backend access, tools, and worker operation.

Usage 

Open the backend chat and review proposed actions.

Developer information 

Read about the architecture, agent loop, and testing.


[n] A Netresearch extension 

Developed and maintained by Netresearch DTT GmbH.

Introduction 

What does it do? 

AI Chat adds a backend module to TYPO3 that lets administrators and editors interact with an AI assistant directly from the TYPO3 backend. The module is available under Admin Tools > AI Chat.

Using the tools nr-llm registers, the assistant can read and modify TYPO3 content -- pages, content elements, records -- from natural language instructions. All processing happens server-side via CLI commands, keeping the web server responsive.

AI agent creating a page, adding content, and optimizing SEO in TYPO3

The AI agent creates a page, adds content, optimizes SEO fields, and evaluates the result — all via natural language in the TYPO3 backend.

Key features 

Integrated chat module 

A dedicated backend module under Admin Tools with a modern chat interface. Send messages, view responses, and manage conversations without leaving TYPO3.

Tools from nr-llm 

The chat runs on nr-llm's agent runtime and tool registry: builtin read and write tools, plus any MCP server configured in nr-llm's MCP Servers module.

Conversation history 

Conversations are persisted in the database. Resume previous chats, pin important ones, or let the system auto-archive inactive conversations.

Floating chat panel 

A toolbar button opens a resizable bottom panel that stays visible across all module navigation. Chat while working in the page tree without switching context.

File attachments 

Attach PDF, DOCX, TXT, and XLSX files to your messages. Text is extracted server-side when needed, so all formats work regardless of the LLM provider. Vision-capable providers also accept images -- which ones depends on the provider (PNG, JPEG, GIF and WebP everywhere, HEIC and HEIF on Gemini).

Markdown rendering 

AI responses are rendered as rich Markdown -- headings, lists, code blocks, and tables -- using marked.js with DOMPurify for XSS safety.

Secure by design 

Access is restricted to configured backend user groups. Messages are length-limited, concurrent conversations are capped, and API keys are never exposed to the browser.

Example interactions 

Once a provider is configured in nr-llm, you can ask the assistant to perform tasks like:

  • "Show me all pages under the homepage"
  • "Create a new text content element on page 42 with the heading 'Welcome'"
  • "What content elements exist on page 15?"
  • "Move the news page to be a subpage of 'About Us'"
  • "List all hidden pages in the site"

What it can actually do depends on which tools nr-llm makes available: its builtin set reads the installation and makes bounded editorial writes (new pages and content elements arrive hidden, as drafts), and an MCP server registered under AI > Operation > MCP Servers adds whatever tools that server offers. With every tool switched off, the chat still works as a general-purpose assistant on the configured LLM provider, but cannot see or change TYPO3 content.

Acknowledgments 

This extension builds on the work of others:

nr-llm
The Netresearch LLM abstraction layer for TYPO3 that provides provider-agnostic access to language models.
nr-vault
Secure credential storage for TYPO3, used to protect API keys for LLM providers.

Installation 

Requirements 

  • TYPO3 v13.4 LTS or v14.3 LTS
  • PHP 8.2+
  • EXT:filelist (typo3/cms-filelist) -- the file picker uses its element browser
  • netresearch/nr-llm (^0.41.1) -- LLM abstraction layer

Optional:

Quick start 

  1. Install the extension via Composer (see below).
  2. In nr-llm, create a Task record that configures your LLM provider (e.g. OpenAI, Anthropic). Note the UID.
  3. Go to Admin Tools > Settings > Extension Configuration > nr_mcp_agent and set llmTaskUid to the Task UID from step 2.

The AI Chat module is now available under Admin Tools > AI Chat.

Composer installation 

composer require netresearch/nr-mcp-agent
Copied!

After installation, run the database migrations:

vendor/bin/typo3 database:updateschema
Copied!

Upgrading 

After updating the extension, run the schema update and the upgrade wizards. A release that has to correct stored rows ships a wizard and names it in the changelog:

vendor/bin/typo3 database:updateschema
vendor/bin/typo3 upgrade:list
vendor/bin/typo3 upgrade:run
Copied!

Decorative images in your own templates 

An editor can mark an image as decorative (see Decorative images). The extension makes fluid_styled_content output such an image with an empty alt attribute, so screen readers skip it. It does this with its own copy of the partial Media/Rendering/Image, at lib.contentElement.partialRootPaths.5. That path is meant only for sites whose content rendering is fluid_styled_content's. What to do depends on how the site loads it:

  • fluid_styled_content's static template in a TypoScript record (sys_template, Include static (from extensions)): nothing to do. The path is added right after that static template.
  • fluid_styled_content imported in TypoScript (@import 'EXT:fluid_styled_content/Configuration/TypoScript/setup.typoscript'): import the extension's line right after it:

    @import 'EXT:fluid_styled_content/Configuration/TypoScript/setup.typoscript'
    @import 'EXT:nr_mcp_agent/Configuration/Sets/DecorativeImages/setup.typoscript'
    Copied!
  • fluid_styled_content as a site set: add the set AI Assistant: decorative images in fluid_styled_content (netresearch/nr-mcp-agent-decorative-images) to the site. It depends on typo3/fluid-styled-content and is loaded after it. Without the set the extension does not change the site's output.
  • bootstrap_package or any other content rendering of its own: do not add the set or the import. They would replace the site's own image partial for every image. Add the condition below to your own image partial instead.

A site that includes fluid_styled_content's static template and then a content rendering of its own that redefines lib.contentElement without clearing it first (bootstrap_package 15 through its static template is built like this) also gets the path, and its own image partial is replaced. Remove the path after that rendering's TypoScript and add the condition to its partial:

lib.contentElement.partialRootPaths.5 >
Copied!

A site package with its own image partial at a higher key (such as styles.templates.partialRootPath at 10), and the templates of other extensions, for example a news detail view, are not changed. Add the condition there yourself. f:media replaces an empty alt with the file's alternative text, so render the decorative image with f:image. Give it title="" as well: without it, f:image adds the file's title, and a decorative image carries neither text (W3C technique H67):

<f:if condition="{file.properties.tx_nrmcpagent_decorative}">
    <f:then>
        <f:image image="{file}" width="{dimensions.width}" height="{dimensions.height}" alt="" title="" />
    </f:then>
    <f:else>
        <f:media file="{file}" width="{dimensions.width}" height="{dimensions.height}" alt="{file.alternative}" title="{file.title}" />
    </f:else>
</f:if>
Copied!

{file} is a TYPO3\CMS\Core\Resource\FileReference. In an Extbase template the image is usually an Extbase file reference: read the mark with {image.originalResource.properties.tx_nrmcpagent_decorative} and pass image="{image}".

After uninstalling the extension, its partial is no longer used, and fluid_styled_content outputs a decorative image with its own alt text or, without one, the file list's. Uninstalling does not drop the column tx_nrmcpagent_decorative; the database analyzer in Maintenance removes it and its values.

DDEV development setup 

The project includes a DDEV configuration for local development:

git clone https://github.com/netresearch/t3x-nr-mcp-agent.git
cd t3x-nr-mcp-agent
ddev start
ddev composer install
ddev typo3 database:updateschema
Copied!

The extension is symlinked into the TYPO3 installation automatically via the Composer typo3/cms extra configuration.

Running tests and quality checks:

# All CI checks (PHPStan + CGL + tests)
ddev composer ci

# Individual checks
ddev composer ci:phpstan     # Static analysis + architecture tests
ddev composer ci:cgl         # Code style check
ddev composer ci:tests:unit  # Unit tests only
ddev composer ci:tests       # Unit + functional tests
ddev composer ci:mutation    # Mutation testing (Infection)

# Fix code style
ddev composer fix:cgl
Copied!

Alternatively, use the Docker-based test runner (works without DDEV):

./Build/Scripts/runTests.sh -s unit        # Unit tests
./Build/Scripts/runTests.sh -s phpstan     # PHPStan
./Build/Scripts/runTests.sh -s cgl         # Code style check
./Build/Scripts/runTests.sh -s mutation    # Mutation testing
./Build/Scripts/runTests.sh -s unit -p 8.3 # Specific PHP version
./Build/Scripts/runTests.sh -h             # Show all options
Copied!

Configuration 

All settings are managed via Admin Tools > Settings > Extension Configuration > nr_mcp_agent.

LLM connection 

llmTaskUid

llmTaskUid
Type
int
Default
0

UID of an nr-llm Task record. This Task defines which LLM provider and model to use (e.g. OpenAI GPT-4, Anthropic Claude). Required -- the extension will not work without a valid Task UID.

Create the Task record in the nr-llm backend module first, then enter its UID here.

With groupTaskMapping set, this is the Task of the users no mapping applies to; it can then stay 0 if every user of the chat is in a mapped group.

groupTaskMapping

groupTaskMapping
Type
string
Default
(empty)

A different nr-llm Task per backend user group, written as comma-separated groupUid:taskUid pairs, for example 3:12,5:14. The first pair whose group the user belongs to decides -- the order of the setting, not the order of the user's groups. Membership includes subgroups, so mapping a parent group covers the groups below it. Users in none of the mapped groups use llmTaskUid. A malformed pair is skipped; the others still apply.

The Task decides the provider, the model and the prompts; what the assistant may do is still decided per user by nr-llm, so a mapping never widens a user's permissions. Before each turn the chat checks the Task's Configuration: it must be active, and if it is restricted to backend groups, the user must be in one of them (administrators always are). Otherwise the turn fails and says why. This applies to llmTaskUid too. See ADR-015.

processRunMaxIterations

processRunMaxIterations
Type
int
Default
12

How many model rounds a run of a guided process (a skill marked as a process, such as the SEO tour) may take before it stops. A round is one answer of the model, with or without tool calls. The chat asks nr-llm for this cap on every run that starts or continues a process; nr-llm caps it at its own ceiling of 20. Ordinary chat runs ask for nothing and keep nr-llm's default of 5. A value below 1 uses the default.

A run that reaches the cap says so in the chat, and a tour offers Weiter to go on. See ADR-023.

Processing 

processingStrategy

processingStrategy
Type
string
Default
exec

How chat messages are processed in the background:

exec

Forks a CLI process per request (ai-chat:process). Simple, no extra setup. Best for development and low-traffic sites.

The process runs the typo3 binary from the Composer bin-dir of the project (default vendor/bin), or typo3/sysext/core/bin/typo3 in a classic installation. If the binary is not there, the conversation fails with a message and the path that was checked is written to the TYPO3 log.

worker

Uses a long-running worker process (ai-chat:worker) that polls for new messages. Better for production -- lower latency, no process forking overhead.

A site set to worker must run ai-chat:worker (see below); without it, conversations stay in processing until ai-chat:cleanup marks them failed.

With either strategy, a turn whose process crashes stays locked until ai-chat:cleanup marks it failed after five minutes, so schedule ai-chat:cleanup.

Access control 

allowedGroups

allowedGroups
Type
string
Default
(empty)

Comma-separated list of backend user group UIDs that are allowed to use the AI Chat module. Leave empty to allow all backend users with module access.

Chat panel 

When llmTaskUid is configured, a chat button appears automatically in the TYPO3 backend toolbar (top right). Clicking it opens a floating bottom panel that stays visible across module navigation.

The panel supports four states:

  • Hidden -- Default. Only the toolbar button is visible.
  • Collapsed -- Minimal header bar showing the active conversation title and status.
  • Expanded -- Resizable panel with chat messages, input, and a compact conversation switcher.
  • Maximized -- Full-height panel with a sidebar for conversation management (search, pin, archive).

The panel height and state are persisted per user in the browser's localStorage.

System prompt 

The system prompt sent to the LLM is not configured in the extension configuration itself, but in the nr-llm records:

Configuration record (tx_nrllm_configuration.system_prompt)

The primary system prompt. Set this to define the AI assistant's persona, language, and behavior. Also use this field for instructions on how the tools registered in nr-llm are to be used.

Example:

Du bist ein TYPO3-Assistent.

## Tool-Nutzung
- Vor jeder Aussage über den Seitenbaum erst
  get_pagetree aufrufen, nie aus dem Verlauf raten.
- Datensatzfelder über read_records lesen, nicht aus
  einer früheren Antwort zitieren.
- create_content_element_draft legt ein verstecktes
  Element an; sag danach, wo es liegt und dass es
  noch freigeschaltet werden muss.
Copied!

Name the tools your installation actually has -- AI > Operation > Tools in nr-llm lists them, and AI > Operation > MCP Servers is where an external server's tools come from.

Task record (tx_nrllm_task.prompt_template)
Additional instructions appended after the Configuration prompt. Use this for task-specific context.

When both fields are set, they are combined (separated by a blank line). If neither is set, a locale-based default prompt is used.

Instructions for a conversation (tx_nrmcpagent_conversation.system_prompt)
Set by the user in the chat (the sliders button, see Usage). They are added after the two prompts above, which stay in force: the assistant is told to follow the user's instructions only where they do not contradict the configured ones. The assistant's identity, the site-language rules and the user's own context are added either way.

Every prompt ends with the user's context:

  • Answer language -- the language the user's backend is set to. The assistant answers in it whatever language the message is written in, unless the message explicitly asks for another language.
  • Where the user is -- the open backend module and, when the module shows a page, that page's uid and title. The page is included only if the user may see it; the check runs with the user's page permissions when the turn is processed. It lets "summarise this page" work without naming a uid.

User interface 

maxConversationsPerUser

maxConversationsPerUser
Type
int
Default
50

Maximum number of conversations to keep per user. Set to 0 for unlimited. When the limit is reached, the oldest non-pinned conversations are archived automatically.

autoArchiveDays

autoArchiveDays
Type
int
Default
30

Automatically archive conversations that have been inactive for this many days. Set to 0 to disable auto-archiving.

Auto-archiving runs via the ai-chat:cleanup command.

File attachments 

File attachments are always available — no special provider configuration required. Text is extracted server-side for document formats, so they work with any LLM provider.

Always supported (server-side text extraction):

  • PDF: application/pdf — requires smalot/pdfparser (hard dependency)
  • DOCX: application/vnd.openxmlformats-officedocument.wordprocessingml.document — requires phpoffice/phpword (hard dependency)
  • TXT: text/plain — no dependencies
  • XLSX: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet — requires phpoffice/phpspreadsheet (optional; install via composer require phpoffice/phpspreadsheet:^3.0)

Additionally available for vision-capable providers (Claude 3+, Gemini, GPT-4o, etc.):

  • Images: image/png, image/jpeg, image/webp

When the provider natively handles a document format (e.g. Claude natively processes PDFs via DocumentCapableInterface), the file is sent as binary instead of being extracted. The file picker automatically restricts to formats the active provider can process.

Storage: An uploaded file is a managed TYPO3 file from the moment it arrives: it is written into the default storage, indexed in sys_file and given a sys_file_metadata record, like any other file in the file module. It is then read at LLM call time and sent as Base64-encoded multimodal content, and the assistant is told its sys_file uid and path — so it can reference the attachment from a content element with nr-llm's file tools instead of asking for it to be uploaded again.

Nothing is ever overwritten or deleted. A name already taken in the folder yields report_01.pdf; if the file of that name has identical content, the existing one is returned instead of a copy. Where the user may not write, the upload is refused with 403 rather than failing as a server error — the file mounts and permissions of the logged-in backend user apply throughout.

attachmentFolder

attachmentFolder
Type
string
Default
ai-chat

Folder for chat attachments in the default storage, relative to its root — ai-chat means fileadmin/ai-chat/. A per-user subfolder (<be_user_uid>) is created inside it and is not configurable: it is what keeps one user's attachments out of another's. An empty value falls back to the default rather than writing into the storage root.

Limits:

  • Maximum 5 files per conversation.
  • Maximum file size: 20 MB per file.
  • File count is enforced both in the frontend (before upload) and in the backend API.

Security: Decide deliberately whether the attachment folder is publicly readable, because the two things it is used for pull in opposite directions.

An attachment is material a backend user hands to the assistant, and it is stored under a path that is guessable by name. Where attachments are only ever read by the assistant, deny direct HTTP access to the folder:

# fileadmin/ai-chat/.htaccess
Require all denied
Copied!

Where editors are meant to place an attached image or PDF into a content element — the file is already in FAL, so this needs no second upload — the folder must stay publicly readable, or the reference renders as a broken image in the frontend. Denying access to the folder and referencing files out of it is a contradiction, not a hardened setup. Point attachmentFolder at a folder your editors publish from in that case, and treat what is uploaded through the chat as publishable.

Security 

maxMessageLength

maxMessageLength
Type
int
Default
10000

Maximum length of a single user message in characters. Set to 0 for unlimited (not recommended).

Messages exceeding this limit are rejected with an error.

maxActiveConversationsPerUser

maxActiveConversationsPerUser
Type
int
Default
3

Maximum number of simultaneously active (processing) conversations per user. Prevents a single user from overloading the system. Set to 0 for unlimited.

Choices in guided processes 

A guided process asks its plain choices -- which point first, whether to continue, how to go on when a source is missing -- through nr-llm's tool ask_choice. The chat shows its answers as reply buttons above the input. nr-llm ships ask_choice switched off: enable it under AI > Operation > Tools in nr-llm before a process relies on it. Without it a process cannot ask such a choice and has to put it into its answer instead. ask_choice needs an nr-llm that has it (nr-llm PR 1029).

The answers are the options the process passes, shown as they are. A "go on without" option, such as "Ohne Bild weiter", is worded by the process's skill, not by the chat.

Dashboard 

These settings fill the AI assistant dashboard widgets (see Dashboard widget). The chat's skill catalogue names a skill by nr-llm's <source uid>:<path>, such as 3:seo-optimieren, so the same skill has a different identifier on every installation. A setting names a skill in one of two ways:

  • a bare slug without :, such as seo-optimieren: the catalogue skill whose identifier is <source uid>:seo-optimieren. Where several sources carry the slug, the source with the highest trust level wins (nr-llm's skill trust: first party, verified, community, untrusted), then the one with the lowest source uid;
  • a catalogue identifier, such as 3:seo-optimieren: exactly that skill.

Letters, digits and ._:/- are accepted, starting with a letter or digit and at most 100 characters; any other entry is ignored.

What an editor reads for a skill comes from EXT:nr_mcp_agent/Resources/Private/Language/locallang_dashboard.xlf: skill.<key>.title, skill.<key>.description and, for a frequent task, task.<key>. <key> is the identifier with every character other than a letter, digit, - or _ replaced by _: 3:skills/seo is labelled by skill.3_skills_seo.title. A catalogue identifier without labels of its own uses those of its slug, so 3:seo-optimieren reads skill.seo-optimieren.title. For a skill of your own, add these labels through a language override ($GLOBALS['TYPO3_CONF_VARS']['SYS']['locallangXMLOverride']). An entry without a title is left out rather than shown by its identifier.

An entry is offered only when the chat can start its skill for the user looking at the dashboard: the skill is in the chat's skill catalogue for that user, nr-llm can pass a skill to a run, and the configuration does not require a second person to approve every change. Otherwise the entry is left out, and a widget left with nothing to offer says that nothing is set up yet. A configured skill that does not exist therefore never leads to a link the chat would refuse.

dashboardGuidedSkills

dashboardGuidedSkills
Type
string
Default
seo-optimieren,inhalt-verbessern

The guided tours the Improve a page widget offers, comma-separated, in this order.

dashboardQuickTasks

dashboardQuickTasks
Type
string
Default
(empty)

The entries of the Frequent tasks widget, comma-separated. Append |page to a task that works on one page: the widget then asks for the page first. Without it the entry is a link that starts the task in the chat. Empty by default, so the preset does not repeat the guided tours; the widget then says that no task is set up yet.

A skill that declares page in its frontmatter (see Whether a skill works on a page) decides this itself: page: required asks for the page, optional and none do not, whatever the suffix says. The suffix applies to skills that declare nothing.

Missing alt texts 

The check for images without an alt text (see Decorative images and ADR-026: A Site-Wide Check for Missing Alt Texts) reads every image field of type file in the TCA, so the image fields of a newly installed extension are checked without any setting. These two settings leave fields and tables out.

altTextExcludedTables

altTextExcludedTables
Type
string
Default
(empty)

Tables whose images are not checked, comma-separated, such as records that are never shown in the frontend. Content elements inside such a record are left out with it. By default every table is checked.

altTextExcludedFields

altTextExcludedFields
Type
string
Default
pages.og_image,pages.twitter_image,pages.nav_icon,be_users.avatar,backend_layout.icon

Image fields that are not checked, as table.field, comma-separated: fields whose images the frontend does not output as an image with an alt text. The default leaves out the page's social-media images, the navigation icon (bootstrap_package renders it as an icon without the alt text), the backend user's avatar and the backend layout's icon, which only the backend shows. A value set here replaces the default, so keep the entries you still want.

Worker mode production setup 

For production use with processingStrategy = worker, set up a systemd service to keep the worker running:

# /etc/systemd/system/typo3-ai-chat-worker.service
[Unit]
Description=TYPO3 AI Chat Worker
After=mysql.service

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/html
ExecStart=/var/www/html/vendor/bin/typo3 \
    ai-chat:worker --poll-interval=200
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
Copied!

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable typo3-ai-chat-worker
sudo systemctl start typo3-ai-chat-worker
Copied!

Scheduled cleanup 

Add the cleanup command to your cron or TYPO3 scheduler to handle stuck conversations, auto-archiving, and deletion of old data. A conversation whose worker never finished stays in processing until this command marks it failed after five minutes — without the schedule it can stay there indefinitely:

# Run cleanup daily at 3:00 AM
0 3 * * * /var/www/html/vendor/bin/typo3 \
    ai-chat:cleanup --delete-after-days=90
Copied!

Usage 

Opening the AI Chat module 

Navigate to Admin Tools > AI Chat in the TYPO3 backend. The module is available to all backend users who have access to the Admin Tools section (unless restricted via the allowedGroups setting).

AI Chat backend module

The AI Chat module in the TYPO3 backend.

Sending messages 

  1. Type your message in the input field at the bottom of the chat area.
  2. Press Enter or click the send button.
  3. The message is sent to the server and processing begins in the background.
  4. The interface polls for updates and displays the AI response when ready.

While the assistant is processing, you will see a loading indicator. Processing typically takes a few seconds, depending on the LLM provider and whether tools are invoked.

The assistant may execute several tool calls (e.g. reading page content, then creating a record) before responding. Each tool call iteration is visible in the conversation. Which tools it has comes from nr-llm — its builtin set plus any MCP server registered under AI > Operation > MCP Servers; this extension registers none of its own.

What the chat tells you besides the answer 

  • Nothing was saved in this step. An answer that claims a finished change ("done", "created", "saved") carries this note when the run behind it wrote no record. Check the page before relying on the answer, and ask the assistant again.
  • The AI provider is not set up. Editors see this sentence when the provider cannot be used, for example because its API key is missing. Administrators see the technical message instead, with a button to the LLM providers or tasks module.
  • This step is still waiting for a decision. A message such as "weiter" or "habe alles freigegeben" while an approval card is on screen does not start a new run and does not approve anything: decide on the card. Without a card, the step is decided in AI Tasks; if you cannot open AI Tasks, ask someone who may approve there. If the step was already decided in AI Tasks, the chat says so and names the records the run wrote.

Answering a question in the chat 

A step can stop and ask you something before it goes on, for example which point of a page it should start with. A proposed change is never asked this way: it waits on an approval card. The question appears as the assistant's message, and the possible answers appear as buttons directly above the input field. Press one to answer; the step continues with your answer, and the answer stays in the conversation as your message.

When the question allows an answer in your own words, the input field says Write your own answer and what you send is the answer. Otherwise a message you send ends the question and starts a new request. A question with several fields is shown as a small form with a Send answer button. If the chat cannot show a question, it says so.

When the question changed after it was shown, or your answer could not be taken, the question comes back with the reason above the buttons; answer again.

Approving a step in the chat 

A step that writes data waits for your decision on a card in the chat. The card is headed with what the step does, for example Create page draft or Delete page, followed by what would change: where, the current state, the new state and the consequences. The approve button carries the same name, so it says what pressing it does. Cancel leaves everything as it is. When a guided process proposes a change, two buttons take the place of Cancel and tell the assistant why the change is not taken: Another variant asks for a different proposal, Keep leaves the current content as it is and closes the open point, Skip moves on without this change and keeps it open; your choice stays in the conversation as your message. In a guided process, a message of your own while a proposal or a question waits cancels that step. The name is the first line of the step's preview when nr-llm marks that line as naming the change; otherwise it is the tool's editor action label, and without one the step is headed Planned step and approved with Carry out step. A turn with several steps is approved as a whole with Carry out all steps.

Administrators and users of AI > AI Tasks decide every approval. An editor whose backend group holds Approve own changes in the chat (under Custom module options) decides the approvals of their own conversations, and may approve a change only when the card shows its preview — the preview is built with the editor's own rights. Where the AI configuration requires a second approver, the card offers only the denial and a colleague approves the step in AI Tasks.

The tool's internal name, its arguments and the record identifiers are under Show technical details, which is closed until you open it.

The assistant also knows which tools exist but are not available to you, and why — switched off, for administrators only, or not part of this chat's configuration — so it can tell you to ask an administrator instead of claiming that nothing can do what you asked.

AI response rendered as Markdown

AI responses are rendered as rich Markdown — headings, lists, code blocks, and tables.

Editing a message 

Every message you wrote as text has an Edit control below it while no answer is being generated. Change the text and choose Save and run again: the message is replaced, everything after it is removed, and the assistant answers the new wording. An attachment of the edited message stays attached. Escape leaves the editor without changes, Ctrl+Enter saves.

What the assistant knows about you 

  • The page you are on. When you send a message while a page module (Page, List, ...) shows a page, the assistant is told that page's uid and title and which module is open. "Summarise this page" therefore works without naming a uid. A page you may not see is never passed on.
  • Your backend language. The assistant answers in the language your backend is set to (User settings > Language), even when you write in another one. Ask for a different language in the message ("answer in English") and it uses that instead.

What the assistant is doing 

The Activity button (a pulse line in the floating panel, Activity in the module) lists the steps of the current turn while it runs: every call to the model and every tool the assistant uses, with its duration, a failed tool call marked as such, and — when a step needs your approval — which tool is waiting. After you decide, your decision and the steps that follow it are added to the same list. The list starts over with your next message. It shows the names of the tools, not their arguments or results; those are in the run's timeline under AI > AI Tasks.

In the expanded panel the list sits above the conversation; maximized, and in the module, it is a sidebar on the right.

Starting with a page and a skill 

A link into the chat module can start a conversation about one page: &pageUid=<page>, optionally &languageUid=<language> and &skill=<skill>. The chat checks that you may open the page and edit the language, opens the new conversation and replaces the parameters with the conversation's own, so reloading opens it again.

Typing / at the start of the input lists the skills available in the chat. Pick one with the arrow keys and Enter, or with the mouse; nothing is sent. The conversation's skill is shown above the input, and the × beside it removes it; in a guided process about a page this ends the process (see below). Every later answer in the conversation uses it.

When the chat's AI configuration needs a second person to approve every change, no skill can be started, and the chat says so: a guided process is decided on the chat's own approval card, where you cannot release your own change.

Whether a skill works on a page 

A skill states in its frontmatter whether it works on a page (the page key of nr-llm, ADR-025: The Skill Says Whether It Needs a Page):

page: required
The conversation is about one page. Without one, the chat asks for it: for a guided process, choose the page in the page tree and confirm it with Diese Seite prüfen; for any other skill, choose the page and then write what to do -- your message carries the page. Once set, the page stays, whatever page the page module shows later; Zur neuen Seite wechseln opens a new conversation on the other page.
page: optional
The chat passes along the page the page module shows with each message, when there is one; it does not ask for one.
page: none
The conversation is about no page. The chat does not ask for one, does not pass one along and does not keep one, even when the conversation is started with a page.

A skill without the key behaves as before: a guided process as required, any other skill as optional. The Frequent tasks widget follows the same declaration (dashboardQuickTasks).

Instructions for a conversation 

The Instructions button (sliders icon in the floating panel) opens a field for instructions that apply to this conversation only -- for example "answer in bullet points" or "you are reviewing the press section". They are added to the instructions an administrator configured for the chat, which stay in force: where the two contradict each other, the configured ones win. Your instructions take effect with the next message. The assistant's identity, its tools and permissions, and the language rules stay the same. The button is highlighted while a conversation has instructions; empty the field and save to remove them. Instructions cannot be changed while an answer is being generated.

Guided processes 

A skill can walk you through a page one point at a time. While it does:

  • The header shows where it stands -- for example "Über uns · Deutsch · Punkt 2 von 5": the page and its language from the conversation, then the point. When space is short the page name is shortened first; the point is always shown in full. Before the first point the header says "Analyse läuft". It changes when the assistant stops to ask you or to propose a change, not while it works.
  • The element is highlighted. With the floating panel open next to the page module, the content element the current point is about gets a frame and the label "Betrifft den aktuellen Vorschlag", and is scrolled into view; a screen reader hears which element is marked. The full-page chat module and the popped-out panel have no page module beside them and only show the progress.
  • Each proposal shows its outcome. A proposal is shown as a block with what is affected and, where nr-llm provides it, the current and the proposed value with its length. After you answer "Übernehmen", the chat says itself whether the change was saved, saved but worth checking, or not saved. At the end it summarises what was applied, what to check and what you skipped.
  • The end is shown. When the process is finished, the header says so and the highlight goes away.
  • At the end you choose what follows. Fertig ends the process; where the dashboard is installed, the chat links to it. Where the process can be started anew (see below), Andere Seite wählen starts it in a new conversation that asks for a page.
  • Another page in the page module is noticed. When the page module beside the floating panel shows another page than the process, the chat says "Andere Seite ausgewählt". Bei „<Seite>“ bleiben keeps the process and does not ask again for that page; where the process can be started anew, Zur neuen Seite wechseln ends it and starts it on the new page.
  • You can end it at any point. The × beside the progress ends the process, and so does removing the conversation's skill. A proposal still waiting for your answer is withdrawn and changes nothing; changes already applied stay, and so do the outcomes shown in the chat and the open points. The highlight goes away and the input gets the focus. While the assistant works or a decision is being carried out, the process cannot be ended.
  • A decision that cannot be taken right now comes back. When nr-llm cannot check the process just then, the proposal stays and the chat asks you to try again. When the AI configuration requires a second person to approve every change, nr-llm ends the process at the first decision: the chat says so, the proposal is not applied, and the conversation can go on without the process.
  • Skipped proposals stay open. A change you answer with Skip is kept as an open point of that page: the record and field it was about, never its text. The next time the process runs on the page, it is offered again. It stays open until a change to the same record and field is applied in full, in any conversation; a change that was only partly applied leaves it open. A proposal you leave behind with a message of your own is not kept.
  • Kept content closes the open point. A change you answer with Keep is not offered again: the open points on that record and field are closed, and the tour summary lists the proposal under Kept.

A process started for a page -- from the dashboard's "Eine Seite verbessern", or by Zur neuen Seite wechseln -- begins at once: the chat sends Diese Seite prüfen for you, and you do not need to type anything. The process stays on that page; to work on another one, start it there. A process started without a page asks for one: choose the page in the page tree, then confirm it with Diese Seite prüfen in the floating panel. This page choice, Andere Seite wählen and Zur neuen Seite wechseln are offered only where nr-llm can start a run with an invocation of the process; until it can, they stay hidden.

None of this changes a record. An administrator can switch the tools behind it off as the tool group nr_mcp_agent.

Conversation management 

The sidebar shows your conversation history. Each conversation has a title that is auto-generated from the first message.

Starting a new conversation 

Click the New conversation button to start a fresh chat. The previous conversation remains in the sidebar for later access.

Resuming a conversation 

Click any conversation in the sidebar to resume it. The full message history is loaded, and you can continue where you left off.

Pinning conversations 

Pin important conversations to prevent them from being auto-archived. Pinned conversations appear at the top of the sidebar list.

Archiving conversations 

Archive conversations you no longer need actively. Archived conversations are hidden from the default sidebar view but can still be accessed.

Conversations are also auto-archived after a configurable period of inactivity (default: 30 days).

Exporting a conversation 

The Export button (a downward arrow in the floating panel) downloads the open conversation as a Markdown file named after its title and the date. The file holds what the chat shows as the conversation: your messages and the assistant's answers with their time, and the name of any attached file. Tool results and system notices are left out. The file is built in the browser from the conversation already on screen; nothing is sent to the server for it.

Attaching files 

A + button appears to the left of the input field whenever file attachments are available.

  1. Click + to open the attachment menu.
  2. Select Upload file to open a file picker and choose a file from your computer.
  3. The selected file is uploaded immediately and shown as a badge above the input field (file name and size).
  4. Type your message and send — the file is included in the request.

To remove a pending attachment before sending, click the × on the file badge.

File attachment badge above the chat input

A selected file is shown as a badge above the input field.

Supported file types:

The following document formats are always available. Text is extracted server-side before sending to the LLM:

  • PDF (application/pdf)
  • DOCX (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
  • TXT (text/plain)
  • XLSX (application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) -- requires phpoffice/phpspreadsheet to be installed

Vision-capable providers (Claude, Gemini, GPT-4o, etc.) additionally accept images:

  • PNG, JPEG, WebP

When the provider natively supports a document format (e.g. Claude natively handles PDFs), the file is sent as-is instead of being extracted. The file picker automatically restricts to the formats supported by the active provider.

Limits:

  • Maximum 5 files per conversation.
  • Maximum file size: 20 MB per file.

If a file is not accepted (wrong type, too large, or upload error), an error message is shown above the input. A file the logged-in user may not store in the attachment folder is refused as well — the file mounts and permissions that apply in the file module apply here too.

What happens to an attached file: it is not a throwaway copy. The upload puts it straight into the file storage — fileadmin/ai-chat/ by default, see attachmentFolder — where it is indexed like any other file, and the assistant is told its file uid and path. So you can ask it to reference the attachment from a content element in the same breath as you attach it, without uploading the file a second time through the file module, and you can ask it to describe the image for the alternative text. Which of those it can actually carry out depends on the file tools enabled in nr-llm, and every write waits for your approval. Placing a file in a different folder afterwards is not something the assistant can do — there is no tool for moving a file — so choose the attachment folder to suit where those files belong.

Nothing is overwritten: a name already taken produces photo_01.jpg, and re-attaching a file that is byte-identical to the one already there reuses it instead of making a copy.

Floating chat panel 

A chat button in the TYPO3 toolbar (top right, next to the search and user menu) opens a floating bottom panel. The panel stays visible across all module navigation -- you can chat with the AI while working in the page tree, list module, or any other backend module.

Chat toolbar button in the TYPO3 backend header

The chat button in the TYPO3 toolbar. The badge shows the number of active (processing) conversations.

The panel has four states:

  • Hidden -- Only the toolbar button is visible.
  • Collapsed -- A minimal bar at the bottom showing the active conversation title.
  • Expanded -- Resizable panel with the full chat interface.
  • Maximized -- Full-height with conversation sidebar.
Floating chat panel in expanded state

The floating panel in expanded state, overlaying the TYPO3 backend. Drag the top edge to resize.

In the expanded state the conversations sit in one row of tabs above the chat. The row holds four of them: pinned conversations first, then the most recently active ones, and always the one that is open. The others are behind More (n) at the end of the row, which opens a list with a search field: type to filter by title, move with the arrow keys, open one with Enter, close the list with Escape. However many conversations you keep, the row stays one line high and the chat keeps its space. Maximized, the panel lists every conversation in the sidebar instead.

Panel height and state are stored in localStorage per user.

Chat as a column 

The Column mode button in the panel's header docks the chat as a column in the backend's content area: in modules with a page tree it sits between the tree and the module, in modules without one (the Dashboard, for example) it is the leftmost column. The column stays when you switch modules, and the module beside it gets the remaining width. The same button in the column's header turns it back into the floating panel.

  • Width -- Drag the edge between the column and the module, or focus it with Tab and use Left and Right to step, Home and End for the narrowest and the widest width. The column is at least 320 px and at most half the content area wide.
  • Selected page -- The chat follows the page selected in the page tree, as soon as you click it. In a module that is not about a page the chat sends no page.
  • Minimize -- The column becomes a small bar in the bottom right corner with the chat's title and state, for example Waiting for your approval when a change waits for you. Its first button opens the column again.

Column mode and width are stored in your backend user settings, so they follow you to other browsers. Where the backend has no content area for the column (for example without access to any module), the chat stays a floating panel. While the content area is narrower than 640 px (twice the column's minimum), for example in a small window, the chat floats as well and docks again once there is room; your choice stays stored, and the Column mode button is not offered meanwhile. While the chat is sending a message or a decision, the Column mode and Open in a separate window buttons are disabled. The module menu is not collapsed automatically.

Dashboard widget 

With the TYPO3 dashboard installed, a Recent chats widget can be added to a dashboard (Add widget > General). It lists your five chats with the newest last message, newest first; a chat without any message yet is not listed. Each entry shows:

  • the title: the name of the guided tour for a guided process, otherwise the chat's own title;
  • below it, in grey, for a guided process: the page it works on (Page “<title>”) and Guided tour. Any other chat has no such line: it only remembers the page you had open when you last wrote, which is not what the chat is about;
  • for a guided process that is at a point, has not finished and still has its page, that point (Point 3 of 5), as the chat's header shows it;
  • when the last message was written: Today, 10:42, Yesterday, 16:05, then the date (Oct 3), with the year once it is more than a year ago, in your backend language.

A click opens the chat in the chat panel next to the dashboard, floating or as a column, as you last used it; a guided process also opens its page in the page module, in its language. A guided process continues where it stopped: a proposal waiting for your answer is shown again. New chat starts a chat there, and Show all chats goes to the chat module. Each entry is one link, reachable with Tab; a screen reader reads its title, context, progress and time.

A guided process on a page you may not see is not listed. A guided process whose page has been deleted since is listed as Deleted page; it opens read-only, with a notice where the input was: it can be read, but no message can be sent or edited, and no question answered, proposal decided or run resumed. End tour in the notice (or the × beside the progress) ends the tour, which withdraws a run still waiting for your answer; the chat then goes on as an ordinary chat, and a guided tour picked in it again asks for a page. A page deleted only in a workspace does not count as deleted.

For a user the chat is not available to, the widget says so instead of listing anything. Which groups may place the widget at all is set per backend group under Dashboard widgets, as for every other widget.

AI assistant dashboard 

Editors can put together a dashboard for working with the assistant from the widgets in the AI Assistant group (Add widget > AI Assistant), or start from the AI Assistant preset when creating a dashboard, which holds all of them and the Recent chats widget, in this order: AI Assistant, Improve a page, Frequent tasks, Recent chats, Recommended for your website.

AI Assistant
A question of your own, without choosing a tour or a page first: type it under What would you like to do? and press Enter or the round send button; Shift+Enter starts a new line. The chat opens as a column beside the dashboard (floating where the window is too narrow for a column, see Chat as a column) on a new conversation, and your question is its first message, as you typed it; the assistant answers from there. The column is for this opening only: your chosen chat layout is not changed, and the chat returns to it once you close it. A chat you popped out into its own window stays there and gets the question there. The send button can be pressed only while there is a question. Where the chat cannot take the question -- while it is still sending another message, or when the question is longer than the configured maximum -- the question stays in the widget and the widget says why. A question about a page while no page is selected makes the assistant ask which page you mean.
Improve a page
Choose a page, then press the button of a guided tour (by default Optimise SEO and Improve content): the chat module opens and the assistant starts that tour on that page, in its default language. The page choice lists Recommended pages first, each with its reason, then the most recently changed pages. Recommended are, for every tour, the pages with open points of that tour, and for the SEO tour also the pages without a meta description, with a title shorter than 30 characters, or without a social-media image (the SEO title and the social-media image are checked only with EXT:seo installed) — at most five, worked out from the stored pages, not by the assistant. Every page offered is one whose content the editor may edit: in their web mounts, with the edit content permission, not locked for editing, and in a language the editor may edit. Folders, links, shortcuts, spacers and translations are left out.
Frequent tasks
Shortcuts into the chat, set by dashboardQuickTasks (none by default). A task that works on one page asks for the page first, from the same pages as Improve a page; any other task is a link.
Recommended for your website
Open points: changes a guided tour proposed that an editor skipped, until a change to the same record and field is applied. Each comes with a link that starts the matching tour on its page, in the language the point is about. A point is shown only when the editor may access its page, read the changed record and field, and edit that language; a point whose skill no longer exists is not shown. Until open points are recorded, the widget says how they come about.

Only tours and tasks the chat can actually start are offered: a configured skill that does not exist (yet), is not attached to the chat, or cannot run on this installation is left out, and a widget left empty says that nothing is set up yet. For a user the chat is not available to, each widget says so instead. Offering a page is not permission to change it: what the assistant does on the page is checked again when it does it.

Decorative images 

Not every image needs an alt text. A purely decorative image, such as a pattern, an ornament or a divider, is output with an empty alt text, and screen readers skip it. Mark such an image with Decorative image: the checkbox sits next to Alternative text where an image is added to a content element or a record (for example a news article), not in the file list, because whether an image is decorative depends on where it is used. Each language version of an image can be marked on its own.

A decorative image is output with an empty alt text even if it has an alt text of its own or one from the file list, in the standard content elements of fluid_styled_content. Depending on how the site loads fluid_styled_content this needs a set or an import, and other templates need the condition; both are in Decorative images in your own templates. Replacing the image creates a new use, which is not marked.

Decorative image is an exclude field, like Alternative text: editors see it once their backend group is granted it under Allowed excludefields (File Reference: Decorative image).

Error handling 

If a conversation fails (e.g. due to an LLM provider error or timeout), an error message is displayed. You can retry by sending a new message in the same conversation -- the system will attempt to resume processing.

Stuck conversations (processing for more than 5 minutes) are automatically marked as failed by the cleanup command.

Developer information 

Architecture 

System overview 

Browser (Backend Module)
    |
    | AJAX (poll + send)
    v
ChatApiController
    |
    | enqueue message
    v
ConversationRepository  <----->  Database
    |                         (tx_nrmcpagent_conversation)
    |
    v
ChatProcessor (exec or worker)
    |
    | fork CLI / dequeue
    v
ProcessChatCommand / ChatWorkerCommand
    |
    v
ChatService
    |
    | resolve Task --> Configuration (nr-llm DB)
    | build system prompt + transcript
    v
nr-llm AgentRuntime::run(configuration, messages, beUserUid)
    |
    |--- LLM Provider (OpenAI, Anthropic, ...)
    |
    |--- nr-llm ToolRegistry (builtin backend tools)
             |
             v
        Logs, exceptions, system status, records,
        page content, ... (RBAC + tool gate enforced)
Copied!

The frontend (a Lit web component) communicates with the backend exclusively through polling. There are no WebSocket or Server-Sent Events connections.

The AI Chat is accessible in two ways:

  • Backend module (Admin Tools > AI Chat) -- Full-page chat interface for longer conversations and history management.
  • Toolbar panel -- Floating bottom panel triggered by the toolbar button. Stays visible across module navigation, allowing users to chat while working in the page tree.

Key design decisions 

Polling over SSE 

The chat UI uses periodic AJAX polling instead of Server-Sent Events (SSE) or WebSockets. This was chosen because:

  • It works reliably behind reverse proxies and load balancers without special configuration.
  • TYPO3 backend requests go through the standard middleware stack, ensuring authentication and CSRF protection.
  • The polling interval is short enough (1-2 seconds) to feel responsive.

CLI processing over HTTP 

Message processing happens in CLI context (ai-chat:process or ai-chat:worker), not in the web request. This design:

  • Avoids PHP timeout issues -- the LLM calls and tool execution in the agent run can take many seconds.
  • Keeps the web server responsive -- no long-running HTTP connections.
  • Allows the worker mode to reuse a single process for multiple requests, reducing overhead.

Crash recovery 

The system is designed to handle crashes gracefully:

  • Every state transition is persisted to the database immediately.
  • If a CLI process crashes mid-conversation, the conversation remains in processing, locked, or tool_loop status.
  • The ai-chat:cleanup command detects conversations stuck for more than 5 minutes and marks them as failed.
  • Users see a clear error message and can retry.

Domain model 

Conversation 

The central entity. Stored in tx_nrmcpagent_conversation.

Fields:

be_user
UID of the owning backend user.
title
Auto-generated title from the first message.
messages

Legacy: the transcript as one JSON array, as releases before NEXT-172 stored it. Read only while a conversation has no rows in tx_nrmcpagent_message; written empty on every save and emptied by the upgrade wizard nrMcpAgent_migrateMessagesToTable (ADR-016). The model still exposes the transcript as one list (getDecodedMessages()); ConversationRepository fills it from the message rows and writes it back to them, in the same transaction as the conversation row.

User messages with file attachments contain additional fields:

{
    "role": "user",
    "content": "What is in this image?",
    "fileUid": 42,
    "fileName": "photo.jpg",
    "fileMimeType": "image/jpeg"
}
Copied!

The fileUid is a TYPO3 FAL UID. ChatService::buildLlmMessages() reads the file and converts it to a multimodal content array before passing messages to the LLM.

message_count
Denormalized count for display without decoding.
status
Current processing state (see below).
current_request_id
Identifier for the active processing request. Used for worker dequeue locking.
system_prompt
Optional custom system prompt override (per conversation), set by the user through /ai-chat/conversations/system-prompt.
activity
JSON list of step summaries of the current turn -- `{"kind": "llm"|"tool"|"approval", "round", "ms", "tool", "error", "approved"}`, at most 100. Written column-only by RunActivityRecorder, which is the onStep callback of AgentRuntime::run() and approve(); never part of Conversation::toRow(), so the turn's final full-row write cannot put back the list it started with. Returned by getMessages on both the full and the fast poll path. Tool arguments and results are not stored here; nr-llm's run record keeps them.
view_context
JSON {"pageId": int, "module": string}: the page the module frame showed and the open module when the user last sent or edited a message. Kept on the row, not on the message, because stored messages go to the provider as they are. The page is re-checked against the user's permissions when the prompt is built (UserContextPrompt).

System prompt priority 

The system prompt is composed in this order:

  1. Identity / behaviour contract -- Always prepended. A fixed block establishes that the assistant is the Netresearch TYPO3 Backend AI Chat, steers it to use its tools instead of asking the user to paste data, forbids it from claiming to be ChatGPT/OpenAI, and defers the answer language to the user context (step 5). This holds regardless of how the Task/Configuration prompt is set.
  2. nr-llm Configuration + Task prompts -- The system_prompt from the nr-llm Configuration record and the prompt_template from the Task record, combined (separated by a blank line). Configure these in the TYPO3 backend to provide tool usage instructions or persona definitions. Always included.
  3. Site-language context -- appended in every case.
  4. User context (UserContextPrompt) -- appended in every case: the answer language (the backend user's lang; a language the message explicitly asks for wins), the open module and the selected page with uid and title, the page only if the user may show it.
  5. Conversation-level prompt -- If a conversation has a custom system_prompt set, it comes last, between <user_instructions> markers (which are stripped from the text itself), and the model is told to follow it only where it does not contradict the rest of the prompt: the administrator's instructions and the language rules stay in force (NEXT-172).

Configuration resolution 

ChatService resolves the LlmConfiguration the chat runs against, and the prompts, through nr-llm:

  1. Load the Task record via nr-llm's TaskRepository (by ExtensionConfiguration::getLlmTaskUid(): the Task of the first pair in groupTaskMapping, in the order written, whose group the user belongs to, else llmTaskUid -- ADR-015).
  2. Take Task::getConfiguration() as the LlmConfiguration passed to AgentRuntime::run(). A missing Task or Configuration fails the turn loudly.
  3. The Configuration's system_prompt and the Task's prompt_template feed buildSystemPrompt().

A provider adapter is still created from the Configuration's model (via ProviderAdapterRegistry) — but only to expand file attachments and report supported formats; the chat turn itself runs inside nr-llm's AgentRuntime.

archived
Whether the conversation is archived.
pinned
Whether the conversation is pinned (prevents auto-archiving).
error_message
Last error message (sanitized, no API keys).

ConversationStatus 

The conversation lifecycle is modeled as a state enum:

idle
Ready for new user input. This is the resting state.
processing
Waiting for a consumer: the request has claimed the conversation for a turn, and ai-chat:process or ai-chat:worker has not taken it yet.
locked
Claimed by ai-chat:process or ai-chat:worker and running. The row stays locked for the whole turn, so no second consumer can take it. The chat shows it like processing.
tool_loop
Legacy transitional state. The tool loop now runs synchronously inside nr-llm's AgentRuntime within a single locked turn, so the chat no longer parks a conversation here; the state is retained for backward compatibility.
awaiting_approval
The run paused before a write and waits for a decision on the approval card.
awaiting_input
The run paused because a tool asks the user for an answer; the chat offers it as reply buttons above the input (ADR-018).
failed
An error occurred. The user can retry by sending a new message.

State transitions:

idle --> processing --> locked --> idle     (success)
idle --> processing --> locked --> failed   (error)
idle --> processing --> locked --> awaiting_approval
                                             (run paused)
idle --> processing --> locked --> awaiting_input
                                             (run asks)
awaiting_input --> processing --> locked --> ...
                                             (answered)
processing|locked|tool_loop --> failed      (cleanup timeout)
Copied!

File attachment flow 

User selects file (upload or FAL browser)
    |
    | POST /ai-chat/file-upload (multipart/form-data)
    v
ChatApiController::fileUpload()
    | validates MIME type + size (max 20 MB)
    v
FAL storage: fileadmin/ai-chat/<be_user_uid>/
    | returns fileUid
    v
Frontend stores {fileUid, name, mimeType} as pendingFile

User sends message
    |
    | POST /ai-chat/conversations/send {content, fileUid}
    v
ChatApiController::sendMessage()
    | validates file limit (max 5 per conversation)
    | reads FAL metadata (fileName, fileMimeType)
    | stores message with fileUid in conversation JSON
    v
ChatService::processConversation()
    |
    v
ChatService::buildLlmMessages()
    | reads file from FAL (getForLocalProcessing)
    | for each file attachment:
    |   images  → base64 data URI (provider must be VisionCapable)
    |   documents (PDF/DOCX/XLSX/TXT):
    |     if provider implements DocumentCapableInterface
    |       → sent as binary (base64-encoded document block)
    |     else
    |       → DocumentExtractorRegistry::extract() → plain-text block
    v
nr-llm AgentRuntime (multimodal messages forwarded to the provider)
Copied!

ChatService::getProviderCapabilities() queries the active provider for its supported formats. It calls VisionCapableInterface::getSupportedImageFormats() for image formats and, if the provider also implements DocumentCapableInterface, appends getSupportedDocumentFormats() (e.g. ['pdf']). The frontend receives this list via GET /ai-chat/status and uses it to set the file picker's accept attribute dynamically — ensuring users can only select file types the current provider can process.

Component map 

Component Responsibility Key files
Backend Module Chat UI (Admin Tools > AI Chat) Classes/Controller/, Resources/Private/Templates/
Floating Panel Toolbar chat widget, persistent across navigation Resources/Public/JavaScript/ (Lit)
Agent Loop LLM call → tool use → reply; owned by nr-llm's AgentRuntime Classes/Service/ChatService.php
Conversation Store Persists messages, pins, auto-archive Classes/Domain/Repository/
CLI Commands ai-chat:process (exec), ai-chat:worker (long-running) Classes/Command/
Missing alt texts Site-wide check for image uses without an alt text, the "decorative image" field and its frontend output (ADR-026) Classes/Service/AltText/, Classes/Domain/Repository/MissingAltTextRepository.php, Configuration/Sets/DecorativeImages/, Resources/Private/FluidStyledContent/
Access Control Group-based access, concurrency caps, length limits Classes/Controller/ChatApiController.php (checkAccess()), Classes/Configuration/ExtensionConfiguration.php

Dependency rules 

Enforced via PHPAt — runs automatically with PHPStan:

  • Domain MUST NOT depend on Controller or Command
  • Controller may depend on Domain and Service
  • Service may depend on Domain; MUST NOT depend on Controller
  • Controller MUST NOT depend on Command — background processing is reached through ChatProcessorInterface, never by invoking a CLI command class
  • Document MUST NOT depend on ChatService or Controller
  • Service MUST NOT depend on ConnectionPool — repositories own database access
  • Tests may depend on anything

Architecture tests: Tests/Architecture/LayerDependencyTest.php and Tests/Architecture/DocumentExtractorArchitectureTest.php

Agent loop 

Since version 0.6.3 the backend AI Chat does not run its own tool loop. ChatService delegates the whole chat turn to nr-llm's AgentRuntime (nr-llm ADR-101), which drives the model over nr-llm's builtin tool registry and returns the settled result synchronously.

Processing a turn 

ChatService::processConversation() performs the following steps:

  1. If no nr-llm Task is configured (llmTaskUid is 0), the conversation is set to failed with a descriptive message.
  2. Resolve the LlmConfiguration the chat should use from the configured Task (llmTaskUid -> Task -> getConfiguration()). A missing Task or Configuration fails loudly rather than silently degrading to a no-tools chat.
  3. Keep the conversation locked: ai-chat:process and ai-chat:worker claimed it as locked before calling the service, and the turn start writes locked again (refreshing the timestamp ai-chat:cleanup measures). A processing row is one waiting for a consumer, so writing processing here would let a second consumer take the running turn.
  4. Build the message transcript: a system message carrying the identity/behaviour contract and the resolved Task/Configuration prompts (see Architecture > System prompt priority), followed by the stored conversation messages. File attachments are expanded to the multimodal wire shape and forwarded as array messages.
  5. Call AgentRuntimeInterface::run() with an AgentRunRequest built from the configuration, the messages and the initiating backend user uid. allowedToolNames is left at null so the run is offered the whole globally-enabled tool set; nr-llm's own tool gate (RBAC, global enable cascade, per-configuration groups) stays authoritative. The request carries a ToolOptions object whose only content is the caller source (see below).
  6. Map the returned AgentRunResult onto the conversation.

Caller-source attribution 

Every run started here is tagged with withCallerSource() so nr-llm's Analytics module lists this extension's usage and cost under nr_mcp_agent instead of grouping it as Unattributed. The operation names the turn:

  • chatTurn -- a turn on a queued conversation.
  • resumeChatTurn -- the same turn re-run over an existing transcript by resumeConversation().

The tag is call metadata persisted on nr-llm's telemetry row; it is never sent to the provider, and the ToolOptions object carries nothing else, so no provider option is overridden by it.

The approval continuation (AgentRuntimeInterface::approve()) is not tagged: it takes no options object, and nr-llm keeps the caller source out of the persisted run state, so those provider calls stay unattributed.

Outcome mapping 

AgentRuntime::run() never throws for a run outcome; it returns a settled AgentRunResult. ChatService maps it as follows:

  • COMPLETED -- append the final assistant answer (ToolLoopResult::$finalContent), set status idle and clear the error message. The clearing matters because the row is written whole: without it a message an earlier state of the same turn wrote -- the reason a refused decision wrote back, above all -- survives the run that resolved it, and a finished conversation goes on looking failed.
  • AWAITING_APPROVAL -- set status awaiting_approval, store the run uuid for the link, and clear the error-message field. Not a failure: the run stopped before a write and is waiting for a human. No sentence is stored for the pause: the chat renders the notice from the status, as the label chat.approvalPending (or, for a reader who may not decide approvals, chat.approvalPendingElsewhere) in the reader's language -- a stored sentence is frozen in the language of the moment it was written (NEXT-159). The field is not always empty in this state: when nr-llm refuses a recorded decision and hands the run back still pending, the reason is stored as an error code (ApprovalHandBackReason) and shown after the label as the chat's own sentence for it in the reader's language. nr-llm's exception message goes to the log and is never shown. The notice says what is pending, not where to grant it -- the decision is offered on the card directly beneath it, and pointing past that into the AI Tasks module is what led to the same write being approved twice.
  • AWAITING_INPUT -- a tool asks the user for an answer (nr-llm ADR-105). Set status awaiting_input and store the run uuid; the question, when the input schema states one, is appended as the assistant's message. The chat offers the answers as reply buttons above the input; the answer is recorded by recordInput() and handed to AgentRuntimeInterface::submitInput() by the worker. A refusal that leaves the run waiting restores awaiting_input with its reason as a code (InputHandBackReason) (ADR-018).
  • a refusal for a guided process (nr-llm ADR-214 item 6; ProcessRefusal). When the process verdict could not be read, or only the person who started the process may decide, the run still waits: the card or the question comes back as for the other hand-backs, with its own code. When the run's configuration requires a second approver, nr-llm stopped the run: the conversation becomes idle and usable again, with the reason as a code (ProcessStopReason), and offers no Retry.
  • any other outcome (FAILED, GUARDRAIL_BLOCKED, …) -- set status failed with a sanitized reason taken from AgentRunResult::$error or derived from the outcome. The mapping keeps a default arm because AgentRunOutcome gains cases in nr-llm minor releases.

Text the model writes in the same round as a tool call -- the overview of a guided process beside chat_set_progress, the explanation beside a proposal, a status line beside a read -- is not in $finalContent, which holds only the last round's text. For COMPLETED, AWAITING_APPROVAL and AWAITING_INPUT, ChatService reads it from the run's llm steps that requested a tool and appends each non-blank text as an assistant message of its own, in the order of the rounds and before the answer, the card or the question. A failed run adds none: Retry runs the turn again over the transcript. Each such message is marked (Conversation::KEY_BESIDE_TOOL_CALL); on later turns the model reads it joined with the assistant message that follows, so one assistant turn stays one message.

The tools the model can call, their execution, retry/back-off on transient provider errors, budget enforcement and the iteration cap all live inside nr-llm now.

Synchronous execution and resume 

AgentRuntime::run() is synchronous and drives the entire tool loop in one call, so a turn never leaves persisted "pending tool calls" in the conversation. The CLI worker (ai-chat:process / ai-chat:worker) therefore always calls processConversation(). resumeConversation() re-runs the turn over the existing transcript for a resumable conversation (processing, tool_loop or failed) and for the locked conversation a consumer hands it. Retry from the chat recovers a conversation left processing because no consumer ever claimed it. A conversation whose consumer crashed mid-turn stays locked; Retry refuses it with 409 like any busy conversation, and it is released only when ai-chat:cleanup marks it failed after five minutes.

It refuses with 409 while an approval decision recorded by recordDecision(), or an answer recorded by recordInput(), has not been carried out yet (Conversation::hasPendingApprovalDecision()). Resuming clears the decision and starts the turn again, so a retry arriving in that window would run a second turn over the same transcript while the first is still performing the approved write -- which is how a page and its content element came to exist twice. A decision no worker ever picked up is not stranded by the refusal: reconcile() reads the run, sees it still waiting, clears the decision and hands the card back.

MCP servers 

External MCP servers are configured in nr-llm (module MCP Servers), which imports their tools into the same registry and agent loop the chat runs on. The stdio MCP client this extension once shipped was removed in 0.12.0; it had not been used by the chat turn since 0.11.

Console commands 

The extension provides three Symfony console commands for background processing and maintenance.

ai-chat:process 

Process a single chat conversation. Used by the exec processing strategy -- the ChatApiController forks this command for each incoming message.

vendor/bin/typo3 ai-chat:process <conversationUid>
Copied!

Arguments:

conversationUid (required)
UID of the conversation to process. The conversation must be in processing status.

Exit codes:

0
The turn ran: the conversation ends idle, waiting for an approval, or failed with an error message. Also returned, with an info line, when there is nothing to do: the conversation is not in processing because another ai-chat:process or a worker claimed it first, or because the turn is already over. That case leaves the conversation as it is.
1
Failure -- conversation not found, or an error escaped the turn.

Behavior:

  • Claims the conversation first: one update moves it from processing to locked, the same claim ai-chat:worker makes. A database deadlock on that update counts as a lost claim. The row stays locked until the turn ends, so no worker and no second ai-chat:process can take the same turn while it runs.
  • Initializes the backend user context for the conversation owner.
  • If the conversation has pending tool calls (crash recovery), executes them first via resumeConversation().
  • Otherwise, runs the full agent loop via processConversation().

ai-chat:worker 

Long-running worker process that polls for conversations in processing status and processes them sequentially. Used by the worker processing strategy.

vendor/bin/typo3 ai-chat:worker [--poll-interval=200]
Copied!

Options:

--poll-interval (optional, default: 200)
Poll interval in milliseconds. How often the worker checks for new conversations to process.

Behavior:

  • Runs indefinitely (designed for systemd or supervisord).
  • Uses dequeueForWorker() with atomic locking to prevent multiple workers from processing the same conversation.
  • Each worker identifies itself with a unique ID (PID + random bytes).
  • After processing, the backend user context is cleared to prevent leaking between conversations.

Production deployment:

See Worker mode production setup in the Configuration section for a systemd service example.

ai-chat:cleanup 

Maintenance command that handles stuck conversations, auto-archiving, and deletion of old data. Should be run periodically (e.g. daily via cron).

vendor/bin/typo3 ai-chat:cleanup \
    [--delete-after-days=90]
Copied!

Options:

--delete-after-days (optional, default: 90)
Hard-delete archived conversations older than this many days.

Actions performed:

  1. Timeout stuck conversations -- Conversations in processing, locked, or tool_loop status for more than 5 minutes are set to failed with a timeout error message.
  2. Auto-archive inactive conversations -- Conversations in idle status that have been inactive longer than the configured autoArchiveDays are archived.
  3. Delete old archived conversations -- Archived conversations older than --delete-after-days are hard-deleted from the database.

Output example:

Timed out 2 stuck conversation(s)
Auto-archived 5 inactive conversation(s)
Deleted 12 old archived conversation(s)

Cleanup summary:
  Timed out stuck conversations: 2
  Auto-archived inactive conversations: 5
  Deleted old archived conversations: 12
Copied!

Testing 

Test infrastructure overview 

The extension uses a layered test approach:

Layer Tool Runner
Unit tests PHPUnit ddev composer ci:tests:unit or runTests.sh -s unit
Functional tests PHPUnit + TYPO3 testing framework ddev composer ci:tests (requires database)
Architecture tests PHPAt (via PHPStan extension) ddev composer ci:phpstan (runs automatically with PHPStan)
Static analysis PHPStan ddev composer ci:phpstan
Code style PHP-CS-Fixer ddev composer ci:cgl
Mutation testing Infection ddev composer ci:mutation

Running tests with DDEV 

# Unit tests
ddev composer ci:tests:unit

# Unit + functional tests
ddev composer ci:tests

# Static analysis (includes architecture tests)
ddev composer ci:phpstan

# Mutation testing
ddev composer ci:mutation
Copied!

Running tests with Docker (runTests.sh) 

Build/Scripts/runTests.sh provides a Docker-based test runner that mirrors the CI environment exactly. It does not require DDEV.

# Show all options
./Build/Scripts/runTests.sh -h

# Unit tests
./Build/Scripts/runTests.sh -s unit

# Unit tests with a specific PHP version
./Build/Scripts/runTests.sh -s unit -p 8.3

# PHPStan
./Build/Scripts/runTests.sh -s phpstan

# Code style check
./Build/Scripts/runTests.sh -s cgl

# Fix code style
./Build/Scripts/runTests.sh -s cgl -n

# Mutation testing
./Build/Scripts/runTests.sh -s mutation
Copied!

Supported -s values: unit, unitCoverage, cgl, phpstan, rector, mutation, lint, composer, composerUpdate, clean, update.

Architecture tests 

Architecture tests enforce dependency rules between the extension's layers. They are implemented using PHPAt and registered as a PHPStan extension — they run automatically as part of ci:phpstan, not as a separate PHPUnit testsuite.

The rules are defined in Tests/Architecture/LayerDependencyTest.php and Tests/Architecture/DocumentExtractorArchitectureTest.php. They ensure, for example, that Domain classes do not depend on Controller classes.

Mutation testing 

Infection is used to verify the quality of unit tests by introducing code mutations and checking whether tests catch them.

The minimum thresholds are defined in infection.json.dist:

  • minMsi: 60 % (Mutation Score Indicator)
  • minCoveredMsi: 70 % (Covered Code MSI)

Run locally:

ddev composer ci:mutation
Copied!

Some mutations are intentionally ignored (see infection.json.dist):

  • CastArray on GeneralUtility::makeInstance calls -- untestable in unit tests without TYPO3 boot.
  • Logical conditions on PHP_SAPI -- compile-time constant, always 'cli' in unit test context.

Architecture decision records 

Architecture Decision Records (ADRs) document the key design choices made during development, including the context, alternatives considered, and consequences of each decision.

ADR-001: Embedded MCP Client in TYPO3 Backend 

Status: Accepted — partially superseded (2026-08-20). The decision to run the chat inside the TYPO3 backend stands. The embedded MCP client named here does not: the chat turn runs on nr-llm's AgentRuntime and takes its tools from nr-llm's registry since 0.11, and the client was removed in 0.12.0 (see ADR-003: MCP Integration via stdio Subprocess, ADR-004: nr-llm as LLM Abstraction Layer). External MCP servers are configured in nr-llm's MCP Servers module.

Date: 2026-03-14

Context 

hn/typo3-mcp-server exposes TYPO3 content operations (pages, records, content elements) as MCP tools. Any MCP-capable AI client — Claude Desktop, Cursor, or similar — can connect to it and manage TYPO3 content through natural language.

The problem: those clients are external applications. Every time an editor wants AI assistance while working in the TYPO3 backend, they must leave TYPO3, switch to the external client, issue their request, switch back to TYPO3, and verify the result. For content workflows this context-switching is constant and disruptive.

Decision 

Build the MCP client and AI chat interface directly into the TYPO3 backend as a native extension (nr_mcp_agent). Editors interact with the AI without leaving TYPO3.

Consequences 

  • Editors can request content changes and verify results without switching applications.
  • The extension must manage the full agent loop (LLM calls, MCP tool execution, conversation state) that external clients handle out of the box.
  • All subsequent architectural decisions (CLI processing, stdio MCP transport, Lit UI, conversation persistence) are consequences of this integration choice.
  • The project is scoped as a proof of concept: the goal is to demonstrate that this integration is feasible and to gather feedback, not to deliver a production-ready product.

ADR-002: CLI-Based Message Processing 

Status: Accepted

Date: 2026-03-14

Context 

Processing an AI chat message involves: calling the LLM API (seconds to tens of seconds), potentially executing multiple MCP tool calls (each spawning a subprocess), and looping until the agent produces a final reply. This cannot complete within a reasonable HTTP request timeout and would block a PHP-FPM worker for the entire duration.

Alternatives considered:

  • Synchronous HTTP response: Ties up an FPM worker; times out on slow LLMs or long tool chains.
  • Async HTTP (ReactPHP/Swoole): Requires a non-standard PHP runtime; incompatible with most TYPO3 hosting environments.
  • Queue system (RabbitMQ, Redis Queue): Adds external infrastructure dependencies.
  • CLI subprocess: PHP CLI has no timeout constraints; uses no FPM workers during processing.

Decision 

Process messages via CLI commands, dispatched by the web server:

  • ``exec`` mode: The web request forks a ai-chat:process <messageUid> subprocess per message and returns immediately.
  • ``worker`` mode: A long-running ai-chat:worker process polls for pending messages. Suitable for environments where forking per request is undesirable.

Both modes write the assistant reply back to the database. The browser polls for completion.

Consequences 

  • Web server stays responsive regardless of LLM latency or tool chain depth.
  • No external queue infrastructure required.
  • exec mode requires proc_open / shell_exec to be available.
  • worker mode requires a process supervisor (systemd, supervisor) to keep the worker alive.
  • The processing strategy is configurable via extension configuration.

ADR-003: MCP Integration via stdio Subprocess 

Status: Superseded (2026-08-20) — the chat turn runs on nr-llm's AgentRuntime since 0.11 (nr-llm ADR-116); the stdio MCP client, the tx_nrmcpagent_mcp_server table and enableMcp were removed in 0.12.0. External MCP servers are configured in nr-llm's MCP Servers module.

Date: 2026-03-14

Context 

hn/typo3-mcp-server implements the Model Context Protocol over stdio. It is designed to be launched as a subprocess by an MCP host. The MCP specification also defines HTTP+SSE as a transport option.

Alternatives considered:

  • HTTP+SSE transport: Would require hn/typo3-mcp-server to run as a persistent HTTP server, adding deployment complexity and changing its operational model.
  • Direct PHP function calls: Would require forking or reimplementing the MCP server logic inside nr_mcp_agent, coupling the two extensions tightly.
  • stdio subprocess: Uses hn/typo3-mcp-server exactly as designed, with zero modifications.

Decision 

Connect to hn/typo3-mcp-server by spawning it as a stdio subprocess. McpConnection manages the process lifecycle (start, communication, shutdown). McpToolProvider translates between the agent loop and the MCP protocol.

Consequences 

  • hn/typo3-mcp-server is used without modification.
  • The MCP connection is process-local: each CLI processing job spawns its own MCP server instance.
  • The stdio transport is synchronous within the agent loop, which is sufficient given that processing already runs in a CLI subprocess (see ADR-002: CLI-Based Message Processing).
  • MCP is an optional dependency: if hn/typo3-mcp-server is not installed, the extension works without tool-calling capability.

ADR-004: nr-llm as LLM Abstraction Layer 

Status: Accepted

Date: 2026-03-14

Context 

The extension needs to call an LLM API. Multiple providers are relevant (OpenAI, Anthropic Claude, Google Gemini, Ollama), each with different SDKs, authentication schemes, and capability sets (vision, native document handling, tool calling).

Alternatives considered:

  • Direct provider SDK integration: Fast to start, but locks the extension to one provider; adding a second requires forking the agent loop.
  • Custom abstraction inside ``nr_mcp_agent``: Duplicates work already done in nr-llm.
  • ``netresearch/nr-llm``: Existing Netresearch TYPO3 extension providing a provider-agnostic LLM interface, Task-based configuration, and capability interfaces (DocumentCapableInterface, VisionCapableInterface).

Decision 

Use netresearch/nr-llm as the sole LLM integration point. The extension references an nr-llm Task record (configured by the TYPO3 administrator) and delegates all LLM calls through its interface.

Consequences 

  • Provider selection and credential management are handled by nr-llm and nr-vault; nr_mcp_agent has no provider-specific code.
  • Capability detection (e.g. whether the provider supports native PDF handling) uses nr-llm interfaces, enabling the document extraction fallback (see ADR-013: Server-Side Document Text Extraction as Provider Fallback).
  • The extension inherits nr-llm's provider support: adding a new provider to nr-llm makes it available in nr_mcp_agent without changes.
  • nr-llm is a hard dependency.

ADR-005: Persistent Conversation Model with State Machine 

Status: Accepted

Date: 2026-03-14

Context 

AI chat sessions consist of multiple messages exchanged over time. Users may close the browser, navigate away, or return to a conversation hours or days later. Processing happens asynchronously in a CLI subprocess (see ADR-002: CLI-Based Message Processing), so the web request and the processing job do not share memory.

Alternatives considered:

  • PHP session storage: Does not survive browser close or server restarts; not accessible from CLI.
  • Stateless (no history): Each message would be processed without context; unusable for multi-turn conversations.
  • Database persistence: Survives restarts, accessible from both web and CLI, queryable.

Decision 

Persist conversations and messages in dedicated database tables (tx_nrmcpagent_conversation, tx_nrmcpagent_message). Messages use a status state machine:

pending → processing → done | error

Status transitions use atomic compare-and-swap (CAS) queries to prevent race conditions between concurrent CLI workers.

Conversation lifecycle is managed by the extension: users can pin conversations, and inactive conversations are auto-archived after a configurable number of days.

Consequences 

  • Conversations survive browser close, server restarts, and CLI worker restarts.
  • The browser polls the message status via a lightweight AJAX endpoint (see ADR-007: Polling over WebSockets or SSE).
  • CAS updates prevent double-processing in worker mode.
  • Auto-archive and cleanup commands (ai-chat:cleanup) keep the table size bounded.
  • Per-user concurrency caps (maxActiveConversationsPerUser) are enforceable via database queries.

ADR-006: Layered Architecture with PHPAt Enforcement 

Status: Accepted

Date: 2026-03-14

Context 

As the codebase grows, accidental dependency inversions (e.g. a Domain class importing a Controller) are easy to introduce and hard to spot in code review. PHP has no built-in module visibility; any class can import any other.

Decision 

Define explicit dependency rules between architectural layers and enforce them automatically via PHPAt architecture tests, which run as part of the PHPStan pass in CI:

Layer May depend on Must NOT depend on
Domain — Controller, Command
Service Domain Controller, ConnectionPool
Controller Domain, Service Command
Document Domain ChatService, Controller
Command Domain, Service Controller

Architecture tests live in Tests/Architecture/LayerDependencyTest.php and Tests/Architecture/DocumentExtractorArchitectureTest.php.

Consequences 

  • Violations are caught at CI time, not during code review.
  • The architecture self-documents: the test file is the authoritative dependency map.
  • PHPAt runs within the existing PHPStan pipeline — no additional CI step.
  • Adding new layers or relaxing rules requires a deliberate test change, making architectural drift visible in pull requests.

ADR-007: Polling over WebSockets or SSE 

Status: Accepted

Date: 2026-03-15

Context 

The browser needs to know when the CLI worker has finished processing a message. Three push/pull patterns were considered:

  • WebSockets: Bidirectional, low-latency — but requires a persistent connection, a compatible server (not standard FPM), and non-trivial infrastructure.
  • Server-Sent Events (SSE): Server-push, lightweight — but holds an HTTP connection open per conversation, which is problematic under FPM connection limits and incompatible with the CLI-based processing model (the SSE endpoint cannot receive events from a separate process without a shared message bus).
  • Polling: The browser calls GET /api/message/{uid}/poll on an interval until status is done. Stateless, FPM-compatible, no persistent connections.

Decision 

Use polling. The browser polls the message status endpoint every 1.5 seconds while a message is processing. The endpoint is optimized for minimal overhead (single indexed lookup by UID and status).

Consequences 

  • No persistent connections, no external message bus, no special server requirements.
  • Response latency is bounded by the poll interval ( 1.5 s), which is acceptable for a backend content management tool.
  • Under load, polling generates additional HTTP requests. The per-request overhead is low (indexed DB query, no session), and the poll stops immediately when the message reaches a terminal state.
  • If future requirements demand lower latency, the polling endpoint can be replaced with SSE without changes to the processing layer.

ADR-008: Lit Web Components Without a Build Step 

Status: Accepted

Date: 2026-03-15

Context 

The chat UI requires a reactive component model: dynamic message lists, optimistic updates, streaming state indicators, and a floating panel that persists across iframe navigation. Options considered:

  • React / Vue / Svelte: Rich ecosystems, but require a build pipeline (Webpack, Vite). TYPO3 extensions ship static assets; introducing a build step adds tooling complexity and diverges from TYPO3 core patterns.
  • Vanilla JS with manual DOM updates: No dependencies, but managing reactive state manually at this complexity level is error-prone.
  • Lit 3: Lightweight ( 6 kB), standards-based web components with reactive properties and declarative templates. Can be loaded directly from an ES module import map — no build step.
  • TYPO3 core components: No suitable chat-oriented components exist in TYPO3 core.

Decision 

Use Lit 3 web components, loaded via TYPO3's import map mechanism. JavaScript is written in ES modules and shipped as-is. No transpilation, no bundler.

Consequences 

  • No build tooling required; assets are edited and deployed directly.
  • Lit's web component model integrates cleanly with TYPO3's outer backend frame: the <ai-chat-panel> element is appended to document.body and persists across module navigation (see ADR-011: Floating Chat Panel Outside the Module iframe).
  • Browser support is limited to evergreen browsers — acceptable for a TYPO3 backend tool.
  • Unit tests use Jest with @web/test-runner compatible setup; the same no-build constraint applies.

ADR-009: Group-Based Access Control via Extension Configuration 

Status: Accepted

Date: 2026-03-14

Context 

The AI chat must be restricted to authorized backend users. TYPO3 provides several access control mechanisms:

  • Backend User Permissions / Access Lists: Fine-grained per-user or per-group permission records. Flexible, but requires administrators to configure individual permission records in the TYPO3 backend — significant overhead for a single on/off feature.
  • Module access via ``allowed_modules``: Controls which modules a group can see, but does not restrict API endpoints.
  • Custom group allowlist in extension configuration: A comma-separated list of backend group UIDs in ext_conf_template.txt. Simple to configure, enforceable on both module and API layer.

Decision 

Use a allowedGroups extension configuration setting. If the list is empty, all authenticated backend users have access. If non-empty, only users belonging to one of the listed groups can use the chat API endpoints; other users get no toolbar item, and the dashboard widget lists no conversations for them.

Consequences 

  • Configuration is a single field in Admin Tools > Extension Configuration — no permission records to create.
  • The check is applied uniformly in the API controller before any processing begins.
  • Granularity is at the group level; per-user overrides require creating a dedicated group.
  • Admin users (the admin flag of the backend user) bypass the check in line with TYPO3 conventions.

ADR-010: LLM Error Message Sanitization Before Browser Output 

Status: Accepted

Date: 2026-03-15

Context 

When the LLM API call or MCP tool execution fails, the exception message may contain sensitive data from the provider stack: Bearer tokens, API keys, internal URLs, or credential fragments embedded in HTTP error responses. If forwarded to the browser as-is, these leak credentials to the end user (and potentially to browser logs, network proxies, or JavaScript error trackers).

Alternatives considered:

  • Generic error messages only ("An error occurred"): Safe, but provides no useful diagnostic information to the user or administrator.
  • Server-side logging only, generic client message: Good for production, but loses context for debugging.
  • Sanitize before sending: Strip known credential patterns and truncate, then forward the cleaned message to the client.

Decision 

All exception messages that originate from LLM or MCP calls are passed through ErrorMessageSanitizer::sanitize() before being stored in the database or returned to the browser. The sanitizer:

  • Redacts Bearer <token> patterns.
  • Redacts strings matching common API key patterns (sk-..., key-..., api-key-...).
  • Replaces URLs with [URL].
  • Truncates to 500 characters.

Consequences 

  • Credential leaks via error messages are prevented at the boundary between the processing layer and the database/browser.
  • Sanitized messages still carry enough context (HTTP status codes, provider error codes) for debugging.
  • The sanitizer is a simple utility class with no dependencies, independently testable.
  • Patterns may need updating as provider error formats evolve.

ADR-011: Floating Chat Panel Outside the Module iframe 

Status: Accepted

Date: 2026-03-16

Context 

The initial AI Chat implementation is a full-page backend module (Admin Tools > AI Chat). To use it, editors must leave their current workspace (Page module, List module), interact with the chat, then navigate back to verify results. For workflows where the editor issues a series of content changes via AI, this creates constant context-switching.

Alternatives considered:

  • Embedded iframe inside each module: Requires patching every TYPO3 core module; not feasible.
  • Sidebar panel inside the module iframe: Only visible in the AI Chat module itself; disappears when navigating away.
  • Floating element inside the module iframe: Iframes are isolated; an element inside one iframe cannot span the full backend.
  • Floating element in the outer backend frame (``document.body``): Persists across all module navigations because it lives outside the iframe.

Decision 

Inject an <ai-chat-panel> web component into document.body of the outer TYPO3 backend frame. The component is loaded via the import map backend.module tag (the same mechanism TYPO3 core uses for toolbar items like live search), so no PHP PageRenderer call is needed. The panel uses position: fixed with z-index coordinated with TYPO3's layering scale.

The existing full-page module is retained for history browsing and extended sessions.

Consequences 

  • The panel persists across all module navigations without any module cooperation.
  • The panel's AJAX calls use the same ajaxUrls available in the outer frame as any toolbar item.
  • z-index coordination is required: the panel sits above the scaffold header but below TYPO3 modals.
  • Drag and resize use the Pointer Events API (setPointerCapture) for reliable cross-element interaction.

ADR-012: Markdown Rendering with marked.js and DOMPurify 

Status: Accepted

Date: 2026-03-17

Context 

LLM responses frequently contain Markdown: headings, bullet lists, numbered steps, code blocks, and tables. Displaying these as raw text degrades readability significantly. The rendered output must be XSS-safe: a compromised or adversarially prompted LLM could produce HTML or JavaScript in its response.

Alternatives considered:

  • Plain text only: Safe, but unreadable for structured responses.
  • Server-side Markdown-to-HTML (PHP): Requires a PHP Markdown library, adds a server round-trip for each render, and moves rendering responsibility to the server.
  • ``innerHTML`` without sanitization: Fast, but allows XSS if the LLM output contains <script> tags or event handlers.
  • marked.js + DOMPurify in the browser: Client-side rendering, no server round-trip, XSS-safe via DOMPurify sanitization after parsing.

Decision 

Vendor marked.js v15 and DOMPurify v3 as static assets (no build step, consistent with ADR-008: Lit Web Components Without a Build Step). LLM response text is parsed by marked.js into HTML, then sanitized by DOMPurify before being set as innerHTML. Both libraries are treated as untrusted-input pipelines: marked produces HTML from untrusted text, DOMPurify strips anything dangerous before it reaches the DOM.

Consequences 

  • LLM responses render as rich text (headings, lists, code blocks, tables) without a server round-trip.
  • XSS is prevented even if the LLM produces malicious HTML in its output.
  • Vendored libraries must be updated manually when security patches are released.
  • No build step is introduced (libraries are used as ES modules or UMD bundles loaded directly).

ADR-013: Server-Side Document Text Extraction as Provider Fallback 

Status: Accepted

Date: 2026-03-25

Context 

Users can attach files (PDF, DOCX, XLSX, TXT) to chat messages. Some LLM providers (e.g. Anthropic Claude) natively accept these formats as binary content. Others do not implement DocumentCapableInterface and cannot receive binary documents at all — the agent loop would throw a RuntimeException and the file would be unusable.

Alternatives considered:

  • Reject files for non-capable providers: Simple, but severely limits usability across providers.
  • Require a document-capable provider: Forces configuration choices on the administrator; incompatible with ADR-004: nr-llm as LLM Abstraction Layer (provider agnosticism).
  • Server-side extraction as a fallback: Extract text from the document on the server, inject it as a plain-text block in the prompt. Works with any provider.

Decision 

Introduce a DocumentExtractorRegistry with a DocumentExtractorInterface. When the configured provider does not natively support a document format, the extension extracts the text server-side and injects it into the prompt as a fenced text block.

Extractors:

  • PlainTextExtractor — always available, no dependencies.
  • PdfExtractor — uses smalot/pdfparser (hard dependency).
  • DocxExtractor — uses phpoffice/phpword (hard dependency).
  • XlsxExtractor — uses phpoffice/phpspreadsheet (optional; XLSX uploads return 422 if not installed).

The two systems are independent and compose in the capability detection layer: a format is usable if either the provider supports it natively OR an extractor is available.

Consequences 

  • All four document formats work with any configured LLM provider.
  • XLSX support is deliberately optional to avoid a heavy dependency for users who do not need it.
  • Extracted text loses formatting (tables become flat text, DOCX styles are stripped) — acceptable given the goal of making content accessible to the LLM.
  • The registry is an extension point: additional extractors can be registered via DI without modifying core classes.

ADR-014: Configurable MCP Server Registry with Auto-Init Default 

Status: Superseded (2026-08-20) — the chat turn runs on nr-llm's AgentRuntime since 0.11 (nr-llm ADR-116); the stdio MCP client, the tx_nrmcpagent_mcp_server table and enableMcp were removed in 0.12.0. External MCP servers are configured in nr-llm's MCP Servers module.

Date: 2026-03-27

Context 

The original implementation used a single hardcoded MCP server configuration supplied via extension settings (mcpServerCommand, mcpServerArgs). This prevented:

  • Running multiple MCP servers simultaneously (e.g. a TYPO3-specific server alongside a project-specific one)
  • Distinguishing tool origins at the LLM prompt level
  • Changing server configuration without deploying new extension settings

Alternatives considered:

  • Multiple extension settings entries: Flat key/value pairs do not scale for N servers; no per-server enable/disable; no UI for reordering.
  • YAML/JSON file in fileadmin: Flexible but requires filesystem access; no TYPO3 access control; not managed via the standard backend.
  • Database-driven registry: Fits the TYPO3 record model; benefits from TCA-based editing, hidden/deleted flags, and sorting; manageable without CLI access.

Decision 

Introduce a tx_nrmcpagent_mcp_server database table. Each record represents one MCP server with fields for server_key, transport (stdio/sse), command, arguments, url, and auth_token. The server_key value is used as a prefix for all tool names from that server ({server_key}__{tool_name}), making the origin unambiguous in LLM tool calls and in the system prompt namespace hint.

McpToolProvider loads all active (non-hidden, non-deleted) records on each getToolDefinitions() call and manages one McpConnection per server key.

Auto-initialisation of the default record 

When enableMcp=1 is configured but the registry table is empty, McpToolProvider::getToolDefinitions() calls McpServerRepository::initDefault(), which inserts a single default record (server_key=typo3, transport=stdio, arguments=mcp:server).

Alternatives considered for triggering this init:

  • ``AfterExtensionConfigurationWriteEvent``: Fires when an admin saves the extension configuration in the TYPO3 backend. Clean and intentional, but does not cover deployments where enableMcp is set via environment variable or AdditionalConfiguration.php — the event never fires in those cases.
  • Upgrade wizard: TYPO3-native and visible, but requires manual admin action after every installation; inappropriate for a one-time default record.
  • Lazy init inside ``getToolDefinitions()``: The findAllActive() query already runs on every call; if the result is empty, initDefault() inserts the default record and findAllActive() is called once more. No extra SELECT is needed because the empty result from the first call is itself the existence check. Covers all deployment scenarios including image-based configs.

The lazy approach was chosen because it reliably handles both UI-driven and deployment-driven activation without requiring additional infrastructure or manual steps.

Consequences 

  • Administrators can add, reorder, enable/disable, and delete MCP servers via the TYPO3 List module (root page, PID 0).
  • New installations get a working default configuration automatically on first chat interaction when MCP is enabled.
  • Tool name collisions across servers are prevented by the server_key prefix; the LLM receives a namespace hint in the system prompt.
  • The auth_token field is stored as a TYPO3 password field (masked in the backend) but is excluded from the tool-list cache key to avoid leaking sensitive values into cache identifiers.
  • SSE transport is reserved for a future implementation; selecting it currently raises a RuntimeException.

ADR-015: nr-llm Task per Backend Group via Extension Configuration 

Status: Accepted

Date: 2026-09-23

Context 

The chat ran every user on one nr-llm Task (llmTaskUid). Installations want different models or prompts for different audiences — editors on a cheaper model, administrators on one with a larger context window, a press team with its own instructions (NEXT-172). The Task is the unit nr-llm uses for exactly that: it names the Configuration (provider, model, system prompt) and adds a prompt template.

Options considered:

  • A TCA table mapping be_groups to Tasks, edited as records. It gives relations with integrity and a list view, at the cost of a table, TCA, a place in the page tree (or root-level records) and permissions for who may edit it — for a handful of rows an administrator sets once.
  • A field on ``be_groups`` holding the Task. Natural to edit, but it extends a core table from an extension that is a proof of concept, and the precedence between several groups of one user becomes hidden in record order.
  • An extension configuration string of groupUid:taskUid pairs, next to llmTaskUid and allowedGroups (ADR-009), which are configured the same way.

Decision 

Use an extension configuration setting groupTaskMapping: comma-separated groupUid:taskUid pairs. The first pair, in the order of the setting, whose group the user belongs to decides; users in none of them get llmTaskUid. A malformed pair is skipped rather than invalidating the whole setting.

ExtensionConfiguration::getLlmTaskUid() answers for the current backend user. Every caller — the status endpoint, the toolbar item, the turn in the worker — already runs as the owner of the conversation (the worker initialises it), so the one method gives the same answer everywhere, and no caller had to change.

Membership is the effective one, userGroupsUID, which includes subgroups. allowedGroups (ADR-009) compares the directly assigned usergroup only; the mapping deliberately does not copy that, because a Task per group is a group setting, and group settings in TYPO3 are inherited by subgroups. ADR-009 is not changed here.

Consequences 

  • One field in Admin Tools > Extension Configuration; no table, no records, no permissions to manage.
  • Precedence is explicit: the order of the pairs.
  • The Task decides the model and the prompts, not the permissions. What the assistant may do stays with nr-llm's per-user tool policy and the Configuration's own group restriction, so a mapping cannot widen a user's rights. The agent runtime does not check the Configuration itself, so ChatService::resolveConfiguration() does, before every turn: the Configuration must be active, and if it is restricted to backend groups, ConfigurationResolver::actorMayUse() must accept the user (admins always pass). Otherwise the turn fails with a message naming the Configuration and the reason. The same check applies to llmTaskUid.
  • Referential integrity is not checked: a deleted Task or group leaves a pair that matches nothing or fails the turn with "Task not found". With a TCA table this would be a dangling relation just the same.
  • If the mapping grows beyond a few pairs, or needs per-site variants, a TCA table is the next step; the resolution stays in getLlmTaskUid().

ADR-016: Messages in Their Own Table 

Status: Accepted

Date: 2026-09-23

Context 

A conversation's transcript was one JSON value in the messages column (mediumtext) of tx_nrmcpagent_conversation (ADR-005). Every write of the conversation wrote the whole value again, a message could not be read or removed on its own, and the column grew without a natural bound — an image reference, a long tool answer and forty turns all end up in one field that is read and rewritten on every turn. Editing a sent message (NEXT-172) is a truncation of that list. NEXT-172 asked for messages in their own table, with an upgrade path that keeps existing conversations readable.

Two constraints come from the rest of the design:

  • The claim is atomic. Sending a message, recording a decision, retrying and editing all claim the conversation with a compare-and-swap on its status (updateIf), and a worker dequeues what is claimed. With the transcript in the same row, the new message and the claim were one UPDATE. Split across two tables they must still be one unit: a claim that loses must leave no message behind, and a worker must never dequeue a conversation whose new message is not there yet.
  • The model does not change. Conversation hands the transcript to the service, the controller and the worker as one list, and several places depend on that (the turn, file counting, edit, export). A parallel change (NEXT-167) was editing the model and the service at the same time.

Decision 

  • New table tx_nrmcpagent_message: conversation, sorting, role, payload (the message array as JSON — tool calls, attachment references and timestamps travel unchanged), crdate; unique on (conversation, sorting).
  • ConversationRepository is the only place that knows. Loading one conversation fills the model's list from the rows; saving writes the conversation row and replaces the rows in one transaction; in updateIf the transaction wraps the compare-and-swap and rolls back when it loses. The messages column is written empty on every save.
  • Replace, not append: an edit truncates, and a transcript is dozens of rows, not thousands. Appending only the new rows is an optimisation for later; it needs the repository to know what is stored, which replace-all does not.
  • Rows written before the table existed stay readable: a conversation without message rows is read from the column. The first save moves its transcript; the upgrade wizard nrMcpAgent_migrateMessagesToTable moves the rest, one conversation per transaction, and keeps existing rows over a stale column value.
  • The list endpoints and the poll never read either — they use the denormalised message_count, as before.
  • The hard delete in ai-chat:cleanup removes message rows whose conversation no longer exists.

Consequences 

  • No code outside the repository, the cleanup command and the wizard changed; the model, the service and the controller see the same list.
  • Each save costs a delete and one insert per message inside a transaction, instead of one UPDATE of a growing value. For the sizes a chat has this is the same order of work; for very long conversations append-only writes are the follow-up.
  • Replacing the rows deadlocks under REPEATABLE READ (the MySQL/MariaDB default) when two new conversations are written at once: the delete of an empty range takes a gap lock, and both inserts wait on the other's. Reproduced on MariaDB 11.4 with two sessions. Every transaction of the repository is therefore restarted up to three times on a DeadlockException, which is the remedy the database itself names; the work is a function of the conversation and safe to repeat. A lock wait timeout is not retried (it has already waited innodb_lock_wait_timeout), and nothing is retried inside a caller's transaction, where the deadlock has rolled back more than the repository's part.
  • A legacy value that is not a JSON list — such a conversation could not be opened before either — is not destroyed by the wizard: it stays in the column behind the prefix !undecodable:, which the wizard skips and no later save clears. The conversation is marked failed and archived and opens with an empty transcript instead of erroring.
  • The wizard selects uids only and moves each transcript in its own transaction. Its claim is a conditional write (WHERE messages = the value it read), taken before anything else, so a chat save that happened after the read is never overwritten by the older value.
  • Both tables must live on the same database connection. The repository writes both through the conversation table's connection, and the orphan sweep and the migration query them together; mapping tx_nrmcpagent_message to another connection in $GLOBALS['TYPO3_CONF_VARS']['DB']['TableMapping'] breaks reads and writes of the transcript, not only their atomicity. It is not supported.
  • The messages column stays in the schema, empty after the wizard; it can be dropped in a later release once no installation needs the fallback.
  • Queries per message (search, a per-message export, a size limit) are now possible without decoding every transcript.

ADR-017: The chat states what the run did, not what it said 

Status: Accepted

Date: 2026-09-23

Context 

An analysis of the backend chats on the Netresearch demo installation (NEXT-167) found four ways in which the chat left its reader with a wrong picture, each in real conversations:

  • A change claimed, none made. Four times in one conversation the assistant answered "Erledigt" and listed the page and the content element it had changed, with uids — in runs that called no tool at all. The run records every write the DataHandler actually performed as a tool_write step (nr-llm ADR-182); nothing in the chat compared the answer with it.
  • A configuration error shown to everyone. A provider without an API key surfaced as "API key identifier is required for provider OpenAI". That sentence helps an administrator and tells an editor nothing, and it named neither the place to fix it nor the person who can.
  • Tools the model could not see. A configuration's tool groups held back sixteen tools. Asked which tools could not be used, the model said it saw no such list — and for a request one of those tools would have served, it could only answer that nothing could do it.
  • "weiter" while an approval was pending. A message sent while a run waited for an approval abandoned the approval and started a second run over the same transcript. After a lost answer, "weiter", "alles approved" and "habe alles freigegeben" each made the model draft the same page again; four of those five re-drafts were approved.

Decision 

The run decides, the text only triggers. When a completed answer claims a change (German and English participles such as "erledigt", "gespeichert", "created", "updated", not preceded by a negation such as "nicht", "nichts", "kein" or "not" within three words) and the run wrote nothing, the assistant message is stored with the notice nothingSaved. The chat renders it as a label in the reader's language: "Nothing was saved in this step". The word list is generous on purpose: a match on an answer that only talks about a change still yields a true notice, because the run wrote nothing.

"The run wrote nothing" is read from the run's persisted event stream (AgentRuntimeInterface::events()), not from the result's steps: every resume starts a fresh trace, so a write carried out at the first approval of a turn is not in the steps of the segment that answers after the second. Two kinds of event count as a write: tool_write, which a builtin writer leaves (nr-llm ADR-182), and a call that executed without error directly after an approval with approved = true — a remote tool whose write needed approval leaves no write target, and every approval-bound call is write-declared (nr-llm ADR-134). A run that could not be persisted has no stream; its result's steps are the evidence then. A stream that cannot be read yields no notice rather than a guessed one.

The notice is for the reader. The model is told the same on the next turn: the flagged answer reaches it with a note appended — "the run behind this answer wrote no record" — built per turn and never stored, so the model does not build on its own claim. The system prompt adds the rule behind it: claim a change only on a successful write result, and never state a uid no tool returned.

A failure carries its kind. Next to error_message the conversation stores error_code: providerNotConfigured for nr-llm's ProviderConfigurationException and ProviderAuthenticationException (the exception chain is walked), chatNotConfigured for a missing Task, configuration or model — on an ordinary turn and on the continuation an approval starts alike. The request that shows the failure phrases it for the reader: an administrator gets the stored text and a link to the nr-llm providers or tasks module, everyone else a localised sentence that tells them to ask the administration. The code is data and the sentence is rendered per request, for the reason NEXT-159 moved the pause notice out of the stored field: a stored sentence is frozen in one language and for one reader.

The model is told which tools it is not given. Where nr-llm provides UnavailableToolsResolverInterface (its ADR-201), the system prompt lists the tools this user's run is not offered, one line each with the reason in plain words, and tells the model to say that such a tool exists and to point to an administrator. The service is looked up in the container by name and its absence yields an empty list, so the chat keeps working unchanged on an nr-llm without it. The list is information, never a gate. A builtin tool's trust-zone refusal in observe mode is offered and therefore not listed. Remote (MCP) tools are listed only when the chat user is an administrator: nr-llm leaves them out for everyone else, because their names are operator configuration.

"Go on" is not a new request, and not a decision. When the conversation waits for an approval and the whole message is one of a short, explicit list of "go on" and "I approved it" phrases, the chat asks the run where it stands before anything is written:

  • still waiting: nothing is stored and no run starts; the reader is told that the decision is taken on the card — or in AI Tasks, for a reader who may not decide in the chat and therefore gets no card;
  • decided elsewhere and still running: the same answer as any busy conversation;
  • decided elsewhere and finished: the message is stored together with a line that names the records the run wrote (pages:10073, read from the run's tool_write events, which the privacy filter keeps at every level), and the conversation is idle. The line is stored language-neutral, for the model, with the notice runFinishedOutside and the records beside it; the reader sees the label in their own language, for the reason error_code exists. The next turn therefore knows the page exists;
  • anything the run cannot answer: the ordinary path.

The message is never taken as the approval. An approval is a decision on the preview the card shows (nr-llm ADR-132, ADR-136); "habe alles freigegeben" is a statement about a decision the writer believes was already taken, and in the demo it was written about a run that had been decided in another module.

The second half of the duplicate guard is nr-llm's: the preview of create_page_draft warns when the parent already holds a page with the same title.

Stuck conversations were already covered: ai-chat:cleanup fails any conversation left in processing, locked or tool_loop for more than five minutes. The demo conversation that stayed in processing for weeks matches that condition, so the command cannot have run there in that time; whether and how it is scheduled on the demo is not in the exported data. The reset now also clears the failure code.

Consequences 

  • An answer that claims a change the run did not make is visibly marked. An answer that describes a change a previous run made, in a turn that made none, is marked too — accurately, since nothing was saved in that step.
  • The model still says what it says; the notice sits beside it. The prompt rule makes the false claim less likely, the notice makes it visible.
  • The conversation table gains the column error_code. Existing rows have an empty code and are shown as before.
  • Non-administrators learn the names of builtin tools they may not use, including administrator-only ones. Tool names and reasons are policy facts, not instance data. Remote tool names stay with administrators.
  • A "go on" phrase outside the list is an ordinary request, and so is any longer sentence that contains one.

ADR-018: A question is answered with reply buttons 

Status: Accepted

Date: 2026-10-08

Context 

nr-llm lets a tool stop a run to ask the user for typed input (nr-llm ADR-105): the run is suspended WAITING_FOR_INPUT with the tool's input schema, and AgentRuntimeInterface::submitInput() continues it with the user's values, bound to the question shown by a digest (nr-llm ADR-150).

The chat did not handle that pause. ChatService::applyResult() mapped the AWAITING_INPUT outcome to failed with "The assistant run did not complete (awaiting_input)", and the approval card dropped an input view.

A guided tour — analyse a page, then one point at a time — has two kinds of interaction. Choices that change nothing (which point to start with, a summary or the next page) are questions the run asks. A proposed change with the answers Übernehmen, Andere Variante and Überspringen is a write, and a write goes through an approval: nr-llm forbids a writing tool from also asking for input (nr-llm ADR-134), and an approval's continuation carries no input. The chat needs both: the question answered with buttons, and the approval card denied with a reason.

Decision 

A run that asks parks the conversation. The new status awaiting_input is set with the run's uuid, like awaiting_approval. It is neither active nor resumable: Retry would start a second run and leave the question unanswered. When the schema states a question (its title, else its description), it is appended as the assistant's message, so the reader sees what is asked and later turns see what an answer answered.

The answer goes through the worker, like a decision. The request checks the answer against the question as it stands, records it on the row (approval_decision = 'input', the digest in approval_turn_digest, the values in the new column pending_input), appends it to the transcript and dispatches the worker. The worker calls submitInput(), which drives the whole continuation. Recording it as a decision of its own kind means everything that guards a recorded approval guards the answer too: Retry refuses while it is in flight, and reconcile() hands the question back if no worker ever took it.

A refusal brings the question back. nr-llm refuses a stale question, an answer that does not validate, a submitter who may not run the tool, a lost race, a deactivated configuration and a run that no longer waits, and leaves the run waiting in each case. The chat restores awaiting_input, takes the undelivered answer out of the transcript and stores the reason as a code (InputHandBackReason), rendered as a sentence in the reader's language.

The schema contract the reply buttons are read from (InputPauseForm):

  • Choice: an object schema with one choice property — enum of scalars, or oneOf/anyOf of {const, title} branches for labelled options — and at most one optional free-text property (a string without enum, oneOf, anyOf or const). Each option is a button. When the choice is not required, text typed into the chat input is submitted as the free-text property. Exactly one of the two is ever submitted.
  • Form: any other object schema of scalar properties, one field per property, submitted together.
  • Unsupported: anything else, and a pause nr-llm cannot render. The chat says it cannot show the question.

A tool that wants reply buttons — the generic "ask the user to choose" tool nr-llm will add, for one — has to put its options into the input schema the run is suspended on.

Typed text without a free-text property is a message of its own. It leaves the question unanswered and starts a new turn, as a message leaves a pending approval behind; the reply options say so ("A message of your own ends the question"). Refusing the message instead would break the promise that free text is always possible.

A write is never a question. "Übernehmen / Andere Variante / Überspringen" for a proposed change is not one input pause: nr-llm forbids a tool that writes from also asking for input (nr-llm ADR-134), and an approval's continuation carries no input. The change goes through the approval card, where the approve button applies it. In a process run, and only when a pending call writes (nr-llm ADR-214, item 9), the card's denial is two buttons, Andere Variante and Überspringen; every other card keeps Abbrechen. Whether a call writes is what nr-llm's view states for it (PendingCallView::$declaresWrite, nr-llm 0.41); a tool that is no longer registered counts as a write. Whether the conversation is a process run is asked through ProcessRunDetectorInterface; nothing implements it yet, so until a conversation carries the skill it runs, every card is a plain one. The server decides which answers a card has, and a reason sent for a card that does not offer it is a plain denial. The reason (variant or skip, DenyReason) is recorded with the decision and its label goes into the transcript as the reader's message. ApprovalDecisionFactory hands the reason to nr-llm as its ApprovalDenialReason (nr-llm ADR-214, nr-llm 0.41) through ApprovalDecision's denialReason. Input pauses with reply buttons stay for choices that write nothing.

The option check is the chat's. nr-llm validates a submission by structure only (nr-llm ADR-105). Membership in the offered options, and the type of a form field, are checked before the answer is recorded; otherwise a crafted request could put any text into a field the user was only offered buttons for.

An answer is not gated like an approval. mayDecideApprovals() guards who may release a write fence. An input-requiring tool cannot declare a write (nr-llm ADR-105, ADR-134); the owner may act on their own run; and nr-llm checks that the submitter may run the tool the question belongs to (nr-llm ADR-150). nr-llm calls its own submit entry point admin-gated as the injection mitigation for submitted values. In the chat the submitter is the conversation's owner, who can already send the model any text as a message, so the answer adds no new way in.

A tour's waiting run is cancelled by a new message. In a process run, a message while the run waits on a card or a question claims the conversation and then cancels the run (AgentRuntimeInterface::cancel()), so the tour's proposal cannot be decided after the tour has moved on (nr-llm ADR-214, item 9). Only a run that still waits is cancelled; when it is being carried on, or a decision on the card won the race, the row goes back to what it was and the message is refused as busy. An ordinary chat keeps the abandoned run waiting, as before. A "weiter" while the card waits stays what ADR-017 made it: a hint to decide on the card, not a new turn.

Consequences 

  • For a write in a process run, the approval card's Abbrechen is replaced by Andere Variante and Überspringen; new column approval_deny_reason.
  • The cancel is guarded on the run still waiting through WaitingRunCancellerInterface; NrLlmWaitingRunCanceller calls nr-llm's AgentRuntimeInterface::cancelIfWaiting() (nr-llm 0.41), and the run status nr-llm reports with a lost attempt decides: queued, running or still waiting keeps the conversation (409), an ended or unknown run releases it. The claim comes first.
  • New status awaiting_input, new column pending_input, new route ai_chat_conversation_input (POST /ai-chat/conversations/input). Run the database analyzer after upgrading.
  • The poll's fast path answers the first poll after a question with the full response, as it does for an approval, so the buttons do not wait for a reload.
  • Both chat surfaces render the answers from one module (chat-reply-options.js): real buttons in a labelled group, disabled while an answer travels, above the input; focus stays where it is.
  • A run that asks two questions in one turn is refused by nr-llm on the second (nr-llm ADR-105); the chat shows whatever the run is suspended on.

ADR-019: A conversation starts about a page, with a skill 

Status: Accepted

Date: 2026-10-08

Context 

A guided process — optimise the SEO of one page, improve its content — is a skill, not an extension (nr-llm ADR-214). The chat has to start such a process for one page in one language, and an editor has to be able to pick a process in a running conversation. The conversation stored only where the user was in the backend (view_context: page and module), createConversation() took no input, and the module URL read only &conversation=<uid>. nr-llm has no API yet to invoke a skill by identifier per run; ADR-214 describes one ("explicit invocation") that is being designed.

Decision 

A conversation can start with a page, its language and a skill. POST /ai-chat/conversations/create takes pageUid, languageUid and skill; the module URL takes the same as &pageUid=, &languageUid= and &skill= and replaces them with &conversation=<uid> once the conversation exists, so a reload opens it instead of starting another. The checks are the user's own: the page must be one they may show (BackendUtility::readPageAccess() with PAGE_SHOW), the language one of the page's site that they may edit (checkLanguageAccess(); a page outside every site has language 0 only), the skill one of the catalogue. A refused start creates nothing.

The language is part of the view context. view_context gains languageId, kept only with a page. The browser sends it where the module URL carries it; the page module usually keeps it in its module data, so a turn on the same page without a language keeps the one the conversation started with. The system prompt names it next to the page, when the user may edit that language.

The skill is stored on the conversation and passed to every run through SkillCatalogueInterface, the one place that knows how nr-llm takes a skill per run. The catalogue is the enabled skills attached to the chat's configuration or Task (the Task the user's groups map to, ADR-015). A process skill goes out as an invocation (below); any other skill goes as a forced skill (RunAugmentation), which nr-llm composes like any skill — as fenced reference data, under its trust and data-class ceilings — and deduplicates against the attached ones. Where nr-llm lacks the classes and members this needs, the catalogue is empty and every run goes without a skill; an identifier is still stored, so nothing has to be repeated once nr-llm can take it. The column is written on its own, like the instructions, so a turn that settles later cannot put an old skill back.

"/" in the input picks the skill. A / with no space after it opens the catalogue as a list on the input (ARIA combobox on the textarea: arrow keys, Enter or Tab to pick, Escape to close). Picking sets the conversation's skill (POST /ai-chat/conversations/skill) and sends no message; the skill is shown above the input with a button to remove it.

An invocation, with the page as its subject. nr-llm ADR-214 passes a process skill as an invocation — the skill's uid and a subject record — rather than as a forced skill on every turn, and skips process skills on the forced path. NrLlmSkillInvocation (behind SkillInvocationInterface) builds nr-llm's SkillInvocation: the skill's uid from the catalogue and the conversation's page as the subject record (pages, uid, the page's language; 0 when the chat does not know it). Only a skill the catalogue marks as a process is invoked; any other skill goes as a forced skill on every turn, as before. The skill's uid is also kept beside the identifier (column skill_uid, 0 when the catalogue did not know it).

The invocation goes out once; later turns name their predecessor. nr-llm ADR-228 derives a running process from the run before: a turn names that run (predecessorRunUuid) and carries no invocation. The conversation stores its last run in last_run_uuid after every run nr-llm recorded — completed, failed, refused, stopped or waiting — and, unlike approval_run_uuid, keeps it through every status. A run nr-llm could not record has no uuid and leaves the previous one. The invocation goes out on the conversation's first run and on the turn after a process skill is picked in the "/" list (skill_start_pending, set by the pick and cleared once a recorded run carried the invocation); that turn is a new start and names no predecessor. A turn whose skill has left the catalogue still names its predecessor, so nr-llm can report that it dropped the process.

A dropped process is said once. When nr-llm drops the process — its skill was detached from the configuration, the turn runs on another configuration, the run it continues from is gone or not the user's, or too many turns in a row could not continue it — it records a dropped step with the reason, which the run result carries on every outcome. The chat adds one line before the answer, stored language-neutral with the notice processDropped and the reason; the reader sees a sentence for that reason in their own language (detached, configuration_changed, predecessor_gone, refused_too_often; a generic one for a reason the chat does not know), and the model is not sent the line. nr-llm records no process for such a run, so the next turn continues none. DroppedSource and DroppedSourceReason are marked internal in nr-llm; the chat reads them until nr-llm declares them API.

No skill on a four-eyes configuration. nr-llm ADR-214 decides a guided process only on the chat card; where the configuration requires a second approver (nr-llm ADR-172), the run's owner cannot release their own write there. A skill is therefore refused at the start and when it is picked (409, error.skillSecondApprover) when the chat Task's configuration requires a second approver. nr-llm marks process skills (Skill::isProcess(), nr-llm ADR-214), and only those are refused. A skill picked before the configuration was switched to four-eyes keeps running.

Consequences 

  • New columns skill_identifier, skill_uid, skill_start_pending and last_run_uuid, new routes ai_chat_skills and ai_chat_conversation_skill. Run the database analyzer after upgrading.
  • A process skill goes out as nr-llm's SkillInvocation and is continued through predecessorRunUuid; nr-llm decides from its approval which version runs (nr-llm ADR-214, ADR-228). Any other skill reaches the model as a forced skill, as reference data rather than instructions.
  • A skill picked while a turn runs is refused, as the instructions are.
  • The chat requires nr-llm 0.41, which carries SkillInvocation, SubjectRecord and predecessorRunUuid (nr-llm ADR-228).

ADR-020: Guided processes report progress and focus 

Status: Proposed

Date: 2026-10-08

Context 

A guided process — a skill that walks an editor through a page point by point — needs three things from the chat that a free conversation does not:

  • Where it stands. The header shows the page, the language and the point: "Über uns · Deutsch · Punkt 2 von 5".
  • What it is about. The content element of the current point is highlighted in the page module beside the chat.

Open points — a skipped finding that stays open beyond the conversation — are not part of this decision. nr-llm ADR-214 has them recorded by this extension from the denial reason of the approval card, not by a tool the model calls, which needs the reason and the conversation's skill; they follow in their own change.

The process itself is a skill; the chat provides capabilities any skill can use, and nr-llm runs and records the run. The model reaches capabilities through tools. A tool knows the run it belongs to (nr-llm's ToolExecutionContext carries the acting user and the run reference), not the conversation; the chat knows the conversation and learns the run's uuid when the run returns.

Decision 

Two tools, registered with nr-llm through ToolInterface (Classes/Tool/):

Tool Effect
chat_set_progress Label, current point, total and a completion flag, for the header.
chat_highlight_element One tt_content uid, for the page module.

A completion report ends the tour. chat_set_progress with completed shows the process as done in the header and drops the highlight. The conversation keeps completed in its guided state, which is where the end of a forced skill (nr-llm ADR-214, the release of the process pin) can be read once the conversation carries one.

Run state, keyed by the run. Progress and highlight are written to tx_nrmcpagent_run_state under the run's uuid and the acting user. When the run returns — completed, waiting for an approval or an answer, failed — ChatService::applyResult() hands the row to GuidedStateLinker, which copies it into the conversation (guided_state) and deletes it. Only the conversation owner's row is read, so knowing a run uuid gives no access to anyone's state. The header therefore changes when a run pauses or ends, not while it works; a guided process pauses at every proposal, so that is when the editor looks.

The highlight names a record, never markup. The tool accepts a uid, and only one the acting user may see: the record exists, tables_select allows tt_content, the record's language is allowed and its page passes readPageAccess with PAGE_SHOW. Every refusal has the same text, so a uid alone does not reveal whether a record exists. The linker keeps the highlight only when the element's page is the conversation's page (view_context). The model never supplies a selector.

postMessage contract. The floating panel (ai-chat-panel) posts

{type: 'nr-mcp-agent:highlight', version: 1, table: 'tt_content', uid: 100}
Copied!

to the backend's module frame (list_frame, the name TYPO3 13.4 and 14.3 give it) with targetOrigin set to its own origin, once per element. page-highlight.js, loaded into the page module through ModifyPageLayoutContentEvent, accepts a message only from the same origin and from its parent window, checks every field, and marks the element by TYPO3's own id element-tt_content-<uid>. Anything else is ignored. The full-page chat module and the popped-out panel have no page module beside them and send nothing; the progress shows on every surface.

Group, data class, effect. Group nr_mcp_agent (an administrator or a configuration's tool groups can switch both off together), data class EDITOR_CONTENT and enabled by default. Without a declared data class nr-llm treats an unknown group as secret-adjacent and withholds it from every cloud provider. Both tools are reads (READ_ONLY), as nr-llm ADR-214 item 9 classifies progress and highlight: they change no TYPO3 record, and their only writes are the chat's own bookkeeping, an upsert on the run's uuid. In a process run a read executes while the editor decides the pending write, so the header and the highlight can show what the card is about.

Consequences 

  • A skill can show progress and focus an element without the chat knowing the skill.
  • Placement: nr-llm ADR-214 has nr-llm ship progress and highlight as builtins, the progress report recorded as a run event and the highlight limited to targets the run registered from its subject record. These tools live here until then; the run-keyed state makes the switch cheap, since nothing here depends on the conversation until the run returns. The highlight here accepts any content element of the conversation's page the user may see, which is wider than a registered target list.
  • Progress appears at the pause, not live.
  • The cross-frame delivery is tested at both ends against the contract (Jest); a browser run against a backend is still needed to prove it end to end.
  • One new table. Run-state rows live from a tool call until the run returns to the chat; ai-chat:cleanup removes rows no chat took over after a day.
  • Open question for nr-llm: nr-llm offers every enabled tool to every run that does not narrow its tool list (ToolCallPolicy::explain() falls back to the enabled set). Runs started outside the chat — AI Tasks, other extensions — are therefore offered the two tools as well. Their run state is never taken over and is purged by ai-chat:cleanup. Restricting the tools to chat runs needs a way for a tool to learn the run's caller, or a per-run tool list, from nr-llm.

ADR-021: Editors approve their own changes in chat 

Status: Accepted

Date: 2026-10-08

Context 

A guided process proposes a change and the editor presses "Übernehmen". In nr-llm a proposed write is an approval (nr-llm ADR-134), so "Übernehmen" is the approve button of the approval card. The chat offered that card only to administrators and to users of nr-llm's AI Tasks module (mayDecideApprovals()): editors who should not see every run of the installation could not apply the changes they asked for.

nr-llm itself lets the owner of a run decide it (mayActOnRun()), checks the approver against the tool policy (nr-llm ADR-133) and, where a configuration requires a second approver, refuses the initiator's approval (nr-llm ADR-172). The module check is the chat's own restriction.

Decision 

A new backend group permission, "Approve own changes in the chat" (customPermOptions, stored as tx_nrmcpagent:approve_own_changes), lets an editor decide the approvals of their own conversations. It is added beside the module rule, not instead of it: administrators and module users decide as before.

Approving needs the change's preview, built with the editor's rights. The brief asked for "the record permissions the write needs". nr-llm offers no question for that before the write runs; the closest signal is the approval preview, which the tool computes as the acting user and which fails, with the tool's reason, for a plan the user may not carry out. So an editor deciding by the new permission may approve only when every pending call has a preview that did not fail; otherwise the card offers the denial only and says that someone with AI Tasks can approve. This is a proxy, and it is stated as one: the write itself still runs as the conversation's owner, where TYPO3's DataHandler enforces the record permissions, and a tool without a preview stays with administrators and module users. The check is repeated on the server when the decision arrives, from the run as it waits then.

Four-eyes is shown, not discovered. In the chat the reader is always the run's initiator. When the run's configuration requires a second approver (read from the run's own configurationUid), the card offers no approve button to anyone and names AI Tasks, where a colleague approves; the denial stays, as nr-llm allows it. A refusal nr-llm still returns (SelfApprovalDeniedException) is a hand-back that keeps the card, no longer a failed conversation.

Consequences 

  • Integrators grant the permission per backend group under "Custom module options".
  • getMessages() returns pendingApproval.approveBlocked: '', secondApprover or preview.
  • An editor with the permission sees the card, may always deny, and may approve changes nr-llm could preview for them.

ADR-022: Open points of guided processes 

Status: Proposed

Date: 2026-10-09

Context 

A guided process proposes one change at a time on an approval card with three answers: Übernehmen, Andere Variante and Überspringen (ADR-018). A skipped proposal is a finding the editor did not want now, not one that is wrong. nr-llm ADR-214 (item 9, "Open points persist in nr_mcp_agent") has the chat keep such findings beyond the conversation and offer them again the next time the process runs on the record, and it fixes the rules:

  • Recorded server-side in exactly one case: the editor answers a card with Überspringen. No tool records one, because a model call cannot guarantee that the record follows the card decision.
  • Keyed by the process skill, the subject record and the target record and fields of the pending write, as nr-llm's PendingCallView::$pendingTarget names them. The preview's prose is never parsed.
  • Closed only by an applied write to the same record and field: a card the chat approved whose call came back with its write step stating COMPLETE and no hook failure after the write.

The parts of the key come from three changes: the card's answers and the denial reason (ADR-018), the conversation's skill and page (ADR-019), and the chat's tools (ADR-020).

Decision 

Recording. The worker reads the card before it hands the decision to nr-llm, from the view whose turn digest is the decision's, so the key is what the card showed. Once approve() has accepted a denial with the reason skip, OpenPointTracker stores one row per field of the target in tx_nrmcpagent_open_point: process skill uid, subject record, target table, uid and field. A write that names a record but no field (a move, a delete) is one row with an empty field. Nothing is recorded when:

  • the reason is variant (a new proposal follows), or the denial has none;
  • the pending call names no target — it creates its record, or its tool does not say — or the card holds more than one call;
  • the conversation has no known skill uid or page;
  • nr-llm refuses the decision and hands the card back, or the continuation fails;
  • a proposal is withdrawn by a new message, or the run is cancelled.

A skip that reaches the worker always comes from a card that offered it: the request turns a reason sent for any other card into a plain denial (ADR-018).

The subject is the conversation's page. nr-llm ADR-214 names the invocation's subject record; nr-llm has no invocation API yet, so the chat uses the page the conversation is about (ADR-019).

Closing. After an approved decision, AppliedWrite reads the result nr-llm returned for it: the first tool step is the approved call, and the write step that follows it must state WriteCompleteness::COMPLETE with hookFailedAfterWrite not set. Then every open point on the card's target record and fields is closed, whichever process recorded it, by deleting the row: the table holds what is open. A partial write, a hook failure after the write, a failed call, a call without a write step and a write that states no completeness close nothing.

Offered again. A conversation with a skill and a page gets the open points of that skill on that page in its system prompt, as record identities and field names (at most 20), and the read tool chat_list_open_points lists a page's open points for a user who may show the page, leaving out points on tables the user may not read. Neither holds a value or the proposal's text.

Site-wide, for the dashboard. OpenPointVisibility::visibleForCurrentUser() lists the open points the current backend user may see across the site, newest first, the limit counted after the visibility rule. Each VisibleOpenPoint carries the page, the target record's language, the skill's uid and nr-llm's stored identifier, the target and the date, and a sentence in the user's backend language, built from the TCA labels and the page title ("<Field> is missing on “<Page>”" for an empty field, "<Field> open on “<Page>”" otherwise; the templates are the labels openPoint.missing and openPoint.open), never model text. The visibility rule also leaves out a point on a field the user's groups may not edit (exclude without non_exclude_fields), for every reader.

Process runs. SkillProcessRunDetector implements ProcessRunDetectorInterface (ADR-018): a conversation with a skill runs a guided process unless nr-llm marks the skill as plain (Skill::isProcess()). With it, a write in a skill conversation gets the three answers, and a new message withdraws a waiting run of that conversation (ADR-018).

Consequences 

  • New table tx_nrmcpagent_open_point: run the database analyzer after upgrading.
  • Needs nr-llm 0.41. The card's target (pendingTarget, nr-llm PR 1024) and the write's completeness (writeCompleteness, hookFailedAfterWrite, nr-llm PR 1023) come from nr-llm 0.41.
  • A finding lost to a withdrawn proposal, a refused decision or a failed continuation is found again the next time the process runs on the record (nr-llm ADR-214).
  • An open point stays when its target record is deleted outside the chat; it is closed only by an applied write.
  • The subject becomes the invocation's subject record once nr-llm can start a run with an invocation; the key does not change.

Amendment of 2026-10-23: Behalten closes 

Context. Überspringen means "not now" and records an open point. When the current content is right, the editor had no answer that settled the point: Überspringen kept offering it, and Übernehmen would have changed it.

Decision. A third denial, Behalten (DenyReason::Keep), is the decision that the current content stays. Once approve() has accepted the denial, OpenPointTracker::settle() closes every open point on the card's target record and fields (OpenPointRepository::close(), whoever recorded them) and records none. Without a target nothing is closed, as for every other answer. To nr-llm the denial is ApprovalDenialReason::KEEP (SKIP before 2026-10-24, see ADR-023), so the model moves on to the next point.

Consequences. Behalten is the second way a point is closed, beside an applied write. A proposal answered with it is a ProposalOutcome::Kept and is listed under Behalten in the tour summary.

ADR-023: The chat guides a process 

Status: Proposed

Date: 2026-10-10

Amended by: ADR-025: The Skill Says Whether It Needs a Page — whether a conversation keeps its page, asks for one or binds none follows the skill's declared page need; the process marker stays the default for a skill that declares nothing.

Context 

A guided process walks an editor through a page one point at a time (ADR-020): a skill run that proposes one write per point, decided on the chat's card with Übernehmen, Andere Variante or Überspringen (ADR-018), with its open points kept beyond the conversation (ADR-022). nr-llm ADR-214 fixes what the runtime guarantees: one pending write per turn in a process, the card as the only place a process write is decided, and nothing the model writes as the source of what the chat states about a record.

What the editor sees around that was still the generic chat: a technical approval card, the model's own words for what happened, a header with the process's label, a frame around an element without a name, and no way to end, restart or move the process. This ADR fixes the chat's side.

The rule throughout: every word the chat shows about a proposal, its outcome, the page or the process comes from the server — the card, the run's result, the conversation, the schema's labels — never from the model's text.

Decision 

The proposal is the card. A card of a write in a process run with one pending call renders in the message flow as a quoted block: the change's name, Betroffen (nr-llm's structured target with the schema's labels), and, where nr-llm structures the preview, Aktuell, Vorschlag and the length against the configured range. The three answers sit directly under it; Übernehmen is the approve answer's label. Without a structured preview the block shows nr-llm's preview lines as they are; nothing is parsed from them. The structured values are raw — stored rich text carries its HTML, the proposed value is model text — and the chat binds them as text only.

The chat states each outcome. When a card is decided, the conversation keeps the card's target and, once the run's result is in, the outcome (ApprovedCallReading, nr-llm ADR-214 item 9): applied when the write states COMPLETE and no hook failed after it; check when a hook failed after the write, the write states no completeness, or a run that was cancelled, lost its lease or failed has no write step; not applied for an error, a partial write, or no write step on a run that did not abort; and skipped. The chat writes its own status line from it inside the message list, and once the process reports completion a summary grouped into applied, to check and skipped. The model may add a sentence; it never reports the outcome.

The header names the page. In a conversation about a page with a skill the header reads "<Seite> · <Sprache> · Punkt n von m", the page by its title for a user who may show it, the language by its name, and "Analyse läuft" before the first progress report. Short of space, only the page name is shortened.

The highlight is labelled. The page module's frame gets a badge with a fixed label from the page module's own language file, never from the message, and the floating panel announces the marked element in a status region. Which highlight a conversation keeps goes through HighlightTargetPolicyInterface; today an element on the conversation's page.

The process can be ended. A × beside the progress ends it through POST /ai-chat/conversations/end-tour: a run waiting on a proposal or a question is withdrawn with nr-llm's guarded cancel, and the conversation's skill and guided state are cleared. Refused while a turn or a decision is carried out; when the guarded cancel loses, the row goes back to what it was. Nothing is written to a record, and applied changes, the outcomes shown and the open points stay. The page module's mark is removed through a clear message, the second shape of the highlight contract. Removing the skill above the input during a process takes the same path.

nr-llm's refusals of a process decision. When nr-llm refuses a decision because the run is a guided process (nr-llm PR 1029), the chat keeps the conversation usable: a run that still waits brings its card or question back with the chat's own sentence; a run nr-llm stopped because the configuration requires a second approver leaves the conversation idle, and ends the process as the × does, with the reason. A process nr-llm drops before a run, for any reason (ADR-019), ends the same way: its skill and header go, and the dropped line says why.

Starting anew is the chat's, behind one gate. A process without a page asks for one; at its end, Andere Seite wählen starts it in a new conversation; when the page module shows another page, Zur neuen Seite wechseln ends it and starts it there. Each of these is offered only where the poll's tourStart says a run can be started with an invocation (SkillInvocationInterface): nr-llm skips process skills on the forced path, so a new start without an invocation would run without its process. Fertig, the link to the dashboard where it is installed, and Bei „<Seite>“ bleiben do not depend on it. The page is the one the page module beside the floating panel shows, read from its URL while it matters.

What else the process keeps fixed.

  • The three answers keep their names in every process: Übernehmen, Andere Variante, Überspringen; labels per action are not used. A tool that proposes an image is to name its variant answer itself ("Anderes Bild"); nr-llm has no way to declare it yet, so the chat shows Andere Variante for every tool.
  • The overview of findings is the skill's own text. The number of points in it is the m the process reports with chat_set_progress.
  • Where a source is missing, the process invents nothing. The next steps are a choice through nr-llm's choice builtin ask_choice (nr-llm PR 1029), shown with the reply buttons of ADR-018: the options are the call's own, rendered as text, and the button sends its option as the answer. Its skip option is worded by the skill ("Ohne <Thema> weiter"), so that Überspringen keeps its one meaning; the chat adds no wording. The same holds for plain choices such as "Los geht's" / "Fertig" after the overview. Nothing in the chat is specific to the tool: its schema is a choice under ADR-018's contract.
  • The page choice confirms with Diese Seite prüfen rather than starting on the click in the page tree, so browsing the tree starts nothing.
  • Suggested pages come from a deterministic provider, never from the model, at most five, each a page the user may edit in that language: for the SEO process pages without a meta description, with a title shorter than 30 characters or without a social image, open points first; for the content process its open points only.
  • Context actions such as "Kürzer" or "Sachlicher formulieren" are not part of this release.

Not adopted, for now.

  • Site-wide scan tiles, and the widgets "Website prüfen" and "Website bearbeiten" (bulk operations): no scanner exists, and bulk writes conflict with one write per turn. Superseded for the check of missing alt texts and the widget "Website prüfen" by ADR-026: A Site-Wide Check for Missing Alt Texts; bulk operations stay not adopted.
  • Markers in the page tree: the tree is core UI, and a dot has no text alternative.
  • An analysis checklist with ticking checkboxes: no tool reports progress per criterion, and the chat's Markdown strips input. Instead, the header shows the label "Analyse läuft" without a count.
  • Internal links set on other pages within one proposal: one approval-bound call per turn, and the targets lie outside the subject page.
  • "CTA nicht ändern" as a fourth answer on the card: Überspringen means "jetzt nicht". A "dismiss, record nothing" answer would need its own ADR decision.
  • A site selector and "Einstellungen" in the dashboard header.

Seams. Where nr-llm does not have what a decision needs, the chat reads it through one class or interface and falls back:

  • StructuredPreview reads PendingCallView::structuredPreviewArray() (nr-llm PR 1036). Without entries: the preview lines.
  • ProcessPinReleaseInterface releases the process pin of an ended process. ChatService implements it with nr-llm's AgentRuntimeInterface::endProcess() (ADR-228), as the run's initiator, for the conversation's last run. The × and the second-approver stop call it, and both also clear the conversation's last run, so no later turn names the ended process's run as its predecessor. A failing endProcess() is logged; the chat state is cleared either way and the editor sees nothing more.
  • SkillInvocationInterface (ADR-019) starts a run with an invocation; NrLlmSkillInvocation implements it on nr-llm's SkillInvocation. Without it no new start is offered.
  • HighlightTargetPolicyInterface limits the highlight to the targets a run registered from its invocation's subject, once nr-llm has them.

Consequences 

  • Two conversation columns, approval_card and card_outcomes: run the database analyzer after upgrading.
  • Needs nr-llm 0.41 for the target, the outcome, the guarded cancel (nr-llm PRs 1023, 1024, 1025) and the structured preview (nr-llm PR 1036).
  • Suggested pages for the choice and Nächste Seite prüfen come from the dashboard's page suggestions and are not part of the chat yet.
  • The three answers keep one meaning each. A context action, once one is built, is a message of its own, which withdraws the waiting proposal.

Amendment: the first run starts with the conversation 

Date: 2026-10-21

Context. A conversation created with a process skill and a page — from the dashboard's "Eine Seite verbessern", or by Zur neuen Seite wechseln — showed the header with "Analyse läuft" and ran nothing: the only thing that dispatched a run was a sent message, and the editor was expected to type one. The decision above says the header shows "Analyse läuft" until the first progress report, and the process skills say the editor formulates no prompt. Separately, a message sent from the chat module carried page 0, because no page module sits beside it, and the turn replaced the conversation's page with it; the run then started without a subject and asked for a page.

Decision.

  • A process about a page starts when the conversation is created. POST /ai-chat/conversations/create with a page and a process skill, where nr-llm can start a run with an invocation, appends the first turn and hands the conversation to the worker in the same request. These are the conditions of tourStart, so every start the chat offers begins; a skill the catalogue does not mark counts as a process there and here. The run carries the invocation with the page as its subject (ADR-019), as every first run of a process skill does. Without a page the chat asks for one, as before; a skill marked as no process waits for the editor's message.
  • The first turn is the page choice's own words. nr-llm has no start without a user message: its runs are built over a transcript, and a provider such as Anthropic's refuses a request without one. The turn is therefore Diese Seite prüfen in the editor's language — the label of the page choice's button, which starts a process without a page in the same way. It is stored and shown like any message, never hidden, and the model receives exactly what the transcript shows. The click on the dashboard is the editor's confirmation, so browsing the page tree still starts nothing.
  • The start is a turn. The limit on running conversations applies; over it the start is refused and nothing is created, as every refused start (ADR-019).
  • A process keeps its page. While a conversation has a process skill (or a skill the catalogue does not mark) and a page, a sent or edited message updates the module of the view context, never its page or language: another page is a new conversation through Zur neuen Seite wechseln. A process without a page takes the page its page choice sends; a conversation without a skill follows the browser as before.

Amendment: a process run has its own round cap 

Date: 2026-10-22

Context. nr-llm stops a run after a number of model rounds. The cap is the caller's to ask for: nr-llm ADR-101 makes maxIterations on AgentRunRequest an explicit request, clamped to the runtime's ceiling (AgentRuntime::MAX_ITERATIONS, 20), and keeps the loop's own default of 5 when the caller asks for nothing. The chat asked for nothing, so every tour turn had 5 rounds. A run that reaches the cap ends with one more call without tools, whose answer may be empty.

Three tour turns on the demo on 2026-10-22 ran into it. Each used its five rounds and ended with the extra call, before the step that closes the turn:

  • SEO tour, first turn: four rounds of reads (the page, its elements, the open points, two image searches), the overview with chat_set_progress in round 5. The extra call returned no text; the chat stored the empty answer as an empty message, and the tour stood at "Punkt 0 von 2" with nothing to click. ask_choice never ran.
  • Content tour, first turn: four rounds of reads with three to five calls each, chat_set_progress in round 5. The question "Los geht's" / "Fertig" came as plain text, not as ask_choice.
  • Content tour, the turn after the editor typed "Los geht's": three rounds of reads, chat_set_progress in round 4, chat_highlight_element in round 5. The proposal never ran, so no card appeared.

So a tour turn needs three to four rounds of reads, then the progress, the highlight where there is one, and the choice or the proposal: six rounds in all three turns, more when the model reads more. The model calls the chat's tools one per round even where the skill allows them together. The one segment measured after an answered choice, up to the proposal, needed 2.

Decision.

  • A run that starts or continues a process asks for its own cap. The setting processRunMaxIterations (default 12) goes on every run whose conversation has a skill the catalogue marks as a process: the first run, which carries the invocation, and every turn that names its predecessor. 12 is twice the six rounds each measured turn needed and leaves room for a page whose elements are read one by one; it stays below nr-llm's ceiling, which clamps any larger value. A run without a process asks for nothing, as before.
  • An empty answer is no message. A completed run whose final text is empty adds no assistant message.
  • A run stopped at the cap says so. When nr-llm reports the termination reason max_iterations, the chat adds one line, stored language-neutral with the notice roundLimit and shown in the reader's language; the model is not sent it. In a running process the chat offers Weiter beneath it while nothing else was sent and nothing waits in the input, a draft or an attachment. Like Diese Seite prüfen, the button sends its own words as the editor's turn, shown like any message, and that turn continues the process from its predecessor (ADR-019). A choice the chat offers itself, worded by the chat, as Fertig and Andere Seite wählen are; it is not an answer to a choice of the skill.

Consequences.

  • A tour turn can cost up to 12 rounds instead of 5. The forced final call of a stopped run comes on top, as before.
  • nr-llm's approve() and submitInput() take no cap, and its resume passes none on: a segment after Übernehmen or after a choice gets the loop's default of 5, whatever the run asked for. One measured segment of that kind needed 2. Resumed segments get the run's cap once nr-llm keeps it across a resume.

Amendment: a step left open offers to go on 

Date: 2026-10-23

Context. A step of a process ends with a choice (ask_choice), a proposal (a writing tool, which waits for its decision) or the end of the process (chat_set_progress with completed). Each of them leaves the run waiting or the process completed. The model does not always do so. On the demo on 2026-10-23, with the round cap of the previous amendment in place, three runs ended their step otherwise:

  • Run 953235de (content tour, first turn) read the page in three rounds and wrote the overview in the fourth, then ended without chat_set_progress and without ask_choice. Two further first turns on the same page ended with the choice.
  • After Überspringen the same tour wrote "Als Nächstes geht es um den Nutzenabschnitt." and ended without the proposal.
  • Run b2a7165a (content tour, last point) set the progress to 3 of 3 after the last choice and ended without completed, so the chat showed no summary.

In each case the chat showed text, or nothing, and no control to answer: the editor had to guess that a message would make the process go on.

Decision. A completed run of a conversation whose skill the catalogue marks as a process, whose progress is not completed and which did not stop at the cap, gets one line, stored language-neutral with the notice stepOpen and shown in the reader's language; the model is not sent it. The chat offers Weiter beneath it under the same conditions as beneath the line about the cap.

The chat does not infer the end of the process from a progress of n of n: a model that counts the last point before its proposal would end the process with the proposal still unmade. Weiter lets the model finish instead.

Consequences.

  • A step that legitimately ends with text, such as a short answer to the editor's question or the wait for information the editor said they would add, gets the line too. The chat cannot tell the two cases apart, so the line says only what holds in both: the assistant waits for the editor, who can reply or continue with Weiter. It does not say that a step was cut short, which would read as a fault where the wait is intended.

Amendment of 2026-10-23: the approval card is a system step 

Context. The card of a pending write sat inside the status line of the chat: centred, small, italic and blue like "Not carried out", and worded in the first person of a message. Editors read it as something the assistant said, although it is not part of the conversation: the model orders a write, the system checks the backend setting, and only because that setting requires approval does it pause and show the card. Without the setting the write is stored directly and no card appears.

Decision. The chat has two kinds of content. Chat messages are written by the model. The approval card is rendered mechanically from the pending write call and shows what is stored where. The card stays recognisably different from a message, and a system status line does not speak in the assistant's first person.

  • The card is a frame of its own, outside .message and .message-row: full width of the message column, left edge of the column, no avatar, no bubble shape. A header band carries an icon and "Freigabe erforderlich" / "Approval required"; the first line below it reads "Folgende Änderung wartet auf deine Freigabe". Text is regular, not italic. The frame keeps tabindex="-1" and the status-notice class, which focus returns to after a decision.
  • The heading names the field when the change touches exactly one: "„Titel“ ändern", with the structured preview's label where nr-llm sends one, else the schema's. The approve button of a plain card keeps the action label. Otherwise the heading stays as before.
  • "Betroffen" reads "Diese Seite · <fields>" when the target is the page the conversation is about; any other record keeps table and uid.
  • The technical details (tool name, arguments, technical line) are for administrators. The response carries showTechnicalDetails; for anyone else the three values are empty in the response, not only hidden by the card.
  • The status line of a variant decision reads "Vorschlag nicht übernommen. Eine andere Variante wird erstellt." The button keeps "Andere Variante", which is also the transcript line the model reads.
  • The answers of the card and the tour (btn-answer) and message text are 14px; the compact btn-sm of the header stays at 12px.
  • The skill chip reads "Aktuelle Aufgabe:" and the skill's human title (SkillLabels), else its name.

Amendment of 2026-10-23: a third denial, Behalten 

The "Not adopted" entry above rejected a "dismiss, record nothing" answer until it had its own decision. This is it.

Decision. The card of a proposal has four buttons in this order: Übernehmen, Andere Variante, Behalten, Überspringen (labels chat.approvalApply, chat.approvalVariant, chat.approvalKeep, chat.approvalSkip). Behalten writes nothing and records no open point; it closes the open points on the proposal's record and fields (ADR-022). The chat confirms with "Behalten — der bisherige Inhalt bleibt." and the transcript line the model reads is the button's label. The outcome is kept and the tour summary lists it under its own heading Behalten. nr-llm is unchanged: the denial reaches it as skip (superseded by the amendment of 2026-10-24 below).

Amendment of 2026-10-23: the process goes on by itself after an approval 

Context. After Übernehmen the model writes its confirmation and the run ends. Where it named no next choice or proposal, the chat showed the line "step open" and the button Weiter (amendment above), so the editor who had just approved had to press a second button to see the next point.

Decision. When a decision is approved, its run completes, and the process is not finished and not cut off at the round cap (the case that the line "step open" reports), the worker does not append that line. It starts one more turn itself, which is the Weiter the editor would have pressed: a turn over the same transcript that names the previous run as its predecessor, with the instruction "Continue with the process." for this turn only. The instruction is not stored, so the transcript shows no message the editor did not write.

The turn happens at most once per approval, by construction:

  • ChatService::applyResult() returns "continue" only for the result of an approved decision, which is the one call that passes the option. performRecordedDecision() is the only caller that reacts to it.
  • The continuation turn applies its own result without the option. If it leaves the step open again, it appends the line "step open" and the chat offers Weiter, as before. Nothing calls performRecordedDecision() for the continuation, so no further turn can start.
  • The conversation is not idle between the two runs. The answer of the approval is stored with the status unchanged, the continuation then takes the row, and the poll of the chat, which stops on an idle conversation, sees one continuous busy phase.

A denial (Andere Variante, Überspringen, and any later denial) starts nothing: the denial is the editor's answer. A run cut off at the round cap keeps its own line and button. A continuation that fails ends as any failed turn does, with the error shown; the chat does not retry it.

The language sentence. The system prompt and the site-language block no longer tell the model to explain that "the other language versions come from the translation tools of the CMS". When the language of the conversation differs from the language the content is created in, the model says so once, in one short sentence from the editor's point of view ("Vorschläge für diese Seite schreibe ich auf Englisch, weil die Seite englisch ist."), and explains translation tools only when asked about other languages. The rule itself is unchanged: content is created in the default language, and no translations are made unless the editor asks for them.

Amendment of 2026-10-24: Behalten reaches nr-llm as keep 

Context. nr-llm had only variant and skip, so the amendment of 2026-10-23 above sent Behalten as skip. nr-llm then told the model that whoever declined "chose to skip it", and after Behalten the assistant wrote to the editor that the point had been skipped. The editor had kept the content. This was measured on the demo with nr-mcp-agent 0.17.8 and nr-llm 0.41.3.

Decision. nr-llm 0.41.4 adds ApprovalDenialReason::KEEP. ApprovalDecisionFactory::denialReason() maps DenyReason::Keep to it, and the extension requires nr-llm ^0.41.4. Übernehmen, Andere Variante and Überspringen are unchanged, and so is everything the chat does with Behalten itself (ADR-022).

Consequence. The failure signature of a regression is the old sentence in the assistant's answer after Behalten: it calls the point skipped.

ADR-024: The Chat as a Column Beside the Page Tree 

Status: Accepted

Date: 2026-10-24

Context 

The floating panel (ADR-011: Floating Chat Panel Outside the Module iframe) always covers part of the module it is used with. A guided process (ADR-023: The chat guides a process) works on the page the page module shows, and the editor reads the chat and the page side by side. The design places the chat as a column between the page tree and the module, with a handle to change its width, and a small bar in the corner when it is minimized.

TYPO3 offers no API for a column in the backend frame, in 13.4 or 14.3. Alternatives considered:

  • A panel below the page tree: the navigation container shows exactly one component, and only modules with a tree have one. The chat would disappear in the Dashboard, when the tree is collapsed, and on narrow screens.
  • Padding on the scaffold for a drawer on the right: shrinks typo3-backend-content-navigation in 14.3, which turns the page tree into a flyout below 750 px.
  • The core context panel (14.2): a modal dialog; it blocks the page tree, which the chat has to follow.
  • A sibling before the module router in the content area: the content area outlives module switches and holds the router in both versions.

Decision 

The <ai-chat-panel> gets a second layout, column. Docked, the element is inserted before typo3-backend-module-router in the content area: the content slot of typo3-backend-content-navigation[identifier="backend"] in 14.3, .t3js-scaffold-content-module in 13.4. Both are horizontal flex rows. The area is found by its shape (chat-column.js), not by the TYPO3 version, and only where the router is its direct child: the workspace-denied and no-module-access pages render an infobox there, and the chat stays floating.

  • While the column is open, the area carries data-nr-chat-column="open", and a constructed stylesheet on the top document gives the router flex: 1 1 0; min-width: 0. Without it the router's :host { width: 100%; flex: 1 0 auto } overflows the area.
  • The column has contain: size: in 13.4 the content area is a grid cell that grows with its content, so a long transcript made the column as tall as the transcript instead of scrolling it.
  • The page tree's component is not resized, so 14.3's flyout breakpoint is not reached by docking the chat.
  • Width: at least 320 px, at most half the content area (core's page tree uses the same half). A content area narrower than twice the minimum (640 px) gets no column: the panel floats from <body> (attribute narrow), the module keeps the whole area, and the stored layout is not changed, so the panel moves back into the area as a column when the area is wide enough again: 24 px above the threshold, so an area dragged around 640 px does not move it back and forth. Drawing and parent change together; while a request holds the move back, the panel keeps the drawing that fits where it is. Inside the area a floating panel would be confined to the area's stacking layer, which in 14.3 lies under the backend header once the area is narrower than 750 px. The setting follows the user to smaller devices, which is why this is decided from the area's width, measured again when the column is asked for, not stored. The floating panel offers the column only where the area is wide enough. The splitter follows the WAI-ARIA Window Splitter pattern: a focusable role="separator" with its width as aria-valuenow, arrow keys in steps of 16 px, Home and End to the limits.
  • Layout and width are stored in the backend user's settings with @typo3/backend/storage/persistent.js (nrMcpAgent.chatPanel.layout, nrMcpAgent.chatPanel.columnWidth), like core stores the tree width. The width is written after a drag ends and 400 ms after the last key press. The floating panel's position, height and state stay in localStorage: they describe one browser window.
  • Minimized, the column is the existing collapsed state, drawn as a bar in the bottom right corner. A new state would have repeated what collapsed already means: chat kept, header only, reached by Escape. There is no maximized state in the column.
  • The page the chat is about follows the page tree: ModuleStateStorage (web) is read where the module URL carries a page, because a tree click updates it before the module has loaded the page. The panel listens on the top document to typo3:module-state-storage:update:web, typo3:module-state-storage:update-with-tree-identifier:web and typo3-module-loaded. A module that is not about a page still sends none, as before.
  • The element is placed once, when the toolbar item creates it, and moved only when the user switches the layout: every move reconnects the element and re-initialises the chat controller. The reconnect aborts the controller's requests in flight, so no move happens while one runs: a message being sent or edited, a free-text answer, an approval decision, ending a guided process, saving the instructions, uploading an attachment. The layout and pop-out buttons are disabled meanwhile, and a move to or from a narrow area waits for the request to end.

Consequences 

  • The column depends on undocumented core markup in two shapes. A core change that removes both makes the chat fall back to the floating panel; the Jest tests pin both shapes, the E2E tests check the geometry.
  • CI's E2E job runs against 13.4 and 14.3; the specs feature-detect the backend shell of both.
  • The module menu is not collapsed automatically when the column opens.

ADR-025: The Skill Says Whether It Needs a Page 

Status: Accepted

Date: 2026-10-25

Context 

Whether a conversation with a skill needs a page (ADR-019: A conversation starts about a page, with a skill) is guessed today, in three places:

  • The process marker. ChatApiController treats a process skill (nr-llm ADR-214 item 6), and a skill the catalogue does not mark, as one that works on a page: it keeps the conversation's page (keepsItsPage()), asks for one (tourStart().choosePage) and starts with it (startsTour()). Every other skill follows the page the browser shows.
  • The quick tasks. dashboardQuickTasks takes a |page suffix per entry; with it the Frequent tasks widget asks for the page.
  • The prompt. UserContextPrompt tells the model, when no page is selected, to ask which page is meant (NO_PAGE).

A skill that works on a page without being a process, or a process that works on no page, cannot say so. nr-llm ADR-229 adds the frontmatter key page: required | optional | none and exposes it as Skill::getPageNeed(), null when the skill declares nothing. It is not part of the skill's version, so it changes no approval.

Decision 

The catalogue entry carries the declaration. NrLlmSkillCatalogue adds page to each entry: required, optional, none or null. It is null when the skill declares nothing and when the installed nr-llm has no getPageNeed() (checked with method_exists()), so the chat runs on an nr-llm without the field and the composer and ext_emconf constraints do not rise.

One rule turns it into the page need (SkillPageNeed::of()): the declared value; else required for a process skill and for a skill the catalogue does not mark or does not list (as everywhere since ADR-023: The chat guides a process); else optional. An undeclared skill therefore behaves exactly as before.

Each call site keeps its own axis. The process marker still decides everything about the process: the start of a conversation with its first turn (startsTour()), the refusal on a four-eyes configuration, the invocation, the round cap, the open step and the process-run checks. The page need decides everything about the page:

required
The conversation keeps its page (keepsItsPage(): the sent message updates only the module; a deleted page makes a process read-only). Without a page the chat asks for one (tourStart().choosePage). For a process this needs, as before, an nr-llm that starts a run with an invocation; its page choice sends Diese Seite prüfen as the first turn (tourStart().startsRun true), and tourStart keeps offering Andere Seite wählen and Zur neuen Seite wechseln. A skill that is no process needs no invocation: its turns carry the skill as a forced skill. Its page choice starts nothing (startsRun false): the chat says to choose the page in the page tree and then write what to do, shows no button, and the editor's own next message carries the page the page module shows (queueTurn() binds it while the conversation has none). The editor's words are the first turn, never the page choice's. Zur neuen Seite wechseln opens a new conversation on the new page and leaves this one as it is, without ending it: ending a conversation that is not a process would withdraw no waiting run. For the same reason a deleted page does not make it read-only: it gives the page up and asks for a new one. The prompt keeps NO_PAGE while there is no page.
optional
Today's behaviour of a skill that is no process: the page follows the browser with every message, the prompt names it or says NO_PAGE, no page choice. A process declared optional starts with whatever page the conversation has (its invocation subject) and does not keep it.
none
The conversation is bound to no page. Creating it with a page, picking the skill and sending a message store no page, only the module; the invocation of such a process therefore has no subject. No page choice, no tour header, no read-only state for a deleted page, and the prompt carries neither page lines nor NO_PAGE.

Quick tasks follow the declaration; the suffix stays for skills that declare nothing. An entry asks for the page when its skill declares required, and does not when it declares optional or none, whatever the suffix says: the skill knows best. For a skill that declares nothing, or where nr-llm does not expose the field, |page decides as before. The default of an undeclared process skill (required) is not applied here: such an entry stayed a link without the suffix, and the chat asks for the page after it opens; it still does. Every existing setting therefore behaves as before.

Consequences 

  • A skill author decides the page need once, in the skill; an installation no longer has to repeat it as |page.
  • No new nr-llm requirement. With an nr-llm without ADR-229 nothing changes; the declarations take effect once one with it is installed. PHPStan sees only the installed nr-llm, so the method_exists() guard is always true against one with the field: that one finding is ignored in Build/phpstan/phpstan.neon with reportUnmatched: false, and the check is green against both. The end-to-end tests run against the released nr-llm and therefore cover only the fallback; the declared values are covered by unit and functional tests.
  • The Improve a page widget (dashboardGuidedSkills) is not changed: it offers guided processes about a page and always asks for one.
  • The page choice of a skill that is no process sends nothing: the first turn is the editor's own message, written after choosing the page, and the input stays open during the choice.
  • The recent-chats widget names a conversation's page by the same rule (pageNeed required): a process declared optional is listed without the page the editor last browsed, and is not hidden when that page is not readable.
  • A |page suffix on an entry whose skill declares optional or none is ignored.

ADR-026: A Site-Wide Check for Missing Alt Texts 

Status: Accepted

Date: 2026-10-25

Context 

The product specification "Website Check: Fehlende Alt-Texte" asks for a list of every use of an image that has no alternative text, across pages and records, reached from the dashboard. Each hit is fixed with the chat or marked as decorative. It is a work list, not a guided tour.

ADR-023: The chat guides a process did not adopt site-wide scan tiles and the widget Website prüfen, because no scanner existed. This ADR adds one scanner, for alt texts, and the widget for it. Bulk operations ("Website bearbeiten") stay not adopted.

What exists: alt texts live on the image reference (sys_file_reference.alternative) and, as the file's default, in the file list (sys_file_metadata.alternative). TYPO3 has no field for a decorative image. nr-llm's set_file_alternative_text writes sys_file_metadata, which the specification rules out: an alt text belongs to one use of the image, not to the file.

Decision 

Slices. The feature ships in three:

  1. The check, the "decorative image" field, the list Fehlende Alt-Texte and its two entries on the dashboard (this ADR's subject). No button in the list writes anything.
  2. The write tool, and Optimieren: the chat opens beside the list with the hit's context, proposes an alt text, saves it or marks the image decorative, and moves to the next hit.
  3. The privacy setting Bildanalyse: whether a scaled image is sent to the model, and folders that are never sent.

Where the write tool lives. Writing alternative on an existing reference goes into nr-llm, next to set_file_alternative_text: every content write the chat makes is an nr-llm tool with its approval card (ADR-004: nr-llm as LLM Abstraction Layer, ADR-021: Editors approve their own changes in chat), through DataHandler, so permissions, workspaces and the record history apply, and the tool is useful without this extension. Setting tx_nrmcpagent_decorative is a tool of this extension, registered with nr-llm's tool registry like the guided-state tools (ADR-020: Guided processes report progress and focus): nr-llm does not know the field, and the field goes away with this extension. It writes through DataHandler as well. Whether it asks for approval is slice 2's decision.

The decorative field. sys_file_reference.tx_nrmcpagent_decorative (the extension's prefix; the specification's tx_aiassistant_ is an example), a checkbox, default off, from ext_tables.sql and a TCA override that puts it after Alternative text in imageoverlayPalette. Like Alternative text it is an exclude field: an editor sees it once their group is granted it. No l10n_mode, so each language version of a reference has its own. It is not on sys_file_metadata.

Frontend output. fluid_styled_content's image partial (Media/Rendering/Image.html in 13.4, Image.fluid.html in 14.3, the same markup) gets an override that renders a decorative image with f:image … alt="" and every other image with the core's own line. f:media cannot be used for the decorative branch: it treats alt="" as missing and falls back to the file's alternative text; f:image keeps an explicit empty one. The partial path sits at key 5: above the core's (0), below a site package's (10, styles.templates.partialRootPath).

The path is never added to every site. A content rendering of its own with its image partial below key 5 would lose that partial for every image: bootstrap_package 16 sets its own path at 0 and, at 10, a setting whose default names a folder it does not ship (Partials/ContentElement/; its partials are in Partials/ContentElements/), so key 5 would win and its images would lose their <picture> and srcset output. The path comes only together with fluid_styled_content:

  • Sites with site sets opt in with the set netresearch/nr-mcp-agent-decorative-images, which depends on typo3/fluid-styled-content. A defaultContentRendering addition would not reach them at all: the core adds it before the sets, and fluid_styled_content's TypoScript starts with lib.contentElement >.
  • Sites with sys_template records get it right after fluid_styled_content's static template (addTypoScript(…, 'fluidstyledcontent/Configuration/TypoScript/')), and only when they include that template. bootstrap_package 16.0.1 ships site sets only, no static template, and its sets do not depend on typo3/fluid-styled-content.

No further mechanism is added; the remaining setups are documented in the installation chapter and covered by the rendering test:

  • fluid_styled_content loaded with @import in a sys_template record: the static-template key is read only for Include static entries, so the site imports the set's setup.typoscript after fluid_styled_content's.
  • The static template followed by another content rendering that redefines lib.contentElement without > (bootstrap_package 15 through its static template): key 5 stays and replaces that rendering's partial, so the site removes it with lib.contentElement.partialRootPaths.5 >.
  • bootstrap_package or any other own content rendering never takes the set or the import; it adds the condition to its own image partial.

A site package with its own image partial at a higher key keeps it; the installation documentation gives the condition to add.

Which fields count. Every column of type file in the TCA, read at runtime: a newly installed extension's image fields are checked without configuration (the specification asked whether this is possible: it is; 13.4 migrates the older inline FAL fields to type file). Of these, the file must be an image (sys_file.type). Excluded by default, in altTextExcludedFields: pages.og_image, pages.twitter_image, be_users.avatar and backend_layout.icon (no image with an alt text in the frontend), and pages.nav_icon: bootstrap_package renders it with its icon ViewHelper, which outputs no alt text from the reference. Unlike the specification's "pages = content elements and Medien", every other image field of pages counts too, thumbnail among them: bootstrap_package renders it through the image partial with the reference's alt text, in thumbnail menus. Tables are excluded with altTextExcludedTables (none by default).

Which tab a hit belongs to. A reference is followed to its record and, through inline fields that name their owner (foreign_field), to that record's owner, up to four levels. The outermost record decides: a page or a content element puts the hit under Seiten, any other table under its own tab, labelled with the table's TCA title. So a carousel item's image belongs to its content element on the page, and a content element inside a news record to that record. An excluded table also takes the records it owns with it.

When a hit is open. The image's effective alt text is empty: the reference's own text, or, where the reference has none (NULL), the file list's text in the reference's language where the file has a metadata translation, else in the default language. The file list's text is read in its live version; a draft in a workspace does not decide outside it. A reference whose text was overridden with an empty one is open even if the file list has a text, because the frontend then renders it empty; whitespace counts as empty. A reference for all languages (sys_language_uid -1) is checked against the default language's file list text only; on a translated page the frontend shows that language's metadata translation instead, so such a reference can be listed although a translation has a text, or the other way round.

Not decorative; reference, records on the way and page not deleted. Only references in a field the record's type shows: listed in its showitem, directly or in a palette, and not turned into another field type by columnsOverrides. A reference left in a field after the record's type changed (an image on a Text element that was Text & Images) is in no form and in no output. Fields a plugin adds through subtypes_addlist are not read; a reference in such a field is not checked.

Hidden and timed content counts and carries why it is not visible: a parent page that passes its visibility on to its subpages (extendToSubpages) and is hidden, not started yet or expired; the page; its translation in the content's language; the element or record; a start time in the future, an end time passed; and, beyond the specification, a hidden reference (Bild ausgeblendet). The level highest up wins, a parent page before the page. Content in a language for which the page has no translation gets no reason, although the frontend may not show it.

Permissions. A hit is kept only when the user may change the reference, as DataHandler would allow it: readPageAccess() with edit page for a page property and edit content otherwise (records: their folder), which includes the web mounts; no editlock on the page or a record on the way (admins excepted); tables_modify for every table on the way and for sys_file_reference; the image field, the inline fields on the way and sys_file_reference.alternative granted where they are exclude fields; the language of the reference and of each record. The same filter feeds the list, its tabs and the dashboard number. Not enforced in this slice: per-value access to the content type (explicit_allowdeny) and access checks extensions add to DataHandler through hooks; DataHandler still applies both when slice 2 writes.

Workspaces. In a workspace each row is overlaid with its workspace version: an alt text or mark set there, a file list text set there, and a record deleted there, count; references new in the workspace are found. Records of tables that are not workspace-aware are left out. Not covered: an alt text that is set live and emptied only in the workspace, on the reference or in the file list, is not found, because the query decides on the live rows; a metadata translation that exists only in the workspace is not read. The tests load EXT:workspaces for this part.

Live, no cache. The list and the dashboard number run the query and the filter on every request, and the list pages in memory. The specification's nightly cache for large sites is not built; it is added when a site needs it. The query over the references has no limit and the number is not counted in SQL: whether a hit counts depends on the page access over the rootline, the field and language rights and the record type, which are decided per reference in PHP. A limit would make the dashboard number wrong. Its cost grows with the number of open references, not with the number of pages.

The list and its entries. A second route of the existing module nr_mcp_agent_chat, reached from the widget Website prüfen (always listing Fehlende Alt-Texte) and a tile in Für deine Website empfohlen (shown only with open hits). Both are behind the same gate as the other assistant widgets, ChatToolbarItem::checkAccess() (ADR-009: Group-Based Access Control via Extension Configuration): every action of the feature leads into the chat. Optimieren is left out until slice 2 rather than shown disabled: a disabled button cannot be focused, so its explanation would not reach keyboard users.

Consequences 

  • An editor sees every image without an alt text they may fix, in one place, without opening pages.
  • A decorative image is output with alt="" in fluid_styled_content: on sys_template sites that include its static template right away, on site set sites once the extension's set is added. Sites with their own image partial need the documented condition until they add it.
  • The check costs one query over the references, one query per table and level for the records, one per 500 pages for the pages and their translations, and per page one page access check (a query of its own plus the rootline from the core's runtime cache), which the scanner keeps for the rest of the scan; it scales with the number of open hits, not with the number of pages.
  • The tests need typo3/cms-fluid-styled-content and typo3/cms-workspaces as development dependencies, to prove the frontend output and the workspace overlay.

Changelog 

The changelog is maintained in the repository, not here.

  • CHANGELOG.md — 0.5.0 onwards, in Keep a Changelog format. This is the file the release flow writes.
  • Releases — one entry per tag, back to 0.1.0, each with its diff. The releases before 0.5.0 are only here.

The project follows Semantic Versioning. Until 1.0.0 a minor bump may carry a breaking change; CHANGELOG.md marks those.