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.