Actions & dispatch
Named actions — put the business logic in the view, not the caller.
Merge patches are great for free-form edits (typing in a field), but they make every caller responsible for the business logic: an agent that wants to "add a todo and keep the counts right" has to compute the whole resulting state — and nothing stops it from computing it wrong.
Actions flip that around. The view declares named, semantic operations with their logic next to the view model; callers just pick an action and provide input. Handlers run host-side in a sandbox, so the rules hold no matter who writes — the human in the browser, an agent over MCP, or the CLI.
// src/actions.ts
import { defineActions } from '@rectsh/rect/actions';
export default defineActions({
addTodo: {
description: 'Add a todo item.',
inputSchema: {
type: 'object',
properties: { title: { type: 'string', minLength: 1 } },
required: ['title'],
additionalProperties: false,
},
handler: (state, input, ctx) => {
state.todos = [
...(state.todos ?? []),
{ id: ctx.id(), title: input.title, done: false, createdAt: ctx.now },
];
},
},
approve: {
description: 'Approve the list. Rejects while any todo is open.',
handler: (state, _input, ctx) => {
if ((state.todos ?? []).some((t) => !t.done)) {
ctx.reject('TODOS_OPEN', 'Finish every todo before approving.');
}
state.completion = { approved: true };
},
},
});The Vite plugin auto-detects src/actions.{ts,tsx,js,mjs}, compiles it into
the view's HTML, and publishes each entry's description + inputSchema in
the spec — the catalog agents discover through rect_get_spec and
rect spec. Delete the file and the view is patch-only, exactly as before.
Handlers
type Handler = (state, input, ctx) => void | nextState;state— a draft of the current view model. Mutate it in place, or return the next state.input— the caller's input, already validated against yourinputSchemaby the host.ctx— everything a handler may not take from the environment:ctx.now— ISO timestamp fixed for this dispatchctx.id()— deterministic unique ids (seed-1,seed-2, …)ctx.actor—'agent' | 'user' | 'cli'ctx.reject(code, message?)— stop with a business rejection
Handlers must be pure JS over their arguments: no DOM, no network, no
Date.now() / Math.random() (the sandbox pins both to ctx-derived values so
a retried dispatch computes the same result).
ctx.reject is the point of the model: a rule violation isn't a corrupted
store or a swallowed error — it comes back to the caller as
{ ok: false, code: 'rejected', rejectCode: 'TODOS_OPEN', message: … }, which
an agent can read and re-plan on.
Dispatching
From the view (see React):
const dispatch = useRectDispatch();
const result = await dispatch('approve');
if (!result.ok && result.code === 'rejected') showToast(result.message);From the store: rect.dispatch('addTodo', { title: 'Ship it' }). From the
CLI: rect dispatch <rect-id> addTodo '{"title":"Ship it"}' (and
rect dispatch dev … --dev against the dev bridge, which hot-reloads
src/actions.ts). Agents call the rect_dispatch MCP tool.
On success the returned snapshot is already folded into the store. Concurrency is the host's problem, not the caller's: the write is revision-guarded, and on a concurrent write the host re-runs the action against the fresh state.
Hand-written HTML
The contract lives at the compiled-HTML level, so views without a build step declare actions the same way — two inert blocks:
<script type="application/rect-view+json">
{ "name": "Todos", "example": { "todos": [] },
"actions": { "addTodo": { "description": "…", "inputSchema": { … } } } }
</script>
<script type="application/rect-actions+js">
Rect.defineActions({
addTodo: function (state, input, ctx) {
state.todos = (state.todos || []).concat([{ id: ctx.id(), title: input.title }]);
},
});
</script>The browser executes neither block. At publish the host extracts them, evaluates the actions block in the sandbox, and requires the registered names to match the spec catalog — a broken contract fails the upload, not a dispatch three days later.
patchPolicy
Once a view has actions you can opt out of raw agent patches in the spec:
{ "patchPolicy": "actions-only" }rect_patch then refuses and points the agent at the actions catalog.
The default ("open") keeps patch available alongside dispatch — semantic
transitions through actions, free-form edits through patch.