Skip to content

Building agentic-ui-vue: Letting an LLM Operate a Vue App Safely

We wanted an AI assistant to do more than explain our Vue app. We wanted it to operate the app: fill a form, search a table, update a Pinia store, or save a record. The obvious routes both broke down quickly.

DOM automation asks the model to interpret pixels and click buttons. It is brittle under a redesign, bypasses application semantics, and turns a simple action into a guessing game. Raw LLM function-calling is better, but it usually starts as one flat, manually assembled list. It does not know which form is mounted, which action is temporarily disabled, or which of two identical panels the user means.

Our answer in agentic-ui-vue is an app-native tool registry. Components declare typed, described, risk-tagged capabilities. The model calls them, but they execute inside the app, against the same state and Pinia stores as the UI.

ts
const todoStore = useTodoStore()

useAgentCapability({
  name: 'todos.add',
  description: 'Add a new item to the current todo list.',
  inputSchema: {
    type: 'object',
    properties: { text: { type: 'string' } },
    required: ['text'],
    additionalProperties: false,
  },
  risk: 'mutating',
  execute: ({ text }: { text: string }) => todoStore.add(text),
})

Pinia is not hidden behind a special adapter here. It remains app state; a capability is simply the explicit contract through which an agent may affect it.

A catalog that follows the UI

The first design decision was that the catalog must have the same lifecycle as the interface. useAgentCapability registers on onMounted, updates through its registration handle when a reactive definition changes, suspends when isAvailable becomes false, and disposes on onUnmounted.

That means the model's tool list describes what is actually on screen. Close a drawer and its capabilities disappear. If a tool-call was already in flight, resolution returns unavailable with a hint to re-list the tools. The result goes back into the agent loop, so the model can self-correct instead of acting on a component that no longer exists.

One safety pipeline, with no side doors

Every call enters through registry.execute(). We deliberately kept one execution path:

text
resolve → stale-check → isAvailable → validate(inputSchema)
        → permission → confirmation → execute

Resolution selects the capability and target. The revision check prevents an old catalog from being used. Runtime availability gets one last vote. The schema validator turns model output into trusted input before permission and confirmation run. Only then can application code execute.

Each capability must declare one of three risk levels: read, mutating, or destructive. read defaults to no confirmation; mutating and destructive default to digest-bound confirmation. A permission hook runs before confirmation, because we should never ask someone to approve an operation they are not allowed to perform.

Approval belongs to the payload

Approving “the save tool” is too broad. The useful question is whether the user approved this save, with these arguments, for this target.

For risky calls, core computes SHA-256 over canonical JSON containing { name, targetId, input }. Object keys are sorted, whitespace is removed, and the result is a stable 64-character digest. Change one byte of that canonical payload and the digest changes, so the app prompts again. An approval can be memoized for that digest or, when policy permits, for the session.

The Vue package provides a headless queue; the host app owns the actual dialog:

ts
const { registry } = useAgent()
const queue = useConfirmationQueue()

registry.setConfirmationHook(queue.hook)

// In the approval UI:
queue.pending.value[0]?.approve('digest')
// or: queue.pending.value[0]?.deny('Not now')

The queue's promise keeps registry.execute() blocked until the UI resolves it. No bundled modal, no security theater, and no approval that silently carries over to a changed payload.

Routing to the right instance

Real apps repeat components. Two forms may both expose form.save, so baking a component id into the capability name would create a noisy, unstable catalog. Instead, each registration can declare a target:

ts
useAgentCapability({
  name: 'form.save',
  target: { id: props.panelId, label: props.title },
  description: 'Save the values in this form.',
  inputSchema: { type: 'object', properties: {} },
  risk: 'mutating',
  execute: saveForm,
})

When multiple active registrations share the name, request serialization emits one tool and injects a _target enum into its schema. The registry strips _target before validating the capability's real schema and routes the call to the chosen instance. If that target vanished, the result is unavailable and the hint lists the surviving targets. The model can switch rather than guess.

Revisions make time visible

Mount, replace, suspend, resume, and dispose all increment the catalog revision. When the client serializes tools, it snapshots that revision and attaches it to later calls. Each registry entry also records the revision of its last breaking registration or replacement.

If a call was planned against an older definition, it returns stale instead of executing. This is optimistic concurrency control for an agent's view of the UI: cheap, explicit, and much safer than assuming the page stood still while the model reasoned.

Three tools instead of fifty

Sending every schema works for a small app. At 50+ capabilities, prompt size and tool selection both get worse. Meta-tools mode sends only:

ts
buildRequestTools(registry, { mode: 'meta-tools' })

// agent.search_capabilities
// agent.describe
// agent.execute

The model searches names and descriptions, describes one capability lazily, then executes it. Crucially, agent.execute calls registry.execute() for the real capability. The wrapper is read; validation, permission, and confirmation happen again on the inner call, and the digest contains the real tool name, target, and input—not the meta-tool payload.

The dot that broke the real agent loop

Our most valuable failure arrived late. We use dotted namespaces such as form.save and users.table.search; they are readable and natural. Our MockLanguageModelV2 tests accepted them, and the complete loop looked healthy.

Then we ran the same loop against a real model through a local 9Router gateway. The request failed with a 400. OpenAI-compatible providers require function names to match ^[a-zA-Z0-9_-]+$. A dot is invalid.

The fix was to treat provider naming as a wire concern. toWireName() sanitizes catalog names at the LLM boundary—form.save becomes form_save—while bindRegistryToChat rebuilds a wire-to-real map from the live catalog and restores form.save before execution. Collision detection prevents two real names from silently mapping to the same wire name.

That live test found a second provider-shaped edge: the AI SDK's default Responses API mis-threaded tool-call ids through the gateway. Our proxy now uses openai.chat(), the Chat Completions path exposed by OpenAI-compatible gateways.

The lesson was sharper than “add an integration test.” Mock LLMs are excellent for deterministic behavior, but they reproduce the contract we imagine. A real model and provider reproduce the contract we actually have.

Clean boundaries, better tests, visible internals

We kept @agentic-ui/core, @agentic-ui/schema, and @agentic-ui/protocol free of Vue; an architecture test and an ESLint rule enforce that boundary. @agentic-ui/vue supplies lifecycle and reactivity. @agentic-ui/client owns AI SDK transport and wire naming. @agentic-ui/testing can exercise the full registry pipeline like a plain function:

ts
const result = await callCapability(capability, input, {
  permission: true,
  confirm: 'approve',
})

As a bonus, @agentic-ui/vue integrates with @vue/devtools-api: a live capability inspector shows names, targets, risk, state, schemas, and revisions, while an audit timeline shows received, validated, permitted, confirmed, executed, or rejected events. The agent's world is no longer invisible.

agentic-ui-vue is MIT-licensed. Start with the getting started guide, or explore the repository.