Operate this site with an agent
The operator-register demo: this site embeds page-agent (pinned, opt-in, bring-your-own key) configured with Poetry's operator register - watch a GUI agent operate the components, and see which tasks succeed through ARIA alone.
What this is
This page embeds page-agent
(pinned v1.12.2, MIT, vendored with its license) configured with Poetry's
operator register: the component contract projected in GUI-operator vocabulary
(data-component/data-slot
whitelisted into the model, plus per-page instructions served at
/operator-register.json).
Nothing loads until you activate it; your key lives in this browser session only and is sent
only to the endpoint you configure.
Or hand the agent tools
This demo operates the human UI through the accessibility tree. The other path is
WebMCP: the same components
register typed tools with the user's own browser agent, so it calls poetry.sections.set_value
instead of finding the tab and clicking it - and the two paths compose.
Activate
The key is session-only: cleared on deactivate or when the tab closes, and sent only to the base URL above.
Task list
Curated tasks with pre-registered expectations. Classify each run: ARIA alone / needed the register / failed on the agent's input layer (page-agent's own synthetic-input gaps: scrim click-through, forced focus, no keyboard; keyboard-first components are EXPECTED failures and labeled).
| Task (paste into the panel) | Expected |
|---|---|
Switch this site's visual style to sera using the style switcher in the header |
succeeds: switcher is a labeled menu |
Open the search palette and navigate to the Combobox component page |
succeeds: command palette is ARIA listbox |
On the Select component page, open the first example's select and choose any option |
succeeds: popup listbox via ARIA |
On the Dialog component page, open the first example's dialog, then close it |
succeeds: dialog + close button |
On the Tabs component page, switch the first example to its Code tab |
succeeds either way (ablated 2026-08-22: bare agent also picked the right group, same step count) |
On the Data Table component page, sort by the first sortable column and select two rows |
succeeds either way (ablated 2026-08-22: bare agent matched it, 5 steps both arms) |
On the Slider component page, set the first slider to its maximum |
EXPECTED FAILURE (confirmed 2026-08-22): 39 track clicks moved it coarsely, never to max; needs keyboard |
On the Date Field component page, set the first example's date to any past date |
succeeds via TYPED input; run variance dominates (measured: 40 steps one run, 8 steps ablated) - clicks alone never set segments |
Sample implementation
The loader below is this page's actual implementation. The Turbo rules it encodes
(remount on turbo:load, rebuild only when the agent is
idle, catch up once at module init) came out of live runs of the task list above. For your own app, install
the host variant as a recipe (pinned CDN script, register optional, Poetry ground rules
embedded):
bin/rails g poetry:add agent-embed
import { Controller } from "@hotwired/stimulus"
// The operator-register demo loader (opt-in, nothing ambient): activates
// the vendored page-agent build (?autoInit=false - no demo agent, no demo
// LLM) and constructs window.PageAgent with poetry's operator register.
// The key lives in sessionStorage only and is sent ONLY to the baseURL the
// visitor configured. Activation survives Turbo visits via a session flag:
// Turbo replaces <body> (taking the agent panel with it), so we re-mount
// on every turbo:load while the flag is set.
const SCRIPT_URL = "/vendor/page-agent/page-agent-1.12.2.js?autoInit=false"
const FLAG = "poetry-docs-agent"
let registerPromise = null
async function operatorRegister() {
registerPromise ||= fetch("/operator-register.json").then((response) => response.json())
return registerPromise
}
function pageInstructions(register, url) {
const path = new URL(url, window.location.origin).pathname
const hit = Object.entries(register.pages)
.filter(([prefix]) => path === prefix || path.startsWith(`${prefix}/`))
.sort((a, b) => b[0].length - a[0].length)[0]
return `${hit ? hit[1] : ""}\n${register.default}`.trim()
}
async function mountAgent() {
// Turbo body swaps and hard loads can leave a live instance with a dead
// panel (the panel element rides <body>); rebuild rather than limp - but
// ONLY when idle: a running task survives the body swap headless and
// completes (URL-state components like DataTable navigate mid-task), and
// disposing it here aborts the task out from under the agent.
if (window.pageAgent && !document.getElementById("page-agent-runtime_agent-panel") &&
window.pageAgent.status !== "running") {
try { window.pageAgent.dispose?.() } catch { /* replaced below */ }
window.pageAgent = undefined
}
if (window.pageAgent) return
const config = JSON.parse(sessionStorage.getItem(FLAG) || "null")
if (!config) return
if (!window.PageAgent) {
await new Promise((resolve, reject) => {
const script = document.createElement("script")
script.src = SCRIPT_URL
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
}
const register = await operatorRegister()
window.pageAgent = new window.PageAgent({
model: config.model,
baseURL: config.baseURL,
apiKey: config.apiKey,
language: "en-US",
includeAttributes: ["data-component", "data-slot"],
instructions: {
system: register.system,
getPageInstructions: (url) => pageInstructions(register, url),
},
})
window.pageAgent.panel.show()
}
document.addEventListener("turbo:load", mountAgent)
// Hard loads race module evaluation against the first turbo:load - catch up
// once at module init (modules are deferred, so the DOM is parsed by now).
mountAgent()
export default class extends Controller {
static targets = ["model", "baseUrl", "apiKey", "status"]
connect() {
if (sessionStorage.getItem(FLAG)) this.note("Agent is active - the panel follows you across pages.")
}
async activate(event) {
event.preventDefault()
const config = {
model: this.modelTarget.value.trim(),
baseURL: this.baseUrlTarget.value.trim(),
apiKey: this.apiKeyTarget.value.trim(),
}
if (!config.apiKey) return this.note("An API key is required (it stays in this browser session).")
sessionStorage.setItem(FLAG, JSON.stringify(config))
try {
await mountAgent()
this.note("Agent mounted - give it a task from the list below.")
} catch (error) {
sessionStorage.removeItem(FLAG)
this.note(`Could not mount the agent: ${error.message || error}`)
}
}
deactivate(event) {
event.preventDefault()
sessionStorage.removeItem(FLAG)
window.pageAgent?.dispose?.()
window.pageAgent = undefined
this.note("Agent deactivated and its key cleared.")
}
note(text) {
this.statusTarget.textContent = text
}
}
Provenance & limits
The vendored build is the upstream IIFE loaded with ?autoInit=false
(no demo agent, no third-party LLM endpoint). page-agent drives the page with synthetic
events — text-only DOM reading, no screenshots; its known infidelities (clicking through
pointer-events scrims, no keyboard synthesis) are part of what this demo measures, not bugs
in the components. Agent activity stays on whatever pages you visit here.