Driving an embedded viewer
Frame the agni viewer in your own page and move it from there, over postMessage, with the same contract a link carries.
A page that frames the agni viewer can move it without reloading it. It can open a sheet, focus a verdict, highlight a net, or apply a whole link’s location to the design already on screen. A review portal, a static check report and an agent’s shell all want this, since each keeps its own page beside the viewer and reloading the viewer throws away the reader’s place.
The contract is ViewerPageService in protos/agni/v1/viewer/page.proto. The viewer page answers it
itself. No server does. Its location message is the viewer’s URL state field for field, so a link
and a call open the same sheet with the same highlight. web/src/navparity.test.ts holds the viewer
to that.
Allowing your page ¶
The viewer takes requests from its own origin and from origins its server names, and refuses every
other with permission_denied. Start the server with one --embed-origin per parent origin, such as
agni serve --mount demo=./designs --embed-origin https://portal.example. A value has to be exactly
an origin, meaning a scheme, a host and an optional port, with no path. The viewer compares it with
the sender’s origin as a string, so serve refuses https://portal.example/ rather than starting a
viewer that ignores its parent.
One request needs no allowing. The viewer takes an ApplyLocation from any page, including a report
opened from disk, when the request carries a url and nothing else. Loading that link in the
viewer’s window does the same thing, and any page holding the window can already do that. The reply
says only whether it worked, with no location, so a page that was not allowed learns nothing about
the design.
The envelope ¶
The protocol is one message shape each way. A request names an rpc of ViewerPageService and carries
its request in protojson.
{ "agni": "agni-viewer/v1", "id": 1, "method": "LocateVerdict",
"body": { "id": "demo-pull-up:(net:SDA)", "rule": "demo-pull-up", "subjects": ["net:SDA"] } }
The viewer answers the window that asked, at that window’s origin, with the same id.
{ "agni": "agni-viewer/v1", "id": 1, "ok": true,
"body": { "resolved": true, "location": { "mount": "demo", "path": "demo-board.kicad_sch", "sheet": "s-power" } } }
A failure carries a Connect code and a message instead of a body, such as
{ "ok": false, "error": { "code": "invalid_argument", "message": "the open design has no sheet \"s-9\"" } }.
The viewer starts listening once the design in its URL is open, and then posts
{ "agni": "agni-viewer/v1", "ready": true } to its parent, so a parent waits for that rather than
guessing when the viewer has booted. Requests are answered one at a time, in the order they arrive.
The calls ¶
Every response carries the page’s location after the call, so a caller learns where the viewer ended up without asking again.
GetLocationreads where the page is and changes nothing. Its answer also lists the sheet views open beside the canvas, each with its id and the sheet it shows.ApplyLocationmoves the page to a location as following a link to it would. A location naming another design opens it. A location naming the open design applies to the page as it stands, and so does a location naming no design. An empty field leaves its part alone, so a caller sends only what it wants changed. The call also takes aurlin place of a location. Theurlcan be any link the server minted, and the call applies the fields that link sets.LocateVerdictfocuses one verdict and draws its proof, opening a sheet it is drawn on. The page may not have answered the verdict yet. Given arulehint, it then asks that one rule about the given subjects first, as it does for a link carrying the same hints.SelectFindingfocuses a finding of the last run, as clicking its row does, except that selecting the focused finding again keeps it rather than clearing it.LocateEntityhighlights a net, part or pin, as clicking a query result does.ShowSheetopens a sheet by its id. It opens the sheet on the canvas, or in the sheet view that itsviewnames.OpenSheetViewopens a sheet view beside the canvas on one sheet, as the Sheets panel’s open beside button does. It answers the new view’s id, such assheetview-2. A page numbers its views from 1 and never reuses a number, so the id of a closed view names nothing.CloseSheetViewcloses one view by its id.
A call can name something the page does not have, such as an unknown verdict or a finding the run
lacks. That call answers resolved: false or selected: false and leaves the page alone, because a
stale link should not clear what the reader was looking at. CloseSheetView answers closed: false
for a view that is not open. A sheet the design lacks is invalid_argument, and so is a ShowSheet
naming a view that is not open.
Say a report link wants the board open beside the schematic. The parent applies the link, then
sends OpenSheetView with sheet: "board". The canvas keeps the schematic, and the view beside
it follows whatever the reader selects.
A parent page ¶
examples/embed-viewer/parent.html is a working parent with no build step. Serve its folder from
your origin and open parent.html?viewer=<a viewer design URL>. Its agniCall(method, body) is the
whole client, about twenty lines, and web/browser/embed.spec.ts drives it across two origins in the
browser suite.
A parent written in TypeScript can use the generated Connect client instead.
web/src/embedclient.ts builds a fetch over postMessage for it to use.
const transport = createConnectTransport({ baseUrl: "https://viewer.invalid", fetch: frameFetch(frame.contentWindow, viewerOrigin, window) });
const page = createClient(ViewerPageService, transport);
const { location } = await page.showSheet({ sheet: "s-power" });
From a saved report ¶
Every link in a check or checklist report (--format html) opens in the window named agni-viewer.
With scripts off, clicking one loads it in that window, so a reader working through a report keeps
one viewer tab rather than gaining one per click. With scripts on, the report’s own script looks for
that window first, and when it already shows a viewer it sends ApplyLocation with the link instead
of loading it, so the viewer keeps the reader’s place. If the viewer does not answer within two
seconds, or answers that it could not apply the link, the script loads the link as before. The
script makes no request of its own. A report links into a viewer only when it was written with
--server, and only such a report carries the script. A report written with no viewer to link to
is still a plain page with no script at all.
examples/embed-viewer/two-pane.html puts a report beside the viewer it links into, in a frame named
agni-viewer, and web/browser/embed.spec.ts checks that a click there moves the viewer without
reloading it.
Other transports ¶
postMessage is one way to carry the pair of a method name and a protojson body. The page dispatches
that pair in web/src/pageservice.ts whatever carried it, so a server-held session for an MCP tool
(agni issue 1154) and an MCP app host (agni issue 1155) are other carriers over the same dispatcher
and need no handler of their own.