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}
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 `Pending`) and highlights it under the checks ofCall View:: $pending Target 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
currenton by one, never pasttotal, 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:cleanupremoves 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 byai-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.