Questions, links and the live page

How agni answers a question, how an answer points at the viewer, and how something outside the viewer moves a page that is already open.

Three things in agni look alike from outside and work quite differently. A question about a design is answered by the engine, over Connect or MCP. An answer that wants to show something in the viewer carries a link, which is an address and nothing more. And a page that is already open can be moved by something beside it, a parent frame or a saved report, without loading it again. This page says which layer a feature belongs in. A feature built in the wrong layer works on the machine it was built on and nowhere else.

The three layers

Layer What it is Who answers Where its state lives
Questions CheckService, QueryService, DesignService and the rest, and the MCP tools over them agni serve, agni mcp, or the browser engine snapshots on the host that ingested them, nothing per caller
Addresses a viewer URL, which router.ts reads as a ViewerLocation nobody, it is text in the link itself
The live page ViewerPageService (protos/agni/v1/viewer/page.proto) the open viewer page in that page, for as long as it is open

A question never moves a page, and a page call never computes an answer the engine owns. The MCP server in particular holds no viewer state. It answers about snapshots and hands back links, and the reader owns the page each link opens (agni issue 1153).

From an answer to an address

Every surface that wants to show something mints its link through the same builders in core/report, so a link from agni check --server, from an MCP answer and from a saved report opens the same view. Each one sets fields of the location, and each has a page call that does the same to a page already open.

Builder Location fields it sets The page call that does the same
VerdictURL verdict, hash, rule, subjects LocateVerdict {id, rule, subjects}
TraceURL trace, traceHops ApplyLocation {url}
QueryURL query, title, binds ApplyLocation {url}
QuerySetURL set, q ApplyLocation {url}
DiffURL against, renameApprox (on the older revision’s address) ApplyLocation {url}

ApplyLocation with a url reads the link with the same parser the page uses for its own address, so any link is a page call without being spelled a second way. The MCP tools put the call beside each link as page (agni issue 1155), with LocateVerdict for a verdict. LocateVerdict carries hints for a page that has not run the rule, so that page can ask about one subject.

Moving a page that is open

The page answers ViewerPageService itself, through pageservice.ts, which dispatches a method name and a protojson body. A carrier only brings that pair to the page, so a call behaves the same whichever one brought it.

Carrier Who is calling Taken from
postMessage, embed.ts a parent page framing the viewer the viewer’s own origin, and each agni serve --embed-origin
postMessage, core/report/viewerlink.js a saved check or checklist report any origin, for ApplyLocation {url} alone
a design’s relay room, viewerroom.ts an agent on /mcp, through the room bridge a tab whose reader turned on “Let agents drive this tab”, under agni serve --mcp --viewer-rooms (agni issue 1154)

The report’s case is narrower on purpose. A report opened from disk has an opaque origin that no server can name, and every link in it targets the window named agni-viewer, so clicking it with scripts off loads the link in that window anyway. The page therefore takes ApplyLocation carrying a link and nothing else from any origin, since that grants what loading the link grants, and its reply says only whether it worked. Every other call is limited to the origins the server named, and so is every reply carrying the page’s location. The report loads the link itself when no viewer speaks within 2 seconds. A page posts an accepted note on an envelope of its own (agni-viewer/v1/accepted) the moment it takes a request. So when a verdict link’s rule takes longer to resolve, the report waits for it rather than loading over it. A report never depends on its script, and a report with no links carries no script. A report has to read the same whether it is emailed, committed or opened with scripts off (core/report/html.go).

A page call opens the same place as a click and a link. sheetToOpen in viewer.ts picks the sheet for a finding, a review finding and a verdict. A verdict carries the sheets it is drawn on (Verdict.sheets). web/src/navparity.test.ts runs a link, a click and a call to the same verdict, and names any of the three that opens somewhere else. Working in the web client has the presenter side of this.

An agent moving an open tab

MCP lets a client call a server and nothing else, so an agent cannot call a page. Under agni serve --mcp --viewer-rooms, both sides connect to something that stands between them (agni issue 1154). The server embeds a massrelay relay at /relay/ and mcpkit’s room bridge beside /mcp (internal/viewerrooms). Each tab joins the room of the design it shows, design:<mount>/<entry>, and offers every ViewerPageService method there as a tool. The bridge serves those tools to agents as viewer_get_location, viewer_show_sheet, viewer_locate_verdict and the rest, and sends each call to one tab, where pageservice.ts answers it as it answers postMessage.

A tab learns its room and a relay token from ViewerRoomService.JoinViewerRoom. The room is resolved the way a report’s links are (linkMountPath, C32), so a tab opened on a design’s folder and one opened on its entry share a room, and an agent naming the design either way reaches both. The token admits its holder to that one room. The server makes a signing key at start and signs each token with it, so a restart turns away a page that has not joined yet. When the page opens another design in place, syncUrl moves the tab to that design’s room.

Every call names its design, which the bridge maps to the room (rooms.WithRoomOf), so an agent switches between designs from one question to the next without attaching to anything. Within a room a call goes to the tab that page names (list_pages lists them). Without page, it goes to the reader’s most recently viewed tab of that design. A call only ever reaches a tab whose reader turned on “Let agents drive this tab”. That switch is per tab, off by default, and kept in the tab’s session storage, so a reload keeps it and a new tab starts with it off.

A tab answers the bridge alone. The bridge joins the relay under a subject, and JoinViewerRoom names that subject to the tab. The tab passes it to the rooms client’s trustBridges, so a message from any other peer in the room goes unanswered (agni issue 1173). Every token the server signs is for the local subject, so no page can join as the bridge. A page that holds a genuine token for the room still cannot call a tab directly and skip the bridge’s check of the switch.

The bridge declares the eight tools up front (rooms.WithPageTools), so tools/list answers the same for every caller and the bridge serves the stateless 2026-07-28 MCP wire as well as the session one. It also refuses a tab’s tool whose schema differs from the declaration by a byte. The declaration is built in Go from the service’s descriptor (internal/viewerrooms/tools.go) and the tab builds its own from protobuf-es descriptors (web/src/toolschema.ts), so both walks are held to one fixture, internal/viewerrooms/testdata/viewer_tools.json. mcpkit#1609 sets out the mapping for proto services as tools, and the fixture follows it. GetLocation is marked NO_SIDE_EFFECTS in the proto, and both walks read its read-only hint from that mark. A page and a server from different builds can still disagree about a tool, and the bridge then refuses it. The tab hears notifications/rooms/tool_refused and names the tool beside its switch (onToolRefused, since mcpkit v0.8.13).

Sheet views beside the canvas

A reader opens a sheet view from the Sheets panel, such as the board beside the schematic (agni issue 1326). The page contract opens one too, so a parent page or an agent can put the board beside the canvas without a reader’s click (agni issue 1370). OpenSheetView opens a view on a sheet and answers its id, ShowSheet with a view moves that view to another sheet, and CloseSheetView closes it. The canvas keeps its own sheet throughout. GetLocation lists the open views beside the location.

A view is not part of ViewerLocation, because a link cannot open one. A link names one sheet, and a view lives only as long as the page holding it. The id is sheetview-<n>, numbered from 1 per page and never reused, so a caller holding the id of a closed view is refused rather than reaching some other view. SheetViews in sheetviews.ts holds the views, and pageApi reaches them through the SheetViewsHost port, so a page with no dock lists none and refuses to open one.

A tab asking an agent

The same room carries the other direction (agni issue 1173). With its switch on, a tab tells the room what the reader selected (page.selection_changed, from the Properties panel’s focus). The Ask box beside the switch sends the reader’s question as room.ask. Its data carries the text, the tab’s GetLocation answer and the selection. A selection carries a view (agni issue 1370). It is the id of the sheet view the reader picked in, or canvas for every other selection. So an agent can tell a pick on the board view from a pick on the schematic. These are page events, which the bridge buffers per room (rooms.WithEventFallbacks) and hands only to agents the tab agreed to. An agent waits for the next one with next_request, on either wire, or subscribes to rooms://events/{room} on the session wire.

viewerrooms.Rooms.Register also serves them as SEP-3415 events through mcpkit’s roomsevents, as room.ask, page.selection_changed and page.event, each taking the room as its resource argument. A session may subscribe only while it is attached to that room, and it hears only the tabs that agreed to it, the same rule as next_request. Attachment lasts as long as an MCP session, so the events need the session wire, and with no identity provider they act for the local subject (events.Config.UnsafeAnonymousPrincipal). rooms-channel builds on these events to put a reader’s question in front of a Claude Code session as a turn. TestARoomsChannelHearsATabsQuestion runs that front end over a pipe against agni’s /mcp, attaching by design as an agent does and by resource. Attaching by design names no room, so the front end watches the room named in attach’s answer. mcpkit’s front end only started doing that in v0.8.13 (mcpkit#1640). Before that it watched nothing, and a question asked by design never arrived.

WithRoomOf declares design as its room argument, so next_request, list_pages, attach and pair take the design the way the page tools do, resolved through the same ViewerRoomService.Room (mcpkit#1633). Before that they took only the resolved room name, and no answer told the agent that name.

What is not here yet

The server has no identity provider, so every relay token and every MCP session acts for one local subject, and the switch is the only agreement there is. Before several people can share one server, each with an agent, the server needs OAuth on /mcp and a relay token per user. An agent driving someone else’s tab also needs mcpkit’s pairing codes.

Running it

make exercise-mcp-ask asks every question tool over MCP and holds each reply’s link to a working page. make exercise-viewer-page drives a framed viewer from a parent on another origin, moves a viewer from a saved report beside it, and has an agent on each MCP wire drive tabs on two designs under --viewer-rooms. A tab that kept agents out is still where its reader left it. Driving an embedded viewer is the guide for a page that wants to do either.