AI Chat 

Extension key

nr_mcp_agent

Package name

netresearch/nr-mcp-agent

Version

0.38.3

Language

en

Author

Netresearch DTT GmbH

License

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

Rendered

Sat, 14 Nov 2026 21:24:57 +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 lets administrators and editors work with an AI assistant directly in the TYPO3 backend. The chat opens from the chat icon in the top bar, as a panel or as a column beside the page tree. The module AI > AI Chat gives an overview: whether the chat works for you, how it is configured, and how it was used.

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 

Chat on every backend page 

A chat panel from the top bar, or a column beside the page tree. Send messages, approve changes and manage conversations without leaving TYPO3. The module AI > AI Chat shows readiness, settings and usage.

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.

Missing alt texts 

One list of every image without an alt text, on pages and in records, that you may fix. A purely decorative image is marked as such and is output with an empty alt text.

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.45.0 || ^0.46.0 || ^0.47.0) -- 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 chat now opens from the chat icon in the top bar. The module AI > AI Chat shows whether it works and how it is configured.

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 is loaded after typo3/fluid-styled-content. It names that set as an optional dependency, so an installation without fluid_styled_content logs no "Missing dependency" error for 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 maximum, the nr-llm setting agent.maxIterations (20 unless changed). Ordinary chat runs take chatRunMaxIterations. 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.

chatRunMaxIterations

chatRunMaxIterations
Type
int
Default
0

How many model rounds an ordinary chat run, one without a guided process, may take before it stops. 0, an empty value or anything that is not a whole number above 0 asks nr-llm for no number, so its default applies: the nr-llm setting agent.defaultMaxIterations (5 unless changed). nr-llm caps every value at agent.maxIterations. A run that resumes after an approval or an answer gets the number again, capped at the maximum in force then. More rounds make a turn longer; a worker ends a turn after --turn-limit seconds, so a large value may need a larger limit.

The overview module lists the rounds each kind of run gets, as nr-llm applies them. A worker that started before a change stops before its next turn and the next worker reads the new value. See ADR-023.

Processing 

maxLiveStreams

maxLiveStreams
Type
int
Default
3

How many conversations may stream their answer at the same time. A stream holds a PHP-FPM worker while a turn runs, so keep this well below pm.max_children. A further conversation shows the answer when it is complete, through polling, as does every conversation with 0.

The stream needs a web server that passes a response on while it is written. It sends X-Accel-Buffering: no for nginx. A proxy in front that buffers responses makes the chat show the text in larger pieces. See ADR-028.

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. Leave empty to allow all backend users. Only direct membership counts, not subgroups; administrators always have 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.

A site's own AI configuration 

A site can name its own nr-llm configuration in the site setting LLM configuration (nrllm.configuration, set netresearch/nr-llm, nr-llm ADR-244), for example because its content may only go to one provider. The chat honours it: a conversation about a page of that site runs on the site's configuration -- its provider, model and system prompt -- instead of the configuration of the chat's Task. The Task's prompt template still applies. Sites without the setting, conversations about no page, and pages the user may not show keep the Task's configuration. The choice is made again on every turn, so a changed setting applies to the next message. See ADR-033.

The chat reads and changes pages only through tools, so it refuses a site configuration it cannot run on, before any model call, and says why in the reader's language:

  • the configuration does not exist, is disabled, or is restricted to backend groups the user is not in;
  • it selects its model by criteria instead of naming a fixed model;
  • its model cannot call tools: the model record lacks the capability Tool Use (AI > Models), or its provider does not support tool calling.

It never falls back to the Task's configuration for that site, because that would send the site's content to a provider the site was set up to avoid. Administrators also get a link to the LLM configurations.

Model discovery ticks Tool Use for the providers that report it per model (Mistral, OpenRouter, Ollama) and for the curated OpenAI, Anthropic and Gemini models. Groq reports no capabilities: tick it by hand on a Groq model that supports tool calling.

What else follows the site's configuration:

  • Term lists of both configurations apply: those bound to the chat Task's configuration and those bound to the site's. A ban of either one blocks, and a list bound to both reports its terms once (nr-llm ADR-290). Site-bound lists keep applying as before. The same holds for the rule-set judge: where both configurations have one, both judge, and the stricter mode decides.
  • Privacy level is read from the run's configuration, so the level of the site's provider applies.
  • Tool groups and excluded tools are the site configuration's. An empty group list allows every group.
  • Approvals waiting when the setting changes to another configuration, or to one the chat cannot run on, are not carried out on the old configuration: the chat hands the card or question back and asks for a new message. When the setting is cleared, a waiting run continues on the configuration it started with.

The run of each turn records its configuration; nr-llm's Agent Runs module (AI > Operation) shows it on the run.

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. For a page of a site with its own AI configuration (see A site's own AI configuration), the Configuration record is the site's.

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 

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.

A new or edited message, the start of a guided process, the turns on an image from the list of missing alt texts, and Retry of a failed turn are refused while the user is at the limit. A decision on a proposal and an answer to a question carry on a run that is already there; the limit does not apply to them.

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: its row then opens the page module and the chat asks for the page there. Without it the row starts the task in the chat module. 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.

Image analysis (privacy) 

Whether images are sent to the AI provider (see ADR-026: A Site-Wide Check for Missing Alt Texts, slice 3). Whether an image shows people cannot be told before it is analysed, so the setting applies to every image and to every feature of the assistant that sends one: the scaled copy for an alt text proposal (Optimise an alt text with the assistant) and a file attached in the chat (Attaching files), where a document the provider would read as a whole (a PDF carries its photos along) goes as its extracted text instead. Sending images needs a data processing agreement with the operator that covers the AI provider's processing, images of people included.

A change applies from the next turn of a conversation, also with processingStrategy worker: each turn runs with the setting of the backend request that started it, which the request stores with the conversation. One exception keeps the image card and the model in agreement: where the card of an alt text conversation already says the image is not sent, the image stays unsent after analysis is switched back on, until the chat moves on to another image. A turn that continues after an approval or an answer resumes the stored run of the AI provider, so an image that run already sent goes to the same provider again; the next turn follows the setting.

It is part of the extension configuration (category privacy), which only administrators with system maintainer rights can change, as every setting on this page.

imageAnalysis

imageAnalysis
Type
options
Default
analyse

analyse (Analyse images): a scaled copy of the image is sent; proposals consider the image and its context.

contextOnly (Context only): no image is sent. An alt text proposal rests on the page or record, the content element or field and the file name, and the image card in the chat says that image analysis is switched off. The chat offers no image format for attachments, nor a document format only the provider reads, and refuses such an upload; an image attached before is not sent, and a document attached before (a PDF) goes as its extracted text.

Any other value sends no image, so a value written by hand that is not one of the two never sends images by mistake.

imageAnalysisExcludedFolders

imageAnalysisExcludedFolders
Type
string
Default
(empty)

Folders whose images are never sent, comma-separated, for example fileadmin/portraits/. A folder includes its subfolders. Write a folder as fileadmin/portraits/ (the base path of a local storage in the web root, in any case, and the folder) or as 1:/portraits/ (the storage uid and the folder); use the second form for a storage outside the web root. A folder written without a storage, such as user_upload/team/ or portraits, is a folder of the default storage, as the file list shows it. . and .. are resolved within the storage. A folder belongs to its storage: fileadmin/team/ does not exclude /team/ in another storage, nor fileadmin/teamwork/. An entry that names no folder of a storage, such as one that climbs above the storage's root, or a bare folder on an installation without a default storage, excludes nothing. It is logged as a warning, once per process and configured value; so is a folder that does not exist (yet), which is excluded all the same. A document attached in the chat from such a folder goes as its extracted text only. Applies while imageAnalysis is analyse; the image card names the folder.

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
StartLimitIntervalSec=0

[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=100 --max-turns=1
Restart=always
RestartSec=0
TimeoutStopSec=610

[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!

A worker ends by itself and expects to be started again:

--max-turns
After this many turns. 1 starts every turn in a fresh process that is already booted, which is the recommended value: nothing of a turn reaches the next one, and a configuration change counts from the next turn. 0 keeps the process running, without a boot between turns.
--time-limit, --memory-limit
After this many seconds, above this many megabytes. Defaults: 3600 and 256.
--turn-limit
A turn that runs longer than this many seconds, 600 by default, ends the process and fails, and the reader gets a Retry. It is never run a second time by itself, because it may have written.
A changed setting
When the system settings file was written since the worker started, it stops before its next turn, so the next worker reads the new values.

SIGTERM and SIGINT stop a worker after its current turn, so give it the time (TimeoutStopSec, or stop_grace_period in Compose). This and --turn-limit need the PHP extension pcntl; without it the process dies on SIGTERM and a stuck turn waits for ai-chat:cleanup.

One worker runs one turn at a time. Run as many as turns should run at the same time. A turn that finds no free worker starts a process of its own, as with exec, so a worker that is down costs the time of that start and nothing else. See ADR-029.

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 chat 

Click the chat icon in the top bar of the TYPO3 backend. The chat opens as a panel, or beside the page module as a column next to the page tree. The full-page chat is linked from the overview module and from the dashboard widgets.

The full-page AI chat

The full-page chat.

The overview module 

AI > AI Chat opens an overview of the chat. Backend users need access to the module in their backend group (Access Lists > Modules); administrators always have it.

What the chat does
The chat answers questions about the installation and prepares changes to pages, content and files; every change waits for your approval. The page lists where it appears: the top bar, the column beside the page tree, the dashboard widgets and the list of missing alternative texts.
Does it work for you?
Each line is checked when the page loads, for the viewer, with the rules the chat applies itself: access (allowedGroups), the nr-llm Task and its LLM configuration (llmTaskUid, groupTaskMapping), whether the model can call tools, how many tools are withheld, and how messages are processed. With the worker strategy the page counts idle workers; a worker busy with a turn leaves no trace, so "no idle worker" is a warning, not a failure. A line the page cannot measure says Not checked. Administrators also see each groupTaskMapping row.
Usage
For the last 7, 30 or 90 days: conversations by state now, conversations started and those of them that have a skill now (an ended guided tour has none), open points of guided tours, model calls and failed calls, billed requests, tokens and estimated cost from nr-llm, and your budget. Administrators see the figures of all users and the failed conversations per error code; editors see their own. No message and no other user's conversation title is shown. A conversation without a message counts nowhere, as no chat list shows it. Model calls are kept by nr-llm for its telemetry retention; when the period reaches further back, the page says from which day they are counted.
Configuration
Administrators see every setting of the extension configuration with its value and whether it is the default.
Go to
The full-page chat, the missing alternative texts, and the nr-llm modules the viewer may open.

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.
  • Rule-set check of the proposed change. When the chat's AI configuration has the rule-set check of nr-llm switched on in report mode, the text of a change the assistant proposes is checked against the configured rule set. A rule the text may break is listed below this line, with the passage it is about; so is a check that gave no answer, in which case read the text against the rules yourself. A change shorter than 30 words is not checked, so a missing hint does not mean that a text was checked and passed. The change is still proposed, and you decide on it as usual. In block mode a change that breaks a rule is not proposed at all: the chat ends with an error that names the rule.

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.

A message you send while you work in a TYPO3 workspace is carried out in that workspace, also when you switch to another one before the chat has finished. A content element the chat changes there is stored as a draft of the workspace: it appears in the Workspaces module, and the live site stays as it was until the workspace is published. The approval card names the workspace, for example Saved as a draft in workspace “Entwurf”; live stays unchanged until the workspace is published, and the target language where the step translates something. If you may not open that workspace, the card says that it is a draft in a workspace whose title is not visible to you. The chat reads the page as your workspace shows it. Changes nr-llm cannot make in a workspace are refused there with a message instead of being made live. When you may no longer work in the workspace the message was sent from, the turn fails and nothing is written.

A step that a rule of the AI configuration excludes, for example because the configuration requires a draft workspace and you work in the live workspace, is marked This step will not be carried out on the card, and the rule's explanation stands below it. Approving the card does not carry that step out. When every step of the card is excluded, the card offers no approval, only the way to cancel. A configuration that requires a draft workspace says so beside the card while you work in the live workspace; switch to a workspace in the backend and send the request again.

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.

What an assignment changed 

Below the conversation, Änderungen dieses Auftrags (Changes of this assignment) lists the records the assistant's steps changed. It appears once a step has written something, in every conversation, not only in guided processes, and it covers all steps of all runs of the conversation, also runs that were continued after an approval. Open it with Enter or Space; it loads the list when you open it.

The list "Änderungen dieses Auftrags" with two changed records

One row per record, with separate badges for each fact.

Each row names the record, with its table and uid. The title is a link to the edit form where you may edit the table, and Änderungen ansehen opens the record history. The badges state, each on its own:

  • what happened: created, updated, moved, deleted or restored;
  • the workspace the record is stored in: a draft in a named workspace, or the live workspace;
  • the language, where the record has one;
  • whether the record is hidden or not hidden. Not hidden says nothing about whether the record is public: a draft in a workspace is not live, and a start time, an end time or a user group can still keep it from visitors. These restrictions are separate badges;
  • whether the record is the target of the step or was changed along with it (the content of a copied page, the overlay of a translation);
  • that the step failed after this record was written, in which case check the record before you ask again.

Under the list a note states what the list covers: the changes TYPO3 reported for the steps of the assignment. A change made another way does not appear, so the list is not proof that nothing else changed. Further notes appear when the capture of a step was not complete (Weitere Auswirkungen nicht vollständig erfasst), when more records changed than the list holds, when runs are older than the capture (only the record each step named is known) and when runs could not be read.

You see a record only if you may read it: the table, the page and its permissions and mounts, the language and, for a draft, the workspace. Records you may not read, and records that no longer exist, are not listed and not named, only counted. The link to the Workspaces module appears for records in the workspace you work in, because that module shows your current workspace.

The list is read-only. It belongs to the conversation: editing a message removes the steps after it from the list as well, and a conversation older than the list shows the record each of its steps named.

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 chat is about: the chips above the input 

When the chat is about one page, a row of chips above the input says which:

  • The page chip (grey, with a page icon) shows the title of the page. Beside the page module (Layout) it is the page selected in the page tree, and it changes as soon as you select another one. In a guided process it is the process's page, which stays when the page tree moves on. Only a page you may see is named.
  • The element chip (white, with a black circle and a yellow dot) appears in a guided process when the current point is about a content element, and changes with the point; a point about no element shows the page chip only. Beside the page module, pointing at it or focusing it with the keyboard marks the element in the page layout, and a click scrolls it into view. In the full-page chat module, in the popped-out panel and while the page module shows another page, the chip is only a label.
  • A page property such as the meta description gets the same chip when the current point is about it, named by the field's label as in the page properties. The page module has no element for it, so this chip is always only a label, and an element marked in an earlier point is no longer marked.
  • The (i) at the end of the row says what the assistant may change, for example "Bereich: ganze Seite „AI Chat Agent“. Geändert wird nur nach deiner Bestätigung." or, for a page property, "Der Assistant ändert nur die Seiteneigenschaft „…“ der Seite „AI Chat Agent“." It opens on pointing at it and on keyboard focus; Escape closes it.

A long title is shortened in the chip; the full title is in the chip's tooltip and in the text of the (i). There is no row for a skill that works on no page (Whether a skill works on a page), for a chat without a page, or while no page is selected. A conversation about a missing alt text shows its image and the sentence on what may change in the same row.

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. A point about a page property, such as the meta description, marks nothing in the page module; the chip above the input names it.
  • 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. Every list of conversations -- the chat module's sidebar, the panel's tabs, its More list and its sidebar, and the Recent chats dashboard widget -- shows the same about each one:

  • Title -- a guided tour is named by its tour and its page, for example SEO optimieren · Über uns, with the page's current title. Any other chat is named by its first message, shortened to its first sentence or about 60 characters; the full text is in the tooltip. A chat about a missing alt text keeps Alt-Text: <file>. A title you gave a chat yourself is always kept, also when it begins like the tour's. The page is named in the chat's language where it has a translation. Two chats that would read alike, such as the same tour twice on one page, show when each was started after the title, to the second where the minute is the same.
  • Context -- what the title does not say already: the page a skill that needs one works on, the skill, Guided tour and the point a tour is at (Point 2 of 5). A page is named only if you may see it in the page tree; a deleted page is shown as Deleted page. An ordinary chat names no page: it only remembers the page you had open when you last wrote, which is not what the chat is about.
  • Time -- when the last message was written: Today, 10:42, Yesterday, 16:05, then the date, in your browser's time zone. The full date and time are in the tooltip.
  • Status -- in words, with a colour that only repeats them: Ready is neutral, Waiting for your answer and Waiting for your approval stand out in the warning colour with an icon, Working is shown as information, Failed as an error.

The lists are ordered by the last message, newest first, with pinned chats on top. A chat without any message is not listed, except while it is the one that is open.

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 lists.

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.

With the privacy setting imageAnalysis on Context only, no image goes to the provider: the file picker offers no image format, an image upload is refused, and an image attached earlier is not sent; the assistant says that it cannot see it. A PDF then goes as its extracted text, not as the file, because a provider that reads PDFs would see the photos in it. An image in a folder listed in imageAnalysisExcludedFolders is not sent either, and a document from such a folder goes as its text.

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 ones with the most recent message, and always the one that is open. A tab shows the status icon and the title; its tooltip adds the context, the status in words and the time. The others are behind More (n) at the end of the row, which opens a list with a search field and the same lines as the module's list: type to filter by title, page or skill, 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. The collapsed bar shows the open conversation's title, with its context and time in the tooltip.

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 takes the dashboard's full width and lists your ten chats with the newest last message, newest first; a chat without any message yet is not listed. When they do not fit, the list scrolls within the widget. Each entry shows what every list shows (see Conversation management):

  • the title, for example SEO optimieren · Über uns for a guided tour;
  • below it, in grey, the context the title does not name, for example Guided tour, or Page “Über uns” · SEO optimieren for a chat you renamed;
  • 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;
  • the status in words, as a badge: neutral for Ready, in the warning colour with an icon while the chat waits for your answer or approval;
  • 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; the full date and time in the tooltip.

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, status and time. The list itself is a tab stop too, before its first entry, and scrolls with the arrow keys, Page Down and End.

A guided process on a page you may not see is listed without the page. 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 them in this order: AI Assistant, Edit a page, Create content, Recent chats, Recommended for your website, Improve a page, Check website. Frequent tasks is in the group, not in the preset.

Every widget opens with one sentence and offers one kind of action: a button, a grid of buttons, number tiles or a list whose rows are links. An action that concerns a page opens the page module with the chat as a column beside it (floating where the window is too narrow, see Chat as a column; a chat popped out into its own window stays there). Where the chat panel is not available, the same action opens the chat module.

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.
Edit a page
Choose a page to edit opens the page module with a new chat beside it. Choose the page in the page tree; the chat follows it, and whatever you then ask is about that page.
Create content
One button per kind of content the assistant can create for you: New page, and News where EXT:news is installed. A kind is offered only where you may create its records and the tool that creates them is enabled for your chat, and that the tool would not refuse for you (the default language, and for news its table settings and nr-llm's deny-list). A button opens a new chat with the start of the request in its input (I would like to create a new page, namely …); nothing is sent until you complete and send it, as the widget says below the buttons. Without any kind, the widget says that your administrator can enable the tools.
Improve a page
One row per guided tour (by default Optimise SEO and Improve content). A row opens the page module and starts the tour in the chat beside it; the tour asks for the page, which you choose in the page tree and confirm with Check this page.
Frequent tasks
Shortcuts into the chat, set by dashboardQuickTasks (none by default), one row each. A task that works on one page opens the page module and asks for the page there; any other task opens the chat module.
Recommended for your website

Number tiles for what the extension can count exactly, each with one action:

  • Images without alt text you may fix, on how many pages and in how many records; View opens the list (see Missing alt texts).
  • Pages without a meta description: page versions you may edit whose meta description is empty, in every language you may edit and in your workspace; hidden pages, folders, links, shortcuts, mount points and spacers are not counted, nor are pages below a page you may not see in the page tree. Each translation counts on its own, and the label says so (Pages and translations without a meta description). The number is kept for five minutes and recounted as soon as a page is saved. Start now starts the SEO tour on the most recently changed of them, beside the page module. The tile is left out where you may not edit the meta description or the SEO tour is not available to you.

A count of 0 is shown as done (No missing alt texts – great!, Every page you may edit has a meta description – great!). Broken links and outdated content are not counted: the extension has no exact source for them.

Below the tiles, the points guided tours left open: changes a tour proposed that an editor skipped, until a change to the same record and field is applied. Each row 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.

Check website
Site-wide checks, one row each. Missing alt texts is always listed and opens the list, also when nothing is open. Pages without a meta description is listed while one is open and starts the SEO tour as the tile does.

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.

Missing alt texts 

The list Missing alt texts shows every use of an image without an alt text that you may fix, across all pages and records, without opening them one by one. Open it from Check website or from the tile in Recommended for your website on the dashboard. It is checked when the list opens: an alt text you add in a content element is gone from the list the next time you open it.

The heading counts the open images. Pages lists the images in content elements and in the page properties, grouped by page in page tree order and within a page in the order of its content. Each record type with open images follows as a tab of its own, named as in the list module, for example News; its images are grouped by record, with the folder the record lies in. A tab whose last image you fixed stays open, saying it has no open hits, until you open the list again. The tabs work with the arrow keys, Home and End.

Each row shows the image (with its crop), the file name, the content element or the image field, the language (its code, such as DE, or DE-CH where two languages of the site share a code), and a button that opens the page in that language, or the record's detail page, in a new tab. A record type gets Open detail page only where its preview is configured (Page TSconfig TCEMAIN.preview), as View in the list module. Hidden and timed content stays in the list: Currently not visible says why, for example Element hidden or Expired since …, because the image needs its alt text once the content is shown again. Many images are listed page by page.

An image counts as open when neither its use nor the file list (Alternative text of the file) gives it an alt text, and it is not marked as decorative. You see only images you may change: on pages whose content you may edit (an image in the page properties needs the right to edit the page as well), in languages you may edit, in content elements of a type you may use, with the right to change Alternative text and the record and its image field. Which tables and fields are checked is set in altTextExcludedTables and altTextExcludedFields.

Optimise an alt text with the assistant 

Optimise in a row opens the chat beside the list, on that image. The module menu folds to make room and unfolds again when you close the chat; the row the chat works on is marked, and its button reads In the chat. The chat starts with a card of the image: where it is used, its language, and that it has no alt text. The assistant then proposes a text for this use of the image, with these answers:

Apply
Saves the text. The chat confirms it with Alt text saved., and the image leaves the list. If it cannot be saved, the assistant says why in one sentence, the proposal stays, and Offer it again brings it back as a new proposal.
Another variant
The assistant proposes a different text. You can also write a wish, such as Shorter, and get a new proposal.
Mark as decorative
For an image that adds nothing to the content (see Decorative images), offered when you may set the mark. The assistant then proposes the mark; you confirm it once, with Mark as decorative on that proposal, and Create an alt text asks for a text instead. For an image that belongs to a section or a record the proposal says so, because such an image probably needs an alt text. Where the assistant itself finds the image decorative, it proposes the mark in the same way; where you may not set the mark, it does not, and such a proposal cannot be confirmed.
Skip
Saves nothing; the image stays open in the list.

Optimise the next hit below the conversation, or Optimise in another row, moves the conversation to the next image: a line Hit changed names it, and its card follows. The next hit is the one after the current image in the order of the list, across the tabs. Optimise while the assistant is still writing its proposal takes effect when it has finished, unless you close the chat before. Closing the chat gives the keyboard focus back to the row it was on, and unfolds the module menu it folded.

The assistant changes nothing but the alt text of this one use of the image, and only after you confirm it; a proposal for another image cannot be confirmed in this chat. It sees the image where the AI provider supports images and the privacy setting lets it go (Image analysis (privacy)), as a scaled copy. Otherwise the card says that the proposal rests on the page, the element and the file name only, and why: image analysis is switched off in the settings, the image lies in a folder whose images are never sent (the card names the folder), the provider cannot see images, or the image cannot be read. The preview of the image in the chat and in the list stays; it is shown in the backend only.

Optimise works in the live workspace and in a draft workspace, when an administrator has enabled the tools set_file_reference_alternative_text and set_file_reference_decorative in nr-llm's Tools module, and when you may approve your own changes in the chat (Approving a step in the chat). Otherwise the button stays in the list, and a note above the list says why it does not work.

In a draft workspace the list shows the images as that workspace shows them, and the alt text or the decorative mark you approve is saved there as a draft; live stays unchanged until the workspace is published. The approval card names the workspace. Two exceptions: an image whose alt text is set in live and emptied only in the workspace is not listed, and an image on a record whose page, or on an inline record whose owner, exists only in the workspace is listed but cannot be optimised there. Where an administrator turned versioningWS off or set versioningWS_alwaysAllowLiveEdit for sys_file_reference, Optimise works in the live workspace only, and the note above the list says so.

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 (AI > AI Chat) -- An overview: readiness for the viewer, configuration, usage and links (ADR-031). The full-page chat is its sub-route nr_mcp_agent_chat.conversation.
  • 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.
turn_timing
JSON object of the current turn's phases, in milliseconds since the epoch: request, dispatched, processStart, claimed, runBegin, firstLlm, runEnd, stored. Written column-only by TurnTimingRecorder: the first two by the web request, the rest at the end of the turn, so they appear together once it is over. Returned as timing by getMessages on both paths, so a measurement needs no access to the server.
live_text
The text the model has written so far in the running turn, written column-only by LiveTextRecorder and emptied when the turn's messages are stored. getMessages returns it as liveText, and /ai-chat/conversations/stream sends it as it grows. See ADR-028.
stream_lease
Until when a stream of this conversation holds one of the maxLiveStreams slots, as a timestamp. See ADR-028.
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 Overview and full-page chat (AI > AI Chat, ADR-031) 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, its list and dashboard entries, the "decorative image" field and its frontend output (ADR-026) Classes/Service/AltText/, Classes/Controller/MissingAltTextsController.php, Classes/Dashboard/WebsiteCheckWidget.php, 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 with the caller source and the site of the conversation's page (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 no provider option is overridden by it. nr-llm stores it beside the run's suspended state and puts it back when the run continues after an approval or an answer (AgentRuntimeInterface::approve(), submitInput()).

The site of the page 

A turn about a page states the identifier of that page's site with withSite(). nr-llm then checks the run against that site's term lists and the lists bound to no site, and leaves the lists of other sites out (nr-llm ADR-238, ADR-244). The site is stored with the suspended run, so a continuation after an approval or an answer is checked the same way. A turn about no page, or about a page that belongs to no site, states no site, and every forbidden term of every site applies.

The same site decides the configuration (ADR-033: A site's own configuration runs the chat). Where it names its own in the site setting nrllm.configuration (nr-llm ADR-244), the turn runs on that configuration instead of the Task's; the Task still adds its prompt template. SiteConfigurationSelector reads the setting through nr-llm's ConfigurationResolver::resolveForSite() with the turn's actor and refuses, before any model call, a configuration that cannot be used, one without a fixed model, and one whose model lacks the capability tools or whose provider adapter is not tool-capable. The turn then fails with a code from SiteConfigurationRefusal; it never falls back to the Task's configuration. A site that names none, a turn about no page and a page in no site keep the Task's configuration.

A run waiting for an approval or an answer keeps the configuration it started with. Before approve() or submitInput() the chat asks the site again: when it now names another configuration than the run's, or one the chat refuses, the decision or answer is handed back with handBack.siteConfig / handBack.inputSiteConfig and nr-llm is not called. A new message starts a turn on the site's configuration.

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. An error that offers storableMessage() is stored in that form (ErrorMessageSanitizer::sanitizeException()): nr-llm's refusal of the rule-set judge quotes the passage of the refused text in its message and leaves it out of the storable form (nr-llm ADR-288). The same applies to an exception the runtime throws and to the CLI commands' error output.

Hints of the rule-set judge 

In report mode nr-llm's rule-set judge lets a proposed write through and reports hints: a rule the text of the write may break, or that the text was not checked (nr-llm ADR-288). The runtime's result carries no hints, so ChatService runs run(), approve() and submitInput() inside nr-llm's GuardrailHintCollector (GuardrailHintCapture), the shared service the guardrail middleware records into, resolved through GeneralUtility::makeInstance() in the process that runs the turn. An nr-llm release without the collector runs the calls as they are.

The hints of one call become one assistant line with the notice ruleHints and the hints under data.hints (kind and message of each), after what that call added: the answer, the text beside a proposal, or the question. A call that reports nothing adds no line, and a failed call adds none, as it is retried over the transcript. The chat renders the label chat.ruleHints and each hint as text; the model is not sent the line. A violation hint quotes up to the judge's quote length of the proposed text, so the line stores that passage; the reasons are in ChatService::noteRuleHints().

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, amended by ADR-028

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

Amended by: ADR-030: A later turn reads what ran — the note beside a flagged answer covers its own step only; what earlier steps saved reaches the model as the chat's record.

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, or one column of the conversation's page (see the amendment below).

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.

Amendment of 2026-10-27 (NEXT-221): the message may carry scroll: <boolean>. scroll: false marks the element without scrolling to it; without the field the element is scrolled to, as before, and any other value is ignored. The element chip above the chat input sends scroll: false on hover and keyboard focus and scroll: true on click, only from the panel while the page module shows the process's page. The chip's name comes from getMessages (guided.highlight.title), read when the chat asks and never stored: the element's header, else its type label, only while the highlight passes the policy with the element's current page, tables_select allows tt_content and the page passes readPageAccess with PAGE_SHOW.

Amendment of 2026-10-27 (NEXT-221, page properties): a step can be about a property of the conversation's page rather than a content element, for example its meta description. chat_highlight_element takes exactly one of contentUid and pageField; both or neither are refused. pageField is a column name of pages: it must exist in $GLOBALS['TCA']['pages']['columns'], and an excluded column needs an administrator or the column in the user's non_exclude_fields. Every refusal of a column has the same text. The tool does not know the conversation and therefore names no page: the run state holds {table: 'pages', field}, and the linker stores {table: 'pages', uid: <the conversation's page>, field} after the policy has allowed pages with that uid as uid and pid (a page is on itself). The model cannot name another page. Because the tool cannot check PAGE_SHOW on a page it does not know, that check happens when the chip's name is read: guided.highlight.title is the column's TCA label in the reader's language, only while the policy allows the page, the page passes readPageAccess with PAGE_SHOW and the reader may edit the column; otherwise ''. Neither the model nor the browser supplies a label. Content element highlights are unchanged.

The page module has no element for a page property. The panel posts no highlight for it and announces none; where an earlier step marked a content element, it posts {type: 'nr-mcp-agent:highlight', version: 1, clear: true} so the page module does not keep pointing at that element. page-highlight.js is unchanged and ignores any message with table: 'pages'. The chip of a page property is text, never a button.

A point about no element drops the highlight. Until this amendment a highlight stayed until the completion report, so a point about no element kept the element of the point before. When a run's state reports progress whose current differs from the conversation's and reports no highlight, the linker now drops the highlight; a run that reports a highlight sets it as before, and a run that stays on the point or reports no progress keeps it. Both values come from the same run's state, so the order of chat_set_progress and chat_highlight_element within a point does not matter. The panel then sends the clear message, as for a page property. The rule relies on a skill reporting the point with chat_set_progress whenever it moves to another one, as the tool's description asks.

Amendment of 2026-10-31: the chat derives the point and the highlight of a proposal. On the demo, 52 of 65 chat turns between 2026-10-23 and 2026-10-30 held a model round whose only calls were chat_set_progress, chat_highlight_element or chat_list_open_points, and those rounds took 35 % of the active time; every model call costs about 1.3 s on top of its output tokens. The tours asked for progress, highlight and proposal as separate steps. Asked to send all three in one response, the model batched progress and highlight beside a question (nr-llm 0.41.18 runs those reads before the pause) but never beside a write: 0 of 10 approval turns, measured on 2026-10-31. The calls that only report the state of a point with a proposal are therefore taken over by the chat:

  • Highlight from the proposal. When a run of a process pauses on a proposal (offersProcessAnswers()) and reported no highlight, the chat reads what the pending write names (nr-llm's `PendingCallView:: $pendingTarget`) and highlights it under the checks of chat_highlight_element, for the conversation's owner only: a content element the owner may see (VisibleContentElement, now shared with the tool), on the conversation's page; or exactly one property of the conversation's page the owner may edit. A write naming several fields, another page, another table or no target (a new element) names nothing, and the existing rule applies: the highlight stays on the same point and is dropped on a new one.
  • The point from the decision, not from the target. A proposal cannot tell which point it belongs to: points 1 and 3 of one measured content tour wrote the same element, and a variant writes the same field again. The editor's decision can. "Übernehmen" whose write the trace shows as applied or to be checked, "Behalten" and "Überspringen" settle the point; the conversation notes that in its guided state. The next pause on a proposal then moves current on by one, never past total, and uses the note up. "Andere Variante", a free message and an answered question leave the point as it is. After the overview (point 0) the first proposal is point 1. A note survives runs that pause on a question in between.
  • What the model reports wins. Progress or a highlight the run reported replaces the derived one, and reported progress also uses the note up. The tools stay, and their descriptions now ask for progress after the overview, beside a question, when the model moves on without a proposal, and at the end; and for a highlight only where no proposal names one.
  • Consent is unchanged. Nothing is written in TYPO3; the derivation reads the pending call nr-llm already shows on the card and writes only the conversation's guided state.

Two paths the chat cannot see: a point closed by an answer to a question ("Ohne Beispiel weiter"), and a point the editor closes with a free message ("passt, weiter"), each followed directly by the next point's proposal. There the model has to report the new point itself; the failure signature, if it does not, is a header one point behind from there until the completion report.

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, or a property of that page itself (ADR-020, amendment of 2026-10-27).

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.

Amendment of 2026-11-04: an inverted toggle never names the change 

Context. The heading rule of the amendment "the approval card is a system step" turned the schema label of the one field a write touches into "„<label>“ ändern". nr-llm's disable_redirect names disabled as its target (ADR-214 of nr-llm: a target names the fields a write sets). TYPO3 labels that column "Aktiviert" and inverts its toggle, so the card that disables a redirect read "„Aktiviert“ ändern", the opposite of what it does. The applied outcome read "Aktiviert (Weiterleitung 12)" the same way.

Decision. A column whose toggle item carries invertStateDisplay — every enablecolumns.disabled column TYPO3 builds (disabled, hidden), and nav_hide, no_search, no_index, no_follow — never names the change by its schema label. The heading falls back to the action label, which the preview's heading supplies ("Weiterleitung deaktivieren", "Weiterleitung ändern"), and the outcome's subject leaves that label out. A label the tool sends in its structured preview still names the heading when it differs from the schema's label; nr-llm's structured labels are mostly the schema's own, and those are left out like it. The "Betroffen" line keeps the label: it names the field the write touches, not what the write does to it.

The fix is in this extension, not in nr-llm: the target names the field correctly, and the rule covers every card that names such a column by its schema label, update_redirect with disabled and third-party tools included. The open-point summary of a guided process (ADR-022) still names the field by its label; no shipped process proposes such a write.

Consequence. The failure signature of a regression is a card or a status line that names an inverted toggle's label ("Aktiviert") as the change.

Amendment of 2026-11-04: an ordinary chat run has its own round cap 

Context. nr-llm made its round limits settings (nr-llm ADR-101, amendment of 2026-11-04): agent.defaultMaxIterations (5 unless changed) for a run that asks for no number, and agent.maxIterations (20 unless changed) as the maximum. An ordinary chat run asked for no number, so the only way to give it more rounds was to change nr-llm's default for every caller. The ceiling of 20 that the amendment of 2026-10-22 names is now that setting.

Decision.

  • An ordinary chat run asks for chatRunMaxIterations. 0, the default, and every value that is not a whole number above 0 ask for nothing, so nr-llm's default applies as before. A process run keeps processRunMaxIterations.
  • The overview shows the rounds a run gets, read through nr-llm's AgentRoundLimitsInterface, which applies the rule its runtime applies: the ordinary run, the process run and the maximum, each with where the number comes from. The settings table beside it keeps showing the stored values.
  • A worker stops when the settings changed since it started (ADR-029). TYPO3 reads the extension settings once, at boot; a worker waiting while an administrator saves new ones would run the next turn on the old ones. It checks when the system settings file (config/system/settings.php in a Composer installation) was last written before it takes a turn and stops instead, and its supervisor starts one that reads the new values. A value written into additional.php takes effect with the next deploy, as before.

Consequences.

  • The chat card's decision still runs in the worker or the forked process (decideApproval() answers 202), never in the web request, so a high maximum cannot run into a proxy timeout there. nr-llm's own Agent Runs inbox and playground continue a run inside the request; see nr-llm ADR-101.
  • A save between the second the worker started and its first look counts as a change, so a save during its boot is not missed. File times have a resolution of one second, so a worker that starts in the second of a save stops again: a save costs restarts for up to one second. A file time ahead of the host's clock does not count. A turn that a worker takes in the instant between the save and its check still runs on the old values.
  • A segment that resumes after Übernehmen or a choice keeps the run's cap (nr-llm keeps it across a resume since its ADR-101 amendment for #1162), so the consequence of 2026-10-22 that such a segment gets the loop's default of 5 no longer holds.

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, with one exception: Optimieren in the list of missing alt texts folds it through its own toggle, so the list keeps its width beside the chat, and unfolds it when the chat closes, unless the user had folded it already (ADR-026: A Site-Wide Check for Missing Alt Texts).

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

Status: Accepted

Date: 2026-10-25

Amended by: ADR-030: A later turn reads what ran — a turn sent without a page keeps the conversation's last page apart from the selection, and the prompt names it.

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

Amended by: ADR-032: Missing alt texts are fixed in a draft workspace — an editor in a draft workspace optimises there, and the decorative mark is written into the workspace the run recorded.

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. It asks for approval (below, Slice 2).

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. An inline field from a table to itself is not followed: it declares a container of records of its own kind (a grid extension's content element holding content elements, for example), whose children the page module edits on their own and the DataHandler checks alone, so a locked container does not hide or refuse the images of its children.

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 nr-llm's writer asks it before its card (nr-llm ADR-230): readPageAccess() with edit content, and for a page property edit page as well (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 values of each record's authMode fields, the content type above all (explicit_allowdeny); 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. Before these, every table on the way has to be writable at all, for admins too: not on nr-llm's denylist of credential and audit tables, not readOnly, and not adminOnly for an editor; the list and the writers ask this in one place (TableWriteEligibility). A page property needs both page rights because the form edits it as a page property, while the DataHandler, writing the reference row alone, asks edit content on its page and edit page only with pages in the same datamap. The same filter feeds the list, its tabs and the dashboard number.

The DataHandler does not repeat these checks for the records the reference hangs on: writing sys_file_reference alone, it checks that row, its page and its table, field and language rights, not the record it belongs to, and for an inline child not the outer content element either. So ReferenceWriteAccess asks, before its card, the rights of the record and of every record that owns it through an inline field, up to the outermost one: an image on a carousel item is refused when its content element is locked or of a type the user may not use. The list asks the same of the same chain. nr-llm's set_file_reference_alternative_text (nr-llm ADR-230) asked the record's own rights but not those of its owners up to 0.41.6; nr-llm 0.41.7 asks every inline owner in another table too, and this extension requires 0.41.7 from 0.22.0 on. Not enforced anywhere before the write: access checks extensions add to DataHandler through hooks.

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. Where Optimieren cannot work (slice 2) it is not disabled, which cannot be focused, so its explanation would not reach keyboard users: the button stays focusable with aria-disabled and aria-describedby on a visible note that says why.

Slice 2 

The decorative mark is a tool that asks for approval. set_file_reference_decorative(reference, decorative) sets or clears tx_nrmcpagent_decorative on one reference through nr-llm's ToolDataHandler, the DataHandler its own writers run (nr-llm ADR-206), as the acting user, in the live workspace only (in the workspace the run recorded since ADR-032: Missing alt texts are fixed in a draft workspace), reads it back, and names the reference as its write target, so nr-llm announces the write with AfterAiRecordWrittenEvent as it does for its own writers. nr-llm pauses every call of a tool that declares a write for an approval card (ToolApprovalRule, nr-llm ADR-134), and no declaration lifts that; declaring the tool a read to save the editor a click would mislabel a record write, which the guided-state tools' READ_ONLY does not do (they write the chat's own bookkeeping only). Like nr-llm's writers it is disabled until an administrator enables it, in the writers' group editing, and usable by editors. Before the card it asks what nr-llm's set_file_reference_alternative_text asks (ReferenceWriteAccess), with the grant for the mark instead of the alternative text, so no card is offered that the DataHandler would then refuse. Its card names the image use and, when the image belongs to a section (the header of its content element, also through a carousel item) or to a record outside the pages, carries the specification's warning (case 2): "Dieses Bild gehört zum Abschnitt „…“ und braucht deshalb wahrscheinlich einen Alt-Text. Trotzdem als dekorativ markieren?"

The editor confirms the mark once, on that card. The specification says: „Gespeichert wird erst nach „Übernehmen“ bzw. „Als dekorativ markieren“.“ nr-llm saves a write only on an approved card (nr-llm ADR-134), and that holds here too. The chip "Als dekorativ markieren" therefore does not save anything and does not count as a confirmation: it asks for the card, and approving the card is the one confirmation, with the warning of case 2 on the card itself. No further "really?" step comes before or after it.

A conversation starts from the reference alone. POST /ai-chat/conversations/create takes altTextReference, the reference's uid, and nothing else: a page or a skill sent along is refused. The server reads everything about the image again: the hit has to be one the list shows the user (MissingAltTextScan::find(), the list's own filter) and one nr-llm's writer would accept (ReferenceWriteAccess for alternative). A reference that is not there and one that is not the user's get one neutral 403, so a uid alone does not tell whether it exists; a reference the user may edit that is no longer open gets 409 done ("Treffer zwischenzeitlich erledigt"). A draft workspace got 409 workspace, as nr-llm's writer wrote the live workspace only; since ADR-032: Missing alt texts are fixed in a draft workspace only a user who may work in no workspace does. A chat whose runs may not call both tools, as nr-llm's own policy answers it (ToolCallPolicyInterface::decide() for the chat's configuration), gets 409 tools and the names of the tools an administrator has to enable. A user who may not approve their own changes in the chat (ADR-021: Editors approve their own changes in chat) gets 409 approval, and a chat whose configuration requires a second approver 409 secondApprover: the proposal is saved by approving its card, which neither could do. The conversation keeps the reference uid only (column alt_text_hit), and is about the image's page in the reference's language when the image lies on a page; a record's image binds no page.

The chat opens beside the list, on the image. Optimieren opens the chat as the column (ADR-024: The Chat as a Column Beside the Page Tree) for this opening only, folds the module menu through its own toggle and unfolds it on closing when it folded it, and starts the conversation, or moves the open one to the row's image. The list follows the chat through the panel's event nr-mcp-agent:alt-text (active, done, gone, closed): the row the chat is on is marked (aria-current) and its button reads Im Chat, a fixed image leaves the list with its counts and a live region, and closing gives the keyboard focus back to the row. Without the panel the button opens the chat module with altTextReference.

The first message is the server's. The image card (image, page or record, element or field, language, "Dieses Bild hat keinen Alt-Text") is a notice with the card's lines as data, written when the conversation takes the image up; the model's turn follows. Every line of the chat's own (card, divider Treffer gewechselt, the chip Überspringen, the confirmations) is a notice the reader sees in their language and the model never reads; the chips Als dekorativ markieren and Erneut anbieten are requests the model reads. A label the chat fills with values uses positional placeholders only (%1$s): TYPO3's lll() fills a plain %s itself, with "undefined" where no value is passed. What the model reads of the image is built anew on every turn from the database: the latest card becomes the image's context (file, language, page or record, element text, neighbouring elements, the record's text fields), an earlier card a line saying the editor moved on.

The flow is a section of the system prompt, not a skill. An extension cannot ship a skill to nr-llm (nr-llm ADR-163), so the instructions — propose one text through set_file_reference_alternative_text, propose the decorative mark through set_file_reference_decorative only for an image that adds nothing, never touch another image — are added to the system prompt of a conversation about an image.

The answers. On a proposal the card offers Übernehmen (approves the card; not offered while the approval is blocked), Andere Variante (denies it with the reason variant, which nr-llm passes to the model), Als dekorativ markieren and Überspringen. Überspringen is the chat's own action with a fixed text and no model turn: the waiting run is withdrawn, nothing is saved, and the chat says that the hit stays open. Als dekorativ markieren is offered only where the user may set the mark (the rights and the exclude-field grant for tx_nrmcpagent_decorative, as the tool asks them); it withdraws the waiting run and starts a turn whose request the model answers with set_file_reference_decorative. The mark's card — the model's own proposal (case 1) or the one the chip asked for — shows its lines, with the warning of case 2 where it applies, and offers Als dekorativ markieren (approves it, the one confirmation) and Alt-Text erstellen. The specification's buttons for case 2, „Ja, als dekorativ markieren“ and „Doch Alt-Text verwenden“, belong to a step of their own, which the single confirmation leaves out. The model's own mark reaches a user who may not set it as a card without the approve button, which says so in the reader's language in place of the tool's refusal, and the image's context tells the model that the mark is not available. Only the system prompt binds the model to the conversation's image; a call of either tool on another reference is therefore not approved in the chat: the card says that the proposal concerns a different image, and an approval is refused. After an approved write the chat confirms it itself, "Alt-Text gespeichert." or "Als dekorativ markiert.", in place of the model's answer, when the run recorded the write. When the write of an approved text did not land, the model's answer says why and the proposal stays, as the specification has it („der Vorschlag bleibt erhalten“): Erneut anbieten asks the model to propose the same text again, as a new card; the server takes the text from the conversation, never from the browser.

Moving on. Another row's Optimieren and Nächsten Treffer optimieren move the conversation to another image: a divider naming it, its card and a new turn, the waiting proposal withdrawn. The next hit is the open hit after the conversation's image in list order across the tabs (pages first, then the record types by their label), after the last the first again; the conversation keeps the image's place in the list, so after a fixed image the next one is the one that followed it. The server refuses every move while a turn runs; the panel keeps an Optimieren that comes meanwhile and takes it up when the turn ends, unless the chat is closed in between. Whether the chat folded the module menu is kept in the user's settings, so closing the chat after a reload unfolds it as well.

The image goes to the model as a scaled copy, where it may see it. A provider that supports vision gets a PNG copy of at most 1024 pixels with the reference's crop; an image that does not come out as a raster image (an SVG the image processor does not convert) is not sent. The card's note is decided with the copy: wherever no copy goes to the model — no vision, a file that cannot be read or converted — the card says that the proposal rests on the page, the element and the file name. Whether an image may be sent at all is AltTextImagePolicyInterface, where slice 3's setting plugs in (below).

Slice 3 

The setting is extension configuration. The specification places Bildanalyse in "AI Assistant / Wissen & Einstellungen, Bereich Datenschutz", changeable by administrators only. The module has no such area, and the extension keeps every administrator setting in its extension configuration; a second store for one setting is not added. So it is imageAnalysis (analyse, Bilder analysieren, or contextOnly, Nur Kontext verwenden) and imageAnalysisExcludedFolders, in the category privacy. The extension configuration is changed by administrators with system maintainer rights, fewer than the specification's administrators. The labels follow the extension configuration's convention, English, and carry the specification's explanation (data processing agreement, people on images) in short form; the specification's status line in the navigation ("Bilder werden analysiert" / "Nur Kontext, keine Bilder") has no place without the area and is not built. Both come with the area, if it is built.

Default: images are analysed. The specification marks Bilder analysieren as the default, which is also how slices 1 and 2 behave. A value other than the two (only possible when written by hand) sends no image: a typo must not send images by mistake.

One policy decides, for every file this extension sends. ImageAnalysisPolicy replaces SendEveryImage behind AltTextImagePolicyInterface, and the chat depends on the interface too, so rebinding it changes every path. The specification says the setting applies "für alle Bilder und für alle Funktionen des AI Assistant, die Bilder verarbeiten", so the same policy also decides a file attached in the chat. With Nur Kontext the chat offers no image format and no format the provider reads natively as a document, and the upload endpoint, which checks against the same list, refuses them; what text extraction reads stays. An attachment is asked about whatever its type, because a document the provider reads natively (a PDF for Claude and Gemini in nr-llm 0.41.7) carries its photos along: a refused image is replaced in the model's turn by a line saying that it is not sent and why, and a refused document goes as its extracted text only (the extractors return text), or as that line where no extractor reads it. The refusal carries the note code and the folder it names (AltTextImageRefusal); the interface's return type changed from a bare code for that, and it gained refusalForFile() and analysesImages(). nr-llm's own image features (its vision service and modules) are not this extension's and are not covered; none of nr-llm's tools the chat calls sends an image.

The setting comes before the provider. AltTextImage asks the policy before it asks whether the provider can see images: where the settings keep an image back, the card says so also on a provider without vision, because that reason holds whatever the provider. The card's note and what the model gets are still decided in one call (prepared()): a refused image is not even processed.

Excluded folders are bound to their storage. An entry is fileadmin/team/ (the base path of a local storage in the web root and the folder) or 1:/team/ (the storage uid and the folder); the second form reaches storages outside the web root. A folder includes its subfolders and is compared with a slash at either end, so /team/ does not take in /teamwork/, and /team/ in another storage is not excluded. Comparison follows the storage's case sensitivity. An entry is resolved storage first: its uid, or its base path at the start of the entry in any case (Fileadmin/team/ is fileadmin/'s /team/); then the rest as a folder of that storage, with . and .. resolved. Taken the other way round, fileadmin/../user_upload/x/ would name another storage's folder; it climbs above the storage's root and is refused. An entry that names no storage at all (portraits, user_upload/team/) is a folder of the default storage (is_default), as the file list shows it under fileadmin; an entry that starts with a storage's base path never falls back, so a step above that storage's root stays refused. Folders are compared by their identifiers, so a folder that does not exist yet is excluded all the same and costs nothing when an image is asked about. An entry that names no folder of a storage (one that climbs above the root, a bare folder without a default storage) excludes nothing; it and a folder that does not exist are logged as a warning, once per configured value and process: a typing error otherwise shows only as images that are sent. The first configured folder that holds the image is the one the card names, written as an editor knows it (fileadmin/team/, or 2:/team/ for a storage outside the web root).

The card's lines are the specification's. Nur Kontext: "Die Bildanalyse ist in den Einstellungen abgeschaltet." An excluded folder: "Bilder aus dem Ordner {Ordner} werden nicht an die KI übertragen." Both go on, like the other notes: "Der Vorschlag beruht nur auf Seite, Content-Element und Dateiname. Prüfe, ob er zum Bild passt." The folder is kept on the card as data (imageNoteFolder) and filled into a positional placeholder. The preview in the chat and the list stays; it is a backend image and never goes to the provider.

A turn runs with the setting of the request that started it. TYPO3 loads its configuration into $GLOBALS['TYPO3_CONF_VARS'] once per process (Bootstrap::populateLocalConfiguration()), and the core's ExtensionConfiguration::get() reads it from there. A worker (processingStrategy = worker) is one long process, so reading the setting anew inside it still yields the value it started with. Reloading the configuration there is no way out: exportConfiguration() resets what every ext_localconf.php added to TYPO3_CONF_VARS, and reading settings.php alone drops the values additional.php sets (the E2E setup sets imageAnalysisExcludedFolders there). Every turn, however, starts in a backend request that reads the configuration anew and hands the turn over by writing the status processing. That same write stamps the two values on the conversation (image_analysis, ImageAnalysisTurnSettings), so a worker cannot take the turn before its stamp is there; the turn overlays them on the process's configuration and puts the process's own back afterwards. Values, not a decision, are stamped, per turn rather than per message or attachment: attachments are read again on every turn, so a stamp from the time a file was attached would go on sending it after a switch to Nur Kontext. The policy reads the setting on every decision, and resolves the folders once per value. A turn queued before the stamp existed runs with the process's values. The card also binds the turn: where it says the setting keeps the image back, the image is not sent, also after the setting allows it again, until the chat moves to another image. That is the one direction a change does not reach at the next turn, chosen so that the card and the model never disagree and the error, if any, is an image not sent.

Known limit: a continued run resends what it already sent. A turn that continues after an approval or an answer (performRecordedDecision(), performRecordedInput()) resumes nr-llm's stored run, and nr-llm keeps that run's messages as they were, image and document blocks included (ToolLoopService). After a switch to Nur Kontext, approving a card that was already open therefore sends that run's image again. It is the same data, sent in the same run to the same provider before the switch, so nothing new leaves the installation; the next turn follows the setting. Rebuilding the stored run would mean rewriting nr-llm's state from outside for a case that needs a card open at the moment of the switch.

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.
  • Slice 3 sends images as before until an administrator changes imageAnalysis; with Context only the chat no longer accepts image attachments, and a PDF goes as its extracted text. It adds the column tx_nrmcpagent_conversation.image_analysis: run the database analyzer after upgrading. Every write of a conversation names the column, so until the analyzer has run no chat can be started or continued.
  • Slice 2 needs nr-llm 0.41.7 (set_file_reference_alternative_text) and adds the column tx_nrmcpagent_conversation.alt_text_hit: run the database analyzer after upgrading. An administrator enables both writing tools, nr-llm's and set_file_reference_decorative, and gives editors the field sys_file_reference:tx_nrmcpagent_decorative.

ADR-027: Every List Names a Chat by Its Context 

Status: Accepted

Date: 2026-10-30

Context 

Editors found the lists of conversations unusable. On the demo, 49 conversations had 25 distinct titles, and 20 were called "Diese Seite prüfen":

  • Every guided tour had the same title. createConversation() starts a tour about a page with the fixed message guided.checkPage (ADR-023: The chat guides a process), and a conversation takes its first user message as its title.
  • Only one list had context. The dashboard widget "Recent chats" showed the tour's skill, its page, its point and the time, but in a narrow column and for five chats. The chat module's list, the panel's tabs, its "more" list, its sidebar and its minimized bar showed the title and the status, because the list endpoint sent nothing else.
  • Empty chats were listed as "New chat" (five on the demo).
  • The status colours said the wrong thing. A ready conversation was green, as if it had succeeded; a conversation waiting for the editor's answer was blue, like one that is still running.

Decision 

One display model, built on the server 

ConversationListPresenter turns a conversation into what a list shows (ConversationListItem). The list endpoint and the dashboard widget use it; the chat module and the panel render its fields with one shared row (conversation-list.js). Per conversation: the title, the full title for the tooltip, the context, the page (only one the user may see in the page tree, titled in the conversation's language where it has a translation the user may edit), the skill's title, the point of a tour, when it was created, when its last message was written, and the status with its tone and label.

When the last message was written is kept on the conversation (last_message_at): the repository writes it with every save of the messages, in the same transaction, from the last message's createdAt. A list therefore never reads a transcript. A row saved before the column existed is read from its last message row until the upgrade wizard nrMcpAgent_lastMessageTime has filled it; a wizard rather than a lazy backfill, because a list request should not write, and a conversation nobody opens again would otherwise never be filled. The server sends the time as ISO 8601; the client words it ("Heute, 10:42") in the reader's time zone and anew at every render, so a list left open past midnight says "Gestern"; a time slightly ahead of the reader's clock counts as now. The queries of the lists already order by it (`CASE WHEN last_message_at > 0 THEN last_message_at ELSE tstamp END`, portable across SQLite, MySQL/MariaDB and PostgreSQL); the wizard walks the table once, batch after batch from the last uid of the one before.

The title is decided at display time 

Stored rows keep their titles; no migration rewrites them. Where a title comes from is kept in title_source: manual once the editor renamed the conversation (renameConversation()), tour for the title a new guided tour is stored with, empty otherwise and for every row from before the column. Neither is part of the full-row write (Conversation::toRow()), as the instructions are not: the worker's final save carries the title it loaded, and a rename during a running turn would be lost. add() writes them with a new row; a save writes the title it carries only `WHERE title_source <> 'manual'``. A rename is cut to the 255 characters of the ``title` column (ConversationTitle::fit()), never inside a character made of several code points; the rename field takes no more (maxlength).

  • A title the editor gave (manual) is never replaced.
  • A guided tour whose title is automatic is shown as "<skill> · <page>" with the page's current title (ConversationTitle). Automatic means: marked tour, or, for a row without a mark, exactly one of the titles the extension gives — empty, the tour's start message in the user's language, a shipped one or an installed backend language, the skill's title, or "<skill> · <page>" with the page's current title. Never a prefix: the rename field starts with the derived title, and "<skill> · <page> (Kunde X)" is the editor's. The page is left out when the user may not see it or it is deleted.
  • Any other title is shown with its whitespace collapsed and, when it is longer than 60 characters, shortened to its first sentence (from 20 characters on) or cut at a word boundary with "…". The full text is the tooltip. Lengths are counted in grapheme clusters, so a cut never splits an emoji or an accented letter. This covers a chat's first message, "Alt-Text: <file>" and a title the editor gave.
  • Two alike — the same tour twice on the same page — get the time they were started after the title ("SEO optimieren · Über uns · 20. Okt., 08:00"), so a tab tells them apart without its tooltip. Started in the same minute, they get the second ("11:27:01", "11:27:44"); started in the same second, a number in the order they were started, then by uid ("11:27:44 (1)", "11:27:44 (2)"), the same on every load. The endpoint sends that suffix as titleSuffix as well, and every list that has to shorten a title shortens the part before it: the time is what tells the rows apart.

The context names what the title does not 

A tour shown as "<skill> · <page>" gets Guided tour and its point; a tour the editor renamed gets the page, the skill and Guided tour. A plain skill that needs a page (ADR-025: The Skill Says Whether It Needs a Page) gets its page and the skill. Every other conversation names no page: it stores the page the browser showed with its last message, which says where the editor was, not what the chat is about. That rule comes from the widget, unchanged.

Lists 

  • Every list is ordered by the last message, newest first, pinned conversations first where pinning exists. tstamp alone moved with a rename or a pin.
  • A conversation without a message is not listed, except the open one: the endpoint still sends it, because the chat opens a conversation it has just created from that list. Since a user can neither see nor archive it, a start that runs nothing takes up the user's empty conversation of the same start (same page, language and skill, no instructions, title or pin of its own) instead of creating another, and the overview counts only conversations with a message.
  • The panel's tabs show the status icon and the title; the context, the status in words and the time are in the tooltip, and the context and the time are each tab's description (aria-describedby, pointing to a hidden element, so browse mode does not read it twice) for keyboard, touch and screen readers. The "more" list searches the title, the full title and the context.
  • The dashboard widget is full width and lists ten conversations. Ten rows with a context line are taller than large, the tallest height a widget can have, so the list scrolls in a region of its own (a <section> named after the widget, so a region, focusable so it scrolls by keyboard); the hint above it and "New chat" and "Show all chats" below it stay in place. Before, the dashboard's content area scrolled hint and list together and cut the last row off against "New chat". The dashboard keeps the size of a widget it has placed, so the wizard nrMcpAgent_recentChatsWidgetSize removes the widget's stored position where it is smaller, on dashboards arranged before 0.26. The dashboard then places it full width in the first rows free across the whole width, so it usually moves below the widgets that shared its rows; the other widgets keep their positions. In a narrow widget the status and the time move under the title; the arrow stays beside it.

Status 

Ready is neutral, waiting for the editor's answer or approval is a warning with an icon, running is information, failed is an error. Every list says the status in words; the colour only repeats it. Each tone sets a background and a text colour that do not depend on the row around it, so a badge reads the same in the selected row; neutral is core's default badge pair, not the variant text colour, which TYPO3 14.3 mixes from currentColor.

Consequences 

  • Existing tours read "<skill> · <page>" in every list without a migration; they follow a renamed page.
  • A page the user may not see is no longer a reason for the widget to leave a tour out: the chat module listed it anyway. It is listed without the page.
  • The table gains title_source and last_message_at; run the database analyzer, then the wizard nrMcpAgent_lastMessageTime.
  • A tour renamed before title_source existed to exactly a title the extension gives is treated as automatic. Every rename since is marked.
  • The pages of a list are read with one query per language of the conversations and one for their translations, with the same checks as BackendUtility::readPageAccess() — the "show page" clause, the web mounts, the workspace overlay — but without the record path no list shows. Translations are read as BackendUtility::getRecordLocalization() reads them: live rows and new ones of the user's workspace, no version rows, nothing of another workspace, then overlaid. The mount check stays per page; core caches the rootlines it reads.
  • The list endpoint reads the context columns of tx_nrmcpagent_conversation (view_context, skill_uid, guided_state, alt_text_hit, title_source, last_message_at) and no message rows, except for rows the wizard has not filled yet.

ADR-028: The answer reaches the reader while it is written 

Status: Accepted, amends ADR-007

Date: 2026-11-01

Context 

The chat ran a turn in a background process and polled every two seconds. A reader saw nothing of an answer until the turn had ended, and then, on average, another second later. On the demo the median turn took 11 s. nr-llm ADR-240 makes the text of a tool round available while the model writes it, in the process that runs the turn. It has to reach the browser from there.

Decision 

The conversation's row carries the text. The process writes the whole text so far to live_text, at most every 100 ms (LiveTextRecorder), and empties the column once the turn's messages are stored. Every reader starts from the same state, so a second tab, a reload in the middle of an answer and a reconnect need no code of their own. Only the transcript is a record.

A request of its own delivers it. GET /ai-chat/conversations/stream (ConversationStream) reads the row every 100 ms and sends what changed as Server-Sent Events, with the same headers and padding as nr-llm's playground and the Cowriter dialog. Through the demo's chain (Caddy, nginx, PHP-FPM) the Cowriter route delivered its events as they were sent, measured on 2026-11-01.

ADR-007 is amended, for its two reasons. The connection limit of FPM is met by maxLiveStreams and a lease; the message bus between the processes is the row, which the stream reads. Polling stays the contract.

Polling stays. The stream carries the text, the activity and the number of messages, nothing else. A message that arrives inside a turn that goes on makes the chat poll at once, as the end of the turn does. Its done event makes the chat poll once, at once, and that poll brings the messages, the approval card, the question and the guided state exactly as before. While a stream is open the poll runs every 10 s to notice a stream that went quiet. When a stream is refused or breaks without having delivered, the chat goes on polling every 2 s, and the poll shows live_text too.

A stream is bounded, because it holds a PHP-FPM worker. It opens only for a running turn and ends with it. It ends after 100 s in any case, and the chat opens a new one. maxLiveStreams conversations stream at the same time, 3 by default. A lease on the row counts them, so a stream that died stops counting after 15 s. A conversation over the limit is refused and polls. Two readers can pass the count at the same moment and go one over, and two tabs on one conversation count once: the limit is a margin below pm.max_children, not an exact number. The nginx comment of the demo records what a pinned pool costs.

No announcement per token. The running text is aria-hidden. The finished message is added to the live region as before and is announced once.

What a guardrail holds does not appear early. nr-llm decides: with a guardrail that holds a stream back (nr-llm ADR-238) the round is not streamed, and the text appears with the message. Otherwise a guardrail that refuses the finished text fails the turn after the reader saw it; the running text then goes and the error stands in its place.

Consequences 

  • The reader sees the first text about when the model has written the first 128 bytes, the part nr-llm's secret redaction holds back. An answer shorter than that appears when it is complete.
  • A model round that only calls a tool shows no text; the activity list and the typing indicator say the turn is working.
  • A provider that cannot stream a tool round behaves as before, through the poll.
  • Every running turn with a reader causes ten small reads per second and one write per 100 ms of model output.
  • Two more columns on the conversation. Neither is part of the full-row write, so a turn's final write cannot put back an old value.

ADR-029: A booted process waits for the turn 

Status: Accepted

Date: 2026-11-01

Context 

ADR-002 runs every chat turn in a process started for it. Before the model is called, that process boots TYPO3, which the reader waits for on every message and every decision. ai-chat:worker existed as the alternative, but it never ended, one worker ran one turn at a time, a turn waited for a worker that was down until ai-chat:cleanup found it, and a long-lived process keeps what a turn left in memory: Extbase's identity map, runtime caches, and the configuration it booted with.

Decision 

The boot moves in front of the turn, and the process still ends with it. A worker boots, waits for a turn, runs it and ends; --max-turns=1 is the recommended setting and its supervisor starts the next one at once. So the reader no longer waits for the boot, and every turn still gets a fresh process: nothing of a turn reaches the next one and memory cannot grow. The price is a boot after each turn instead of before it, which nobody waits for, and a changed configuration counts from the next boot, so at the latest after --time-limit. (Since the round-cap amendment of ADR-023 of 2026-11-04, a worker stops before its next turn when the settings were saved since it started.) TYPO3 reads the time once at boot, so a worker sets it again when it takes a turn.

Keeping a process across turns stays possible and is not the default. --max-turns=0 saves that boot and needs the state above to be safe. It would also keep a connection to the provider open between turns, which saves little: in six direct calls of a chat turn each, 0.98 s with a kept connection against 1.02 s with a new one.

A turn never waits for a worker that is not coming. A worker reports itself idle every two seconds in tx_nrmcpagent_chat_worker, and takes its row away with a turn. A dispatch compares the idle workers with the turns no process holds and, with too few, starts a process as exec does. So all workers being busy costs a boot, a worker that is down costs a boot, and a deploy costs a boot. Both may reach for the turn; the claim in dequeueForWorker() and claimForProcess() lets one win.

The limits end a worker, not a turn. --time-limit and --memory-limit take effect between turns. SIGTERM and SIGINT let a running turn finish. --turn-limit is the one exception: a turn that exceeds it ends the process, after the provider call it may be blocked in, which has a timeout of its own. A supervisor's stop grace period above it lets a deployment wait for a running turn.

A turn is never run again by itself. A turn that was killed or exceeded the limit may have written, and the duplicated pages of an earlier version came from a second run over the same transcript. It fails with the state ai-chat:cleanup leaves, without a pending decision, and the reader gets a Retry.

Consequences 

  • The reader waits for a poll of the queue, --poll-interval at most, instead of a boot.
  • Turns that run at the same time need as many workers. The rest are served by a process of their own, at the old speed.
  • A deployment runs one more service per worker. The extension works without any: processingStrategy stays exec by default.
  • SIGTERM handling and --turn-limit need pcntl. Without it a worker dies on SIGTERM, in the middle of a turn if there is one, and a stuck turn waits for ai-chat:cleanup.
  • An idle worker queries the database every --poll-interval (200 ms unless set) and writes every 2 s.

ADR-030: A later turn reads what ran 

Status: Accepted

Date: 2026-11-02

Amends: ADR-017: The chat states what the run did, not what it said (the note beside a flagged answer) and ADR-025: The Skill Says Whether It Needs a Page (the page of a turn sent without one).

Context 

On the demo an SEO tour saved six fields of a page: the page title, the OpenGraph image, title and description, and the X title and description. Each was a card the editor approved; the record history shows every write. Three days later the editor asked in the same conversation "was ist hier passiert?". The answer was that nothing had been checked or changed, that the earlier answers had been misleading and that the tour had not taken place.

The model had not seen the writes. A turn is a run of its own, and a new run gets the transcript's text and nothing else: the tool calls of earlier runs, their results, and what became of their cards are not part of it. The tour was one run that waited on each card and continued after it, so while it ran the model had all of it; once it ended, none of it reached a later turn. The run of the question sent six messages — the system prompt, three user messages and two assistant messages that joined the tour's own text — and no tool round. The outcome lines the reader saw ("Seitentitel gespeichert") are the chat's own rendering of card_outcomes and never went to the model.

What the model did see was its own proposals ("Ich schlage … vor") and the rule of ADR-017: claim a change only on a successful write result, otherwise say plainly that nothing was saved. Applied to a transcript without results, the rule produced the denial. Time played no part: a question one minute after the tour ends reaches the model the same way. Neither did the versions between the tour and the question; the transcript has carried text only since the chat stored the text beside tool calls.

The page was a second, separate gap. Ending the tour clears the skill (ADR-023), and a conversation without a skill follows the browser (ADR-025). The question came from where no page is shown, so the turn stored page 0 and the model was told that no page is selected.

Decision 

The chat keeps a record of every run segment and gives it to every later turn. When a run segment returns — a turn, a continuation after an approval or an answer, or one that failed — the chat stores in run_record:

  • how the run ended (completed, waiting, failed, …; unknown when the turn failed before nr-llm returned its steps);
  • the tools that ran, with the number of calls and of failed calls, leaving out the tools that only drive the chat's display or ask the editor, and the calls of a turn whose card was not approved: nr-llm records those as tool steps with an error result before the continuation's first model round, and listed as failed they read as failed attempts to save (amendment of 2026-11-02; two of five answers to "was ist hier passiert?" after three Behalten answers on the demo said so), and the calls nr-llm held back while the run waited (see the amendment below);
  • the records written, as table:uid, and whether nr-llm reports the write complete, partial or unconfirmed;
  • the calls still waiting for a decision;
  • the decision taken on a card, where no card outcome already says it — a plain denial records none there;
  • a card or question the editor left undecided, by writing a new message or ending the tour: otherwise its record would go on saying that it waits. The note says that nothing from it was saved in the chat. The run of an ordinary chat is not cancelled and may still be decided in AI Tasks; the note says that such a later decision is not recorded here. An edit records nothing: it cuts the waiting turn away.

Each entry also notes how many card outcomes existed before it, so notes at the same transcript position keep the order in which they happened. The row keeps the newest 200 entries.

An edited message takes its turns' record with it. Editing a message cuts the transcript after it; the run records and card outcomes of the cut turns go too. Kept, they would be read as the record of the turns that take their place.

The record is built from the run's structured steps, never from the model's words, and only from what nr-llm's privacy filter keeps at every level: tool names, error flags, write targets and completeness. Tool arguments and results are not read, so no stored value reaches a later prompt through it. Card outcomes add the field names a card named. Every identifier is reduced to the characters identifiers use.

The record goes to the turn it belongs to, as the chat's words. Each turn builds notes from the record and the card outcomes, labelled "[Chat record — written by the chat from the run, not by the assistant: …]", and places each in the assistant message of the segment it describes; a segment without an answer gets an assistant message of its own between the two user messages around it. The model is called on a user turn, so a note that would open or close the transcript — the record of a failed segment, read again by a retry — goes into that user message instead, labelled the same way. The notes are never stored in the transcript. At most 40 notes and 6000 characters are sent. Over that budget, notes of steps in which nothing ran, nothing was written and nothing was decided are left out first, then the oldest; the first note then says that older notes exist and cannot be read in this turn, rather than letting the gap read as "nothing happened".

The note beside a flagged answer is scoped to its step (amends ADR-017). It said "Nothing it describes as done was saved". An answer that sums up what earlier steps saved gets the nothingSaved notice too, and the note then told the model that those saves never happened. It now says that a change the answer describes as done in this step was not saved, and that what earlier steps saved is in the chat records.

A turn sent without a page keeps the conversation's last page apart from the selection (amends ADR-025). view_context keeps lastPageId when a turn comes with page 0. It is no selected page: the skill invocation, the highlight, the list and everything else that acts on the selected page read pageId as before. Only the prompt uses it: instead of "No page is selected", the model is told that no page is selected now, which page was last selected in the conversation, that the transcript and the chat records say which pages earlier steps worked on, and to ask before new work on a page the user does not refer to. The page is named only when the user may see it, as the selected page is.

No new system-prompt rule. The record is the fact; whether the model also needs a rule to answer questions about past actions from it is measured on the demo after the release, and a rule is added only if the measurement shows it is needed.

Consequences 

  • The conversation table gains the column run_record. Rows from before have none; their card outcomes still reach the model as notes, so a tour finished before the upgrade is reported from its cards.
  • Every turn carries its notes; a tour of eight segments and six cards adds about 2,500 characters. Conversations longer than the budget lose their oldest notes, and the model is told so.
  • A conversation about a missing alt text (ADR-026) replaces the transcript with its own and gets no notes: its write is confirmed in the transcript already.
  • The record says what ran and what was written, not what the values were. "Which title was saved?" is answered by reading the record, as before.

Amendment of 2026-11-02: calls nr-llm refused 

Context. nr-llm records every call it refuses as a tool step with an error result, like a tool that ran and failed. The chat's activity list showed "fehlgeschlagen" under "Von dir abgelehnt" after every Behalten, and the run record counted calls nr-llm held back as failed tools. nr-llm 0.41.7 to 0.43.4 refuse calls in these places (ToolLoopService):

Refusal Reading Shown to the editor
A declined card's calls, and a load_skill of the declined turn decision the answer (Behalten, Überspringen, Andere Variante, or "abgelehnt"), no tool entry
An approved call whose tool is no longer offered (switched off, admin-only) failure (not_permitted) "nicht ausgeführt: nicht mehr freigegeben", and to ask an administrator to allow it again
After an approval: a question (ask_choice) the model asked in the same turn, outside a guided process not run (held) "noch nicht ausgeführt"; the model asks it again
After an answer: the question's own tool, no longer offered failure (not_permitted) as for the approved call: the answer was not taken
After an answer: another call no longer offered, a second call that needs input, a write without an approval not run (held) "noch nicht ausgeführt"
Beside a proposal or a question: a second write, a call that needs input, a remote call not run (held) "noch nicht ausgeführt"
A call the invocation policy refuses (not permitted, unknown, denied by a rule), in any segment failure "fehlgeschlagen", as before: nr-llm times these like a call that ran

Decision. RefusedCalls reads one segment's steps for both the activity list and the run record, so the two cannot disagree. It reads structure, never the result text: where the step stands (a continuation opens with the waiting turn's calls, before its first model round), its duration (nr-llm records the refusals of a waiting turn's calls with exactly 0.0 ms; a call that ran is timed) and whether the tool asks the editor something (ask_choice, nr-llm's only such tool: after an answer nr-llm runs every call of the question's tool with the answer and refuses one only when the tool is no longer offered). A declined card's calls get no entry in the list and no place in the record; a call held back is listed as not run and left out of the record; an approved call nr-llm no longer permits is a failure in both, with its reason in the list. An answered question nr-llm no longer permits is a failure in the list; the record leaves ask_choice out, as it always did. A declined card's entry names the answer the editor gave.

Consequences.

  • The reading depends on nr-llm recording refusals with a duration of 0.0. A refusal recorded with a measured duration would be shown as failed again, which is the state before this amendment.
  • After an answer, another call whose tool was switched off while the question waited reads "noch nicht ausgeführt", not "nicht mehr freigegeben": nothing in the step separates it from the other refusals of that path. nr-llm would have to type the refusal for the chat to tell them apart.
  • A tool of another extension that asks the editor something is read like any other tool until it is added to RefusedCalls::QUESTION_TOOLS.
  • Activity rows written before the upgrade keep their entries.

ADR-031: The module is an overview, the chat a route 

Status: Accepted

Date: 2026-11-03

Amends: ADR-011: Floating Chat Panel Outside the Module iframe and ADR-024: The Chat as a Column Beside the Page Tree (where the chat is opened).

Context 

The module nr_mcp_agent_chat rendered the whole chat, the same <nr-chat-app> the panel from the top bar shows (ADR-011: Floating Chat Panel Outside the Module iframe) and the column beside the page tree (ADR-024: The Chat as a Column Beside the Page Tree). A user who opened the module saw a second copy of the chat and nothing about whether it worked for them, how it was configured, or how it had been used. The module sat under Administration, while nr_llm's modules sit in the shared section AI (nr_llm ADR-183).

Links built from the route (dashboard widgets, the missing alternative texts, ChatStartUriBuilder) are the fallback for a middle click and for a user without the panel: the chat reads conversation, skill, pageUid, languageUid and altTextReference from the URL.

Decision 

  • The module's first page is an overview (OverviewController): what the chat does and where it appears, whether it works for the viewer, the extension configuration (administrators), usage in a chosen period, and links into the chat and into nr_llm.
  • The full-page chat is the sub-route nr_mcp_agent_chat.conversation (path /conversation), the shape of missingAltTexts. Every link builder targets it. A request to the first page that carries one of the chat's parameters is redirected there, so a link built before this change still opens the chat.
  • Readiness asks the rules the chat applies, not a copy of them: ChatAccess (the toolbar item's access rule) and ChatTaskResolver (Task → Configuration, active, usable by the actor), which ChatService now calls too. A state the page cannot measure is shown as not checked. A worker busy with a turn leaves no row, so "no idle worker" is a warning, not a failure: turns then start a process of their own.
  • Usage comes from the conversation table and from nr_llm's public ExtensionUsageReaderInterface (nr_llm ADR-248) for the source extension nr_mcp_agent. Administrators see the installation's figures, everyone else their own. No message content and no other user's conversation title is shown; failed conversations are counted per error code, never with their message.
  • The module moves to the section netresearch_ai. Its identifier and path stay, so bookmarks keep working.

Consequences 

  • maxConversationsPerUser is read nowhere; the overview does not list it, and the configuration documentation says so.
  • Agent runs, analytics and records stay in nr_llm's modules; the overview links to the ones the viewer may open.

ADR-032: Missing alt texts are fixed in a draft workspace 

Status: Accepted

Date: 2026-11-05

Amends: ADR-026: A Site-Wide Check for Missing Alt Texts (the refusal of a draft workspace, the decorative mark written in live only).

Context 

"Optimieren" in the list of missing alt texts (ADR-026: A Site-Wide Check for Missing Alt Texts) refused an editor working in a draft workspace: the start, the switch to another image, "Nächsten Treffer optimieren", "Erneut anbieten" and "Als dekorativ markieren" answered 409 workspace, the list showed no button, and set_file_reference_decorative refused every workspace but live. The reason was nr-llm: its writers wrote the live workspace only.

That has changed. A conversation records the workspace its turn was handed over in, the worker sets the user up in it, and the run's actor carries it (nr-llm ADR-260, this extension since 0.33.0). nr-llm's set_file_reference_alternative_text writes a version of the reference in the workspace the run recorded (nr-llm #1294), and refuses a draft workspace the run did not record. The list itself already read the editor's workspace (ADR-026: A Site-Wide Check for Missing Alt Texts, Workspaces).

What core does with the one write involved, a datamap of sys_file_reference by the live uid, as a backend user in a draft workspace (DataHandler::process_datamap(), TYPO3 v14.3.10 lines 921-945, v13.4.37 lines 909-933): a live record of a workspace-aware table that the workspace may not edit live (workspaceCannotEditRecord(), v14.3.10 lines 9564-9584, v13.4.37 lines 9430-9471) gets its existing version in the user's workspace written, or a version created by a version/new command and then written. A row of another workspace is refused (v14.3.10: "Workspace ID of record does not match current user workspace", v13.4.37: "Workspace ID of record didn't match current workspace"). sys_file_reference is workspace-aware (versioningWS) in both versions.

Decision 

A draft workspace is no refusal. AltTextGate::refusal() answers workspace only for a user who may work in no workspace (core sets a negative workspace): there a turn records none, and nothing could be saved. The label error.altTextWorkspace says that. In a draft workspace it answers liveOnly where the installation's TCA turns the versioning of sys_file_reference off or lets it be edited live (see below): both writers refuse there, and the label error.altTextLiveOnly says so. Every other step of the flow works in the workspace the editor works in when they take it, and the turn records it.

The image is decided on as the workspace shows it. ReferenceWriteAccess reads the reference and its record as nr-llm's writer reads them (nr-llm ADR-260): in a draft workspace the live row overlaid with its version there, or a row new in that workspace; a version row named by its own uid and another workspace's row are not there. The pages and the inline owners are read live, as nr-llm reads them, so the gate lets through what the writer accepts and nothing else (ImageReferenceRepository::findAsTheUserSees()).

The decorative mark writes into the workspace the run recorded. DecorativeImageWriter writes through nr-llm's ToolDataHandler as before. In a draft workspace it writes only where the run's actor recorded that workspace (ToolExecutionContext::$actor->workspaceId, nr-llm @api), and only where core keeps a draft of sys_file_reference (versioningWS set, versioningWS_alwaysAllowLiveEdit not, which TYPO3 13.4 also honours for a versioned table). The read-back reads the version through the live uid. The tool's answer ends with "Stored as a draft in the workspace …", so the model does not report a draft as live.

The card names the workspace. set_file_reference_decorative adds "Workspace: “…” — saved there as a draft, live stays unchanged until the workspace is published" (the acting user's workspace, the one the run writes into), shows the record as the workspace shows it, and shows only the refusal for a call its write will refuse for the workspace, so no card promises a draft that is not stored. Who may read the card is nr-llm's decision (nr-llm ADR-136, ADR-260); the tool's own check runs against the viewer in the viewer's workspace.

Amended 2026-11-08: the proposal's card names the workspace too. It shows the proposed text instead of the preview lines of nr-llm's set_file_reference_alternative_text, and with them it hid nr-llm's line naming the workspace. ApprovalCallPresenter hands that line over as the call's workspace, recognised by nr-llm's own label (approvalPreview.workspace.target) in the preview's language and, without one, by nr-llm's source text; the proposal's card and the proposal block of a process card that shows nr-llm's structured values show it. In live nr-llm writes no such line, and the card shows none. The decorative mark's line is built from the same nr-llm labels (with approvalPreview.value.quoted for the quotation marks) instead of a translation of this extension's own, which read the same in English and German.

The model reads the texts of the workspace. AltTextContext reads the page, the content element, the elements around it and a record's text fields as the workspace of the turn shows them.

Provenance needs nothing here. nr-llm dispatches AfterAiRecordWrittenEvent for the tool's write target with the acting user's workspace; its MarkAiWrittenRecord marks pages and tt_content only, not references.

Consequences 

  • An editor in a draft workspace gets "Optimieren" and the whole flow; the alt text and the decorative mark are stored as a version of the reference in that workspace, and live stays as it is until the workspace is published.
  • nr-llm must be a version that contains #1294 (the first release after 0.43.17); the release of this extension requires it. With 0.43.17, nr-llm's alt text writer still refuses a draft workspace: for a live reference after its card, for a reference new in the workspace already on the card. The decorative mark works with 0.43.16 and later.
  • ✗ A reference on a record whose page, or on an inline record whose owner, is new in the workspace is in the list but refused at "Optimieren" with the neutral 403, because nr-llm reads those pages and owners live. In a page's own file field the page is the record and is read as the workspace shows it, as nr-llm reads it.
  • ✗ An alt text that is set live and emptied only in the workspace is still not listed there (ADR-026: A Site-Wide Check for Missing Alt Texts).
  • ✗ An element moved onto the page in the workspace is not among the elements around the image that the model reads. On a page with more than 500 elements in the image's language, the elements around it are picked from the first 500 by sorting.
  • ✗ Writing the alt text or the mark on a reference whose draft was already sent to review sets its stage back to "editing", as every DataHandler write in a workspace does and as nr-llm's field tools do.
  • ✗ The scaled image the worker sends to the model is cropped as the live reference says: outside a backend request, core's ResourceFactory reads the reference without the workspace overlay.

ADR-033: A site's own configuration runs the chat 

Status: Accepted

Date: 2026-11-11

Amends: ADR-015: nr-llm Task per Backend Group via Extension Configuration (the Task decides the configuration; for a site with its own configuration it no longer does).

Context 

nr-llm lets a site name its own LLM configuration in the site setting nrllm.configuration (nr-llm ADR-244). An installation uses it where a site's content may only go to one provider, or one model. cowriter, nr-landingpage and nr-repurpose honour it. The chat passes the site of the conversation's page to nr-llm since 0.36.0, so the site's term lists apply, but it ran every turn on the configuration of its Task: in ADR-244 a configuration the caller passes wins over the site's, and the chat always passed its Task's. A site set up for one provider therefore had its pages read and edited through the Task's provider.

The product decision of 2026-11-11 (NEXT-169): where the site of the page sets its own configuration, the chat uses it instead of the Task's. If its model cannot call tools, the chat refuses with a message that names the reason and what an administrator can change. It never falls back to another model, because that would break the site's provider requirement.

How nr-llm records whether a model can call tools, as measured on nr-llm 0.43.23:

  • The model record has a capability tools (Tool Use, Model::supportsTools()). nr-llm requires it of a model it selects by criteria for a tool call (nr-llm ADR-138, OperationCapabilityMap) and does not check it for a fixed model. Discovery writes it for Mistral, OpenRouter and Ollama, which report it per model, and for the curated OpenAI, Anthropic and Gemini entries. Groq reports no capabilities, so its models lack the flag whether or not they can call tools.
  • The provider adapter implements ToolCapableInterface. nr-llm's agent loop refuses an adapter that does not, with "does not support tool calling".

Neither alone is reliable: the adapter answers per provider, not per model, and the flag is missing on every Groq model.

Decision 

The site's configuration replaces the Task's. At the start of every turn, a retry included, SiteConfigurationSelector asks nr-llm's ConfigurationResolver::resolveForSite() for the configuration the site of the conversation's page names, with the turn's actor. The site is the one siteOf() already passes to nr-llm, so the term lists and the configuration cannot come from two sites. Where the site names one, the turn runs on it: its provider, model, options and system prompt. The Task still adds its prompt template, the chat's job. The Task's configuration is not asked for, so a disabled or group-restricted Task configuration does not stop a turn on the site's. A site that names none, a conversation about no page, and a page the user may not show keep the Task's configuration, as before.

What the chat cannot run on is refused before any model call. The turn fails with one of three codes (SiteConfigurationRefusal):

  • siteConfig.unusable: the configuration does not exist, is disabled, or is restricted to backend groups the user is not in (nr-llm's exceptions from resolveForSite());
  • siteConfig.noFixedModel: it selects its model by criteria. The chat needs a fixed model to check, and already ran only on fixed models;
  • siteConfig.noTools: the model lacks the capability tools, or its adapter is not tool-capable. Both have to say yes.

The stored message carries the site, configuration and model names as JSON; the chat renders the label in the reader's language for every reader, an administrator with a link to the LLM configurations. Nothing falls back to the Task's configuration.

A waiting run is not continued on another configuration. A run keeps the configuration it started with, and nr-llm continues it there. Before approve() or submitInput() the chat asks the site again. When it now names a configuration other than the run's, or one the chat refuses, the decision or answer is handed back (handBack.siteConfig, handBack.inputSiteConfig) without calling nr-llm. A denial is held back too, because it also continues the run with a model call. A new message is the way on: an ordinary chat leaves the waiting run waiting and a guided process cancels it, neither with a model call. A site that names no configuration leaves waiting runs as they were. The site asked is the one of the conversation's page, which only a new turn changes (a decision or an answer keeps the page of the turn that made the proposal), so it is the site the run was started for.

The bans of both configurations apply (owner decision of 2026-11-11). On a site's configuration the run names the Task's configuration with nr-llm's withGuardrailConfigurations() (nr-llm ADR-290). nr-llm then screens the run with the term lists of both: a ban of either one blocks, a list bound to both is read once, and the value survives a pause for an approval or an answer. A ban can be added, never dropped, as a privacy level can only be tightened. The rule-set judge (nr-llm ADR-288) follows the same principle in nr-llm: every side whose judge is on is judged with its own rule set by its own judging configuration, and the stricter mode of the two decides (block over report over off). The value is chosen again on every turn, so a turn on the Task's configuration names nothing further.

Recorded on the run. nr-llm stores the configuration on every run (AgentRun::configurationUid) and shows it in Agent Runs. The chat adds no record of its own: the run record (ADR-030) feeds later prompts and stays limited to what ran. A refused turn starts no run; the refused site, configuration and model are kept only in the conversation's error and in the log.

Without a conversation the Task answers. The upload formats and the list of unavailable tools are computed without a page, so they describe the Task's configuration. A turn expands its attachments for the provider it actually runs on.

Consequences 

  • ● A site that names its own configuration keeps its content with its provider and model in the chat too, with the same rule as cowriter, nr-landingpage and nr-repurpose, except that the site wins over the chat's Task.
  • ● A refusal says which site, configuration and model, and what to change, in the reader's language. The editor is not left with a generic failure.
  • ● The term lists of the chat's configuration and of the site's configuration both apply; site-bound lists keep applying. The privacy level follows the run's configuration, which is now the site's.
  • ◑ The chat calls withGuardrailConfigurations(), so it needs an nr-llm release that contains ADR-290.
  • ◑ A Groq model, or a hand-made model record, that can call tools but lacks the Tool Use flag is refused until an administrator ticks it. The flag is the only per-model statement nr-llm has.
  • ◑ A site configuration made for cowriter brings its system prompt and its tool groups into the chat. An empty tool group list allows every group.
  • ◑ A guided process whose site changes its setting mid-way continues on another configuration with the next turn, and nr-llm drops the process with configuration_changed (nr-llm ADR-228); the chat says so.
  • ✗ A proposal waiting when the setting changes cannot be decided any more in the chat; the editor goes on with a new message.

Alternatives considered 

Fall back to the Task's configuration when the site's cannot call tools. Rejected by the product decision: silent, and it sends the site's content to the provider it was set up to avoid.

The Task wins, as in cowriter. cowriter puts a task's configuration above the site's. For the chat that is today's behaviour, which the decision reverses.

Trust the adapter alone. Rejected: ToolCapableInterface is implemented per provider, so a model of a tool-capable provider that cannot call tools (an embedding or audio model put into a configuration) would pass and fail at the first call.

Probe the model with a tool call. Rejected: a model call before the turn costs money, takes seconds on every turn, and sends a request to the provider for a turn that may then be refused.

ADR-034: The chat lists what an assignment changed 

Status: Accepted

Date: 2026-11-13

Amends: ADR-030: A later turn reads what ran (the run record keeps the run's uuid) and ADR-017: The chat states what the run did, not what it said (the chat can show what the runs did, not only say it).

Context 

An assignment can write more than one record: a page copy takes its content along, a translation writes overlays, and a hook of the installation can write anything. The chat showed the editor one card per decision and, afterwards, the model's text. The only user-facing summary of what was saved existed for guided processes (card_outcomes, ADR-023); an ordinary chat with several tool calls, or a run that was resumed after an approval, had none. The run record of ADR-030 listed every write of a segment, but identifiers only and only for the next prompt.

nr-llm now records, per tool call, the records the DataHandler wrote (nr-llm ADR-292, run step tool_changes): table, uid, action, workspace, live uid, language, whether it is the record the tool named, the disable flag and the time and group restrictions the row carries. It says honestly when it does not cover a call (not_captured) and counts what it left out (changesOmitted). The pending call it hands to the card carries workspace and language as values, and a call refused by an invocation rule as such (nr-llm ADR-293).

Two things were missing here. The conversation did not know its runs: it kept the uuid of the last run and of the run awaiting approval, and its run records did not name the run. And the approval card still found the workspace by cutting nr-llm's prose line apart.

Decision 

The run record keeps the run's uuid. Each entry gets run beside outcome (additive, in the existing JSON column, no schema change). Conversation::getRunUuids() lists the distinct uuids, oldest first; a run resumed after an approval has several entries and one uuid. Entries from before have no run; the change view then uses the writes they list. The uuid is not part of what a later prompt reads: RunRecordNotes reads the fields it names. The limits of ADR-030 apply: the row keeps the newest 200 entries, and editing a message removes the entries of the turns it cuts, so an assignment that was cut loses its changes from this view; the records themselves stay in TYPO3.

A list per conversation, read from the runs' events. ConversationChangeReader reads, for each run of the conversation that the conversation's owner may read (AGENT_READ, as for a pending run), the events through AgentRuntimeInterface::events() and decodes the tool_changes events with ObservedChangeSet::tryFromEventPayload(). A record several calls changed is listed once: the later observation states the state now, a record created earlier stays created, a delete wins, and having been the tool's target once is kept. A call whose tool step failed shows its list with a failure marker: the records it wrote before it failed are the ones to check. The call's tool_write step adds to the observed record, and no row of its own, what a tool that reports no error can still state: a hook that failed after the write gives the same marker, and a write the tool did not finish (writeCompleteness partial) marks the call's target as partial. A run without any tool_changes event, recorded before nr-llm wrote them, is read through its tool_write events instead: the record each tool named, shown without action, workspace or dependent records and marked as such. A run with tool_changes events is never also read through tool_write, because both name the tool's target. Runs the actor may not read, or that are gone, are counted and said so.

Every fact is its own badge, and the list never says complete. The view states action, workspace (the one the row is stored in), language, hidden (hidden=false is "not hidden" and no word for public), the restrictions the row carries, whether the record is the step's target or was changed along with it, and a failure after the write, each as a separate badge. Under the list a note always says what it covers: the changes TYPO3 reported for the steps of the assignment, and that a change made another way does not appear. Further notes appear where nr-llm reports a capture that is not complete, where entries were left out (nr-llm's limit of 50 per call and this list's limit of 100, counted together), where records are withheld from the viewer, where runs predate the capture and where runs could not be read.

The viewer's permission decides per record, here. nr-llm names table and uid of records the acting user may not be allowed to read, and the events of a run are readable by whoever may read the run (nr-llm ADR-292). The chat therefore shows a record only to a viewer who may select its table, to whom its page is shown (page permissions and mounts, as the page module checks them), who may access its language and the workspace its row is stored in, and only while the row exists. A file record (sys_file, sys_file_metadata) needs read permission on that very file as core decides it (ResourceStorage::checkFileActionPermission('read', $file)): the file's folder lies in one of the viewer's file mounts, not merely some mount of the storage. Everything else is withheld whole - no table, no uid, no title - and only counted ("n weitere Datensätze werden nicht angezeigt"). A deleted row is checked like any other; a row that is gone for good cannot be checked and is withheld. The title of a workspace the viewer may not use is withheld too; its id is not.

Links where core offers one. The record title links to the backend edit form when the viewer may modify the table; "Änderungen ansehen" opens the record history (record_history) for a record that was not deleted. The workspaces module has no URL for one workspace: it shows the workspace the viewer works in. The link to it appears only when that is the workspace the record is in. A link opens in the backend's content frame when there is one (ContentContainer.setUrl) and follows normally otherwise, so the chat keeps its state.

Delivery: a flag with the poll, the list when opened. The messages response carries changesAvailable (a run that wrote a record or had a tool fail, which may have written first; or an old entry that lists a write), so a conversation that only read shows nothing. The list is its own request (GET /ai-chat/conversations/changes), made when the reader opens the "Änderungen dieses Auftrags" disclosure and again when a poll reports a change while it is open, so a poll never pays for reading events. The disclosure is a native details element: reachable and operable by keyboard, announced as expandable, in both surfaces and with the chat's theme tokens (light and dark).

The approval card takes workspace and language from nr-llm's fields. ApprovalCallPresenter no longer cuts nr-llm's workspace line out of the preview lines. It words the chat's own sentence from workspaceId and workspaceTitle, and says that the title is not visible to the reader when nr-llm withheld it. The language comes from PendingCallView::$language: none is stated where the tool takes no target language, which is not the default language. The prose line stays among the preview lines for the cards that show them. Constraint: nr-llm with these fields (the constraint in composer.json names the release that has them).

A call refused by a rule is shown refused. The card marks a call with refusedByRule as one that will not be carried out; its preview lines are the rule's explanation. The approve button names the calls that are carried out; when every call is refused, the card offers no approval and says so (approveBlocked: allRefused). nr-llm's decision is the one it takes when the call executes; the card never promises an execution.

The draft-workspace requirement is read from the run's configuration. nr-llm's card view carries no require_draft_workspace (WaitingRunView has the configuration's label only). requiresDraftWorkspace() reads LlmConfiguration::getRequireDraftWorkspace() from the run's own configuration, as requiresSecondApprover() does for the second-approver setting, and the card says that the configuration requires a draft workspace beside a call that would write live. It does not parse anything and is false where the run or the configuration cannot be read; nr-llm refuses the write itself in that case.

Consequences 

  • The conversation table does not change. Entries written by this version carry one more key; older entries decode as before.
  • Opening the list reads the events of every run of the conversation once per opening and per reported change. A long conversation reads many runs; the list is capped at 100 records.
  • A record changed through a path the DataHandler does not report is not listed, and the view says that it cannot show it. For runs before nr-llm wrote tool_changes, only the tool's own record is known.
  • The change view is read-only. It cannot undo a change; the record history link is core's.
  • The card's workspace sentence is the chat's wording, not nr-llm's; the two can differ in the preview lines of a plain card, which still show nr-llm's line.
  • Requires the nr-llm release that contains nr-llm ADR-292 and ADR-293 (tool_changes events, PendingCallView workspaceId, workspaceTitle, language and refusedByRule).

Not decided here 

  • Several cards of one turn (the single pending call that cardSnapshot() handles) is unchanged; the change view does not depend on card capture.
  • A hint before the first message that the configuration requires a draft workspace and the editor works live. Without a pending call the chat has no run, and so no run configuration to read; resolving the configuration of the chat's Task for that hint is a separate decision.

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.