agent (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
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).

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 nothingdescribe() 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.

Secrets

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" data-bind="value=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.11.0 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.11.0, each verified by execution:

  • a custom element carrying data-tosi-secret inside a bound <form> (el.form is undefined on custom elements; a form-associated one keeps its owner on internals.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> between

Tracked as tosijs#41. Marking the control itself, or its immediate wrapper, works in all of these and is the reliable form. Prefer it to relying on discovery.

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].pw is withheld from read('rows'), read('rows[0]), and read('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.

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.contract will change shape without a deprecation cycle while the layering questions settle (tosijs#29, #30) — how contract.attributes and initAttributes divide 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, use static 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.