rect.sh

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 your inputSchema by the host.
  • ctx — everything a handler may not take from the environment:
    • ctx.now — ISO timestamp fixed for this dispatch
    • ctx.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.

On this page