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 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.
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-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. 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].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.
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.