The command palette, the AI’s client tools and the page’s own buttons tend to be three lists of the same things, kept in sync by hand. They drift. The AI offers an action the page no longer has, or the palette misses one the AI can do.

So pages publish their actions to one store, only while they’re mounted. Everything that wants to know “what can I do here?” reads that store.

interface Action<I> {
  name: string;
  title: string;          // what a palette row says
  description: string;    // what the model reads
  input: z.ZodType<I>;
  annotations: { readOnly?: boolean; consequential?: boolean; payload?: boolean };
  run(input: I, app: AppHandle): Promise<{ ok: true } | { ok: false; reason: string }>;
}

// in a page
useRegisterAction(EXPORT_CSV);          // registered on mount, gone on unmount
             ┌──────────── ActionStore ─────────────┐
  pages ───▶ │ actions      (live, by name)          │ ──▶ command palette rows
  root  ───▶ │ destinations (every route the nav has)│ ──▶ agent: client tools + catalog
             │ screen       (what's on it now)       │ ──▶ payload cards
             └───────────────────┬───────────────────┘
                                 ▼
                 execute(name, input): one at a time

navigate is built in, and checks its target against the app’s published destinations before it goes:

async run({ destination, params, search }, app) {
  if (!app.destinations.some((d) => d.id === destination))
    return { ok: false, reason: `${destination} isn't a page you can open.` };
  await app.go({ to: destination, params, search });
  return { ok: true };
}

The model can only go where the nav can go. A made-up route comes back as a reason the model can read, not a broken page.

Rules

  • Validate twice. The server checks a tool call against the declared schema, and execute checks it again against the action’s own schema before running.
  • One at a time. execute queues, so each action sees the URL the last one left. “Filter to critical, then sort by date” lands in order.
  • One way to the router. Every action reaches navigation through go, so palette, buttons and AI all produce URL changes the back button can undo.

Why it works

The person and the AI see the same capabilities, because there’s only one list. A page that adds an action gets it in the palette and in the agent’s toolbag at once, with no extra wiring.