An AI-agent-first frontend framework. Components are pure data structures. State is immutable. Everything is observable.
JSON-to-DOM rendering — Components return { tag, props, children } trees. No templates, no JSX.
import { defineComponent } from '@atrotos/vio'
const Greeting = defineComponent({
name: 'Greeting',
state: { name: 'World' },
render(state) {
return {
tag: 'h1',
children: [`Hello, ${state.name}!`]
}
}
})Immutable state — State changes produce new objects. Keyed reconciliation determines what to update.
Observable everything — All mutations flow through an event bus. Serializable, loggable, replayable.
import { createApp, defineComponent } from '@atrotos/vio'
const App = defineComponent({
name: 'App',
state: { count: 0 },
render(state, ctx) {
return {
tag: 'div',
children: [
{ tag: 'h1', children: [`Count: ${state.count}`] },
{ tag: 'button', props: { onClick: () => ctx.setState({ count: (state.count as number) + 1 }) }, children: ['Increment'] }
]
}
}
})
const app = createApp({
root: '#app',
routes: [{ path: '/', component: App }],
store: {
state: { theme: 'light' },
actions: {
toggleTheme(s) {
return { ...s, theme: s.theme === 'light' ? 'dark' : 'light' }
}
}
}
})
app.mount()render(state, ctx) returns a descriptor (or null). Nest components with { tag: Child, props, key }; every nested component is a real instance with its own state and lifecycle.
ctx field |
Purpose |
|---|---|
props |
Props from the parent; route components get { params, query } |
setState(patch) / getState() |
Local state |
store / dispatch(action, payload) |
Global store snapshot and actions |
route |
{ path, params, query } |
getRef(name) |
Element tagged with props: { ref: 'name' } |
emit(event, payload) |
Event bus |
Props: class takes a string, array, or { name: bool }; style takes a string or object; value/checked are controlled; on* takes functions; give list items a key. SVG works.
Safe by default: <script>, <base>, <meta>, <object>, <embed>, innerHTML, srcdoc, string on* handlers, and javascript: URLs are dropped and reported as security:blocked events.
Routes: guards return true, false, or a redirect path, and run even without a store. Unknown paths render nothing and emit route:notfound. Navigating between paths served by the same component keeps its state.
An agent can see, operate, and verify a running app without selectors or screenshots. packages/vio/AGENTS.md is the full contract in about 500 tokens.
app.manifest()
// { route: '/', routes: ['/'], actions: { add: 'text:string · Add a todo', clear: 'confirm' }, components: [...] }
app.see()
// route=/
// e1 app
// e2 main
// e4 form: e5 textbox "New todo" val="", e6 button "Add"
// e7 list
// e8 item "Buy milk": e9 checkbox, e10 button "Delete"
await app.act('e9', 'click') // returns a diff:
// ~ e8 item .done "Buy milk"
// ~ e9 checkbox [checked]
await app.invoke('add', 'Eggs') // named store action → diff; actions with meta.confirm need { confirm: true }
await app.assert({ text: '1 left' }) // waits up to 1s → { pass: true } or { pass: false, detail }
app.see({ diff: true }) // only what changed since the last snapshotRefs stay stable while the element exists. Long lists page (page, pageSize), deep trees collapse (depth), ref scopes to a subtree, and password fields are redacted. Token budgets for the snapshot, diffs, manifest, AGENTS.md, and MCP tool definitions are enforced in tests.
Describe store actions for agents with meta:
store: {
state: { todos: [] },
actions: { add: (s, text) => ({ ...s, todos: [...s.todos, text] }), clear: (s) => ({ ...s, todos: [] }) },
meta: { add: { params: 'text:string', doc: 'Add a todo' }, clear: { confirm: true } }
}Agents can describe a screen as a flat JSON spec instead of writing code. A catalog lists the trusted components (and HTML tags) a spec may use; every spec is validated before it touches the DOM.
import { defineCatalog, defineSpecView } from '@atrotos/vio/spec'
const catalog = defineCatalog({
components: { Badge: { component: Badge, doc: 'Status label', props: { 'label!': 'string', tone: ['info', 'warn'] } } }
})
const View = defineSpecView({ name: 'View', catalog, actions: ['toggle'] })
// The spec lives in the view's state. Agents set or patch it:
app.patchState('View', [{ op: 'replace', path: '/spec', value: {
root: 'list',
nodes: {
list: { type: 'ul', children: ['row'] },
row: { type: 'li', each: '/todos', key: 'id', text: { $item: '/text' },
props: { onClick: { $action: 'toggle', args: { $item: '/id' } } } }
}
} }])- Values:
{ $state: '/ptr' }reads the store,{ $item: '/field' }reads the currenteachitem,{ $action: 'name', args }dispatches a store action,{ $event: 'value' }passes an input's value as an arg. Functions are never allowed. - Validation reports every problem with a JSON Pointer and a fix:
E_SPEC_PROP /nodes/b/props/lable: Badge has no prop "lable"; did you mean "label"?. An invalid spec renders its errors (visible inapp.see()) instead of the UI. - Safety: unknown tags (
script,iframe,svg…), string handlers, raw HTML props,__proto__keys, and oversized or cyclic specs are rejected. - Discovery: a mounted spec view puts its catalog and the spec grammar in
app.manifest().docs. app.patchState(id, ops)applies JSON Patch (add,remove,replace,test) to any component's state, all-or-nothing.
The spec module is a separate entry point, so apps that don't use it don't ship it.
Lower-level control of a running app:
// State management
app.setState(componentId, { count: 5 })
app.getState(componentId)
// Global store
app.dispatch('toggleTheme')
app.getStore()
// Navigation (returns false when no route matches)
app.navigate('/dashboard')
app.getRoute()
// Introspection: nested tree with stable path IDs like "App/TodoList/Item[3]"
app.getComponentTree()
// Event observation
app.on('state:change', (event) => console.log(event))
// Batch: validates every op first, applies them with a single render, rolls back state if one throws
app.batch([
{ action: 'setState', target: id, payload: { count: 10 } },
{ action: 'dispatch', payload: { action: 'toggleTheme' } }
])
// Teardown
app.destroy()| Module | Import | Description |
|---|---|---|
| Store | Store |
Global state with pure action reducers |
| Router | Router |
Path matching, params, guards |
| HTTP | HttpClient |
Fetch wrapper with interceptors |
| Forms | createForm |
Form state, validation, node generation |
@atrotos/vio-devtools is a companion package that exposes a running Vio app to AI agents via the Model Context Protocol. An MCP-capable client (Claude Code, Cursor, etc.) can read state, dispatch actions, navigate routes, and inspect the component tree — all from the terminal.
Architecture: MCP Server (stdio) ↔ WebSocket Bridge ↔ Browser Client
- Browser side — connect the devtools client in your app entry:
import { connectDevtools } from '@atrotos/vio-devtools/client'
const app = createApp({ /* ... */ })
app.mount()
connec
79B0
tDevtools(app)- MCP config — add to your
.mcp.json(or Claude Code settings):
{
"mcpServers": {
"vio-devtools": {
"command": "node",
"args": ["./packages/vio-devtools/dist/cli.js"]
}
}
}- Build —
npm run buildfrom the workspace root.
Security: the bridge binds to 127.0.0.1 only and rejects browser connections from non-localhost origins (add more with VIO_DEVTOOLS_ORIGINS=https://a,https://b). Set VIO_DEVTOOLS_TOKEN on the server and pass the same value as connectDevtools(app, { token }) to require a token.
Coding agents often work faster with shell commands than with MCP. vio drives the same running app through a local control port (3101), which both vio serve and the MCP server open.
npx vio serve # bridge only, if you are not running the MCP server
npx vio status # which page is connected
npx vio see --diff
npx vio act button:Add click # refs (e5) or queries: role[.class][:name], with > to scope
npx vio act "group:Eggs>button.toggle" click
npx vio invoke addTodo '"Milk"'
npx vio assert text="1 item left" # prints pass / fail: …, exit code 2 on failure
npx vio run examples/starter/agent/todo.flow.jsonvio run executes a JSON flow ({"steps": [{"act": ["textbox", "fill", "Milk"]}, {"assert": {"text": "Milk"}}]}) and stops at the first failing step, so agent flows can be regression-tested. Exit codes: 0 ok, 1 error, 2 assertion failed.
Only one page is driven at a time: the newest page to connect wins, and vio status shows which one. Pages reconnect automatically after the devtools server restarts. The control port refuses browser requests (any Origin header) and non-loopback Host headers, and requires x-vio-token when VIO_DEVTOOLS_TOKEN is set.
| Tool | Description |
|---|---|
vio_manifest |
Routes, store actions, mounted components |
vio_see |
Text snapshot with refs; diff, ref, depth, page |
vio_act |
click / fill / check / select / press / … by ref or role:name query; returns a diff |
vio_invoke |
Run a store action by name; returns a diff |
vio_assert |
Wait for text / route / state / ref / count conditions |
vio_state |
Read store or component state by JSON Pointer; set or JSON patch component state |
vio_nav |
Navigate; returns a diff |
vio_debug |
Component tree, recent events, or errors |
- JSON-native —
{ tag, props, children }. Any LLM can produce valid component trees. - Pure renders —
render(state, ctx) → tree. Same input, same output. Deterministic. - Full introspection — Agents can see every mounted component, its state, and props via
getComponentTree(). - Full control —
see,act,invoke,assert, plussetState,dispatch,navigate,batch. - Serializable output — Trees, state, and events are plain data (functions are dropped from introspection). Works over any transport.
- MCP-ready — Connect
@atrotos/vio-devtoolsand any MCP client can control your app live.
MIT