/*{ "parent": "utilities", "description": "EXPERIMENTAL agent interface: expose a tosijs app's state, wiring, and actions to AI agents (and test harnesses) as a described, observable, path-addressed surface." }*/
/*#
# 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
> ⚠️ **`data-tosi-secret` is 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 one `querySelector` by 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
> receiving `tosi_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:
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 `