Agent API Reference (EXPERIMENTAL)
enableAgentInterface() turns a tosijs app's existing records — the state
registry, the binding wiring, the event handlers — into a described,
path-addressed surface for non-human users: AI agents, test harnesses,
automation. Nothing is recorded that tosijs doesn't already know; describe()
assembles the picture on demand.
Nothing is exposed until you say so. Say what an agent may see, and that is exactly what it sees:
import { tosi, enableAgentInterface } from 'tosijs'
const { app } = tosi({ app: { cart: [], filter: '', addItem() {} } })
const agent = enableAgentInterface({
expose: {
roots: [app.cart, app.filter],
actions: [app.addItem, app.checkout],
write: true, // omit for scoped reads with no writes
},
})
agent.describe() // roots, wiring (elements ↔ paths ↔ handlers), actions
agent.read(app.filter) // serializable value
agent.observe(app.cart, (path) => { ... }) // push; returns un-observe
agent.changes(cursor) // turn-based drain: final value per changed path
await agent.when(app.order.status, (s) => s === 'confirmed') // await a condition
await agent.settled() // await quiet — read what it could NOT see, below
agent.write(app.filter, 'milk') // through the same observers as any write
agent.call(app.addItem, 'buy milk') // invoke an action by path
agent.log() // the audit trail
Paths or proxies, everywhere. Every verb and every manifest entry takes
either — agent.read(app.filter) and agent.read('app.filter') are the same
call, because the proxy already carries its path. Prefer the proxy: it survives
a rename and it cannot be misspelled. Strings remain fully supported, and are
what you want when the path arrives from outside the program (a tool call, a
config file, a wire message).
Every record in describe().wiring has a key. Refer back to a control by
its key, not its index: the index moves whenever the list changes (a row is
added, a section renders), the key does not. A key lasts while the element
exists and stays bound to the same paths. It changes when the element is
replaced. In a virtual list, rows that stay on screen keep their elements
across scrolls; a row that scrolls out is discarded, and one that scrolls in is
cloned fresh from the template (faster than cleaning up and reusing an old
row). So a row's key lasts while it stays on screen. It also
changes if an element is ever re-pointed at different paths, so a key never
follows an element to different data. Keys are opaque (k1, k2, …): they
say nothing about paths or position.
Anything that is neither — a plain object, a raw value read out of the tree —
is refused, with kind: 'path'. It used to be coerced with String(), so
roots: [app.cart] declared a root named "[object Object]" and every read
then failed as out-of-scope: a broken manifest whose error blamed the reader.
Worse for a scalar, where String() yields the value, so read(app.filter)
quietly read whatever path the filter text happened to spell.
Undeclared state is not redacted, it is absent: it never enters the map,
the elements bound to it never appear in wiring, and every verb refuses the
path. A manifest scopes sight, not reach — roots says what may be seen,
write: true is a separate grant to change it, and declared actions stay
callable either way. describe().writable reports which you have.
While developing, one word opens everything:
const dev = enableAgentInterface({ expose: 'all' }) // and warns that it did
enableAgentInterface() with no manifest is legal and exposes nothing —
describe() reports an empty app and every verb refuses. That is deliberate:
the default used to be read-only over the entire registry, which is how four
separate secret leaks became reachable through one unargumented call. Scope is
the control; the redaction described below is defence in depth beneath it.
settled() — what it means, and what it does not
After an action, an agent often needs to know when it is safe to look again.
If you know what you are waiting for, use when(): it waits for that
exact condition and is right every time. settled() is for when you don't.
const r = await agent.settled({ timeout: 5000, quietMs: 50 })
// { settled: true, waitedMs: 63, quietMs: 50,
// covers: ['state-notifications', 'state-quiet', 'component-renders', 'agent-calls'],
// notCovered: ['network', 'timers', 'external-async', 'list-throttle',
// 'share-sync', 'unbound-dom'] }
settled: true means exactly this: at the moment it resolved, tosijs had
nothing pending that it knows about, and no state this surface can see had
changed for quietMs. "Nothing pending" is the four items in covers:
covers |
|
|---|---|
state-notifications |
every queued observer and binding notification has been delivered (bind()'s DOM updates happen there) |
state-quiet |
no visible state changed during the last quietMs — the window starts when you call, never earlier |
component-renders |
no Component render is queued |
agent-calls |
every promise returned by an action started through this surface's call() has settled |
It does not mean that the app is idle, that data has loaded, that a request
has finished, that the screen has painted, or that nothing will change a
millisecond later. tosijs can only vouch for work it queues or starts itself.
Everything else is listed in notCovered, on every result, settled or not:
notCovered |
why tosijs cannot see it |
|---|---|
network |
a request in flight is not state until its result is written, which can happen after settled resolves |
timers |
setTimeout, setInterval and animation-frame loops outside tosijs |
external-async |
async work tosijs did not start — including work an action starts without returning its promise |
list-throttle |
a bindList's throttled DOM update changes the DOM, not state |
share-sync |
share() / sync() outbound queues |
unbound-dom |
DOM changed outside bindings, CSS transitions and animations, paint |
out-of-scope-state |
under a manifest only: undeclared state is deliberately not watched, because waiting on it would disclose that it changed |
Under a manifest, only state-quiet is scoped. state-notifications and
component-renders are page-wide: tosijs's notification queue and render
queue are shared by the whole page, so a render caused by undeclared state can
delay settled, and on a timeout pending.renders counts it. That reveals
that something rendered, never which path or what value; if that timing is
sensitive for your app, do not expose settled to an agent you do not trust.
Each of those is pinned by a test in which settled resolves true while that
work is still pending, so the limits are part of the contract, not a caveat.
Pass notCovered on to whatever trusts the answer (a test runner, a model),
rather than turning the result into a bare yes.
Two things make an app easier to settle: return the promise from an async
action (then agent-calls covers it), and when you know the end state, wait
for it with when() instead. settled: false is not an error: it resolves with
reason: 'timeout' and a pending summary of what was still busy. It rejects
only for bad options or a surface revoked while waiting. While the page is
hidden, animation frames stop, so a queued render keeps it unsettled until the
page is visible again.
Secrets
⚠️
data-tosi-secretis badly named and the name will change. It marks what this library must not copy into a description. It is not a security boundary and cannot be one: everything it covers is ordinary client-side DOM, readable with onequerySelectorby any script in your origin, any extension, and anyone with devtools — whatever tosijs does.Worse, it is an attribute, so it is a signpost.
document.querySelectorAll('[data-tosi-secret]')hands a reader your own curation of which parts of the page you considered sensitive. That judgement is work an attacker would otherwise have to do. Nothing here is disclosed that was not already reachable — but the marker tells you where to look.So: mark as little as possible, and do not reach for it as protection. The actual control is
expose— what the surface is willing to describe at all — and the CLOSED default beneath it. This mechanism is defence in depth for the case where a description leaves the origin (a model-context host receivingtosi_describe); against anything running in the page it buys you nothing. A rename, and a way to declare withholding in code rather than in markup, are planned for 1.12.0.
A path bound to a password field, a cc-* autocomplete, a hidden CSRF token,
or anything you mark data-tosi-secret is withheld — it reads back as the
sentinel ⟨secret⟩ rather than its value:
input({ type: 'password', bindValue: app.login.password })
agent.read('app.login.password') // '⟨secret⟩'
agent.read('app.login') // { user: 'ada', password: '⟨secret⟩' }
This matters for what you DID expose — an undeclared path is absent, not
redacted, so redaction is what protects a secret sitting inside a declared
root (and it is the only thing protecting you under expose: 'all').
And describe() withholds the element's own content, not just its bound
value. A secret-marked element — or any element inside a
data-tosi-secret region — publishes its tag, role, name, bound path and
geometry, but not its href, placeholder, title/alt-derived name,
aria-description or a toggle's checked state. A reset-link token lives in
an href, not in a bound path, and until 1.10.2 it travelled in cleartext
beside a text field that had been correctly withheld. The record carries
secret: true so a consumer can tell suppression from absence.
An aria-label survives — it is authored to be announced, and dropping it
would make every secret control anonymous to assistive tech and to the audit.
Names survive secrecy; content and live state do not.
Secrecy is a property of the PATH, not of an element. Marking one control
secret withholds that path everywhere it surfaces — read, describe,
changes, when — including from other elements bound to the same path,
and from every field beneath it if the path names an object. It is also
one-way for the session: a path that was ever secret stays secret, because the
alternative is a window in which it isn't.
⚠️ The path has to be LEARNED first, and that is where the gap is. tosijs discovers a secret path by finding a secret control and looking for the binding that feeds it: on the control, on its immediate parent, through a wrapping
<label>, on its owning<form>, or across one shadow boundary. Where the binding sits somewhere else, the path is never learned and the value reads back in cleartext. Known-uncovered as of 1.10.2, each verified by execution:
- a custom element carrying
data-tosi-secretinside a bound<form>(el.formis undefined on custom elements; a form-associated one keeps its owner oninternals.form, unreachable from outside) — so an explicit marker is currently weaker than the heuristic- a shadow component containing a password, inside a bound
<form>- a light-DOM container two or more levels up with no
<form>betweenTracked as tosijs#41.
The reliable form is to mark the element that CARRIES THE BINDING, or any ancestor of it — not the control. Discovery works outward from a secret control looking for a binding; marking the binding's element instead means there is nothing to discover.
⚠️ A previous version of this note said "marking the control itself, or its immediate wrapper, works in all of these". The first half is false in ALL THREE shapes above, and it shipped in 1.10.2. Verified by executing the published build: marking the control returns the value in cleartext for the custom element, the shadow component and the two-levels-up container. The marker is only a hint to discovery, and discovery is precisely what fails in each of these — an explicit marker is weaker than the heuristic, which bullet one already said. The "immediate wrapper" half is true only where that wrapper happens to BE the bound element, which is the rule above stated in a way that holds by accident. If you marked a control on the strength of the old advice, re-check it.
What this is for, and what it is not. It is not a defence against script
running in your page — that code can read the state directly and never asks
the agent surface. It exists because describe() output is designed to
leave the machine: it is assembled to be handed to a model, and typically an
off-device one. The guarantee is "tosijs will not volunteer your secrets into
that channel", not "your secrets are safe from an attacker with code
execution". Scope is the real control — declare a manifest, and keep
secrets out of the roots you expose.
Shadow roots are covered (since 1.10.0). Every secret scan used to be
light-DOM only — querySelectorAll and closest both stop at a shadow
boundary — while the write path deliberately crosses it. So a password
inside a styled Component wrote to state normally and was never redacted:
read, changes and describe all returned it in cleartext, against the
guarantee above, for anything using the supported shadow-DOM pattern. Three
things now cross the boundary: the document sweep descends into open shadow
trees, a [data-tosi-secret] region marks the paths bound inside it (it
previously withheld rendered content while leaving read() of the same path
in cleartext — half of what its name promises), and a component that renders
a secret control marks the path bound on its own host, which is where such
a binding actually lives. Closed shadow roots stay invisible, correctly:
nothing outside can bind into one either.
Matching is by spelling (tosijs#32). A secret learned as
rows[id=r1].pwis withheld fromread('rows'),read('rows[0]), andread('rows[0].pw')— descent and index-aliasing are both covered. The known gap is narrower: exotic spellings of a direct query that no canonicalisation resolves. Treat the redaction as defence in depth beneath manifest scoping, not as the boundary itself.
Scope withholds what tosijs can trace to a path — not text you wrote into the page. Under a manifest,
describe()'s structural tier maps headings and landmarks. A heading bound to an undeclared path (or containing such a binding) publishes no text, and its record carriestextWithheld: trueso the absence reads as withheld rather than empty. An unbound heading's text is published: it has no path, so there is nothing for scope to check — including when the text was interpolated from state at creation time:h2(`Reset token: ${app.token.value}`) // published: a copy, not a binding h2({ bindText: 'app.token' }) // withheld: scope sees the pathThis is deliberate. Withholding every unbound heading under a manifest would withhold the page's structure, which is most of what makes the map useful. To keep authored text out, bind it, or mark the heading
data-tosi-secretoraria-hidden.describe({ structure: false })is not an author control — it is the describing caller's option.
One call is the whole story: where the browser provides a WebMCP host
(document.modelContext), enableAgentInterface() also registers the
generated tool set automatically — agent.webmcp is the receipt, and
webmcp: false opts out. No host, no-op.
🚧 CONTRACTS ARE IN FLUX.
ComponentMap/static contract/expose.contractwill change shape without a deprecation cycle while the layering questions settle (tosijs#29, #30) — howcontract.attributesandinitAttributesdivide the work, and whether an integrator's overlay may embellish a component's own declaration rather than replace it. Changes land in patch and minor releases and are called out in the CHANGELOG. For stable attribute declaration today, usestatic initAttributes; since 1.8.1 it is described to agents identically.EXPERIMENTAL. Shapes and names may change. The surface is deliberately protocol-neutral — MCP / WebMCP adapters sit on top of it; the WebMCP auto-registration is a convenience over that adapter, not a dependency.