Skip to content
DocspackagesDocumentation

@lunora/vue

Vue 3 composables built on @lunora/client — live queries, optimistic mutations, and an SSR hydration handoff.

PackagesVue

@lunora/vue is the Vue 3 adapter for Lunora. It's a thin layer over the framework-neutral @lunora/client, which owns the WebSocket transport, subscription registry, offline queue, and delta-merge. The adapter re-expresses that contract as Vue composables. A live query is a shallowRef the socket writes to; an optimistic mutation is a small bundle of refs plus an awaitable mutate.

Preview. The Vue adapter exists and exposes the same hydrate-then-subscribe handoff as React, but it is preview maturity and not yet proven end-to-end against a running app. The React adapter is the only one verified through the full live-loader path. See Bring your framework.

import { LunoraClient } from "lunorash/client";
import { createLunora } from "@lunora/vue";
import { createApp } from "vue";

import { api } from "./lunora/_generated/api";
import App from "./App.vue";

const client = new LunoraClient({ url: import.meta.env.VITE_LUNORA_URL });

createApp(App).use(createLunora(client)).mount("#app");

Exports

SymbolKindRole
createLunorapluginapp.use(createLunora(client)) provides the shared client to the whole app.
provideLunoracomposableprovide the client to a subtree from inside a parent setup().
useLunoracomposableRead the LunoraClient from the nearest provider. Throws if none is mounted.
LUNORA_INJECTION_KEYinjection keyThe InjectionKey the provider uses, for advanced manual inject().
useQuerycomposableLive query as a ref. Reactive args re-subscribe; "skip" short-circuits.
useMutationcomposableOptimistic mutation handle: { data, error, pending, mutate, reset } refs.
useActioncomposableAction handle: { data, error, pending, call, reset } refs. No optimistic options.
subscribeToQueryfunctionLow-level: subscribe with fixed args into a ref (the primitive behind hydration).
hydratePreloadedcomposableSeed a ref synchronously from an SSR Preloaded token, then attach the live subscription.

Re-exported types: ArgsOf, LunoraClient, FunctionReference, MutationCallOptions, OptimisticLocalStore, OptimisticUpdate, Preloaded, ReturnOf, Unsubscribe, User, UseQueryOptions, and the MutationHandle and ActionHandle interfaces.

Provider — createLunora / provideLunora / useLunora

Mount the single app-wide LunoraClient so every composable can resolve it. Use the plugin form at the app root, or provideLunora inside a parent component's setup() to scope a client to a subtree:

import { createLunora, provideLunora, useLunora } from "@lunora/vue";

// Plugin form (app root):
createApp(App).use(createLunora(client)).mount("#app");

// Composition form (inside a parent <script setup>):
provideLunora(client);

// Anywhere below: read the client directly.
const client = useLunora();

useLunora() throws a clear error when called outside a createLunora/provideLunora scope, so a missing provider fails loudly instead of surfacing as a later undefined dereference.

useQuery(fn, args, options?)

Subscribes to a query and exposes the latest value as a ref. The ref is undefined until the first server response lands, then updates on every delta the server pushes. This is the Vue equivalent of React's useQuery.

args may be a plain value, a ref, or a getter. Passing a reactive source makes the subscription reactive: when the args change, the previous subscription is torn down, the ref resets to undefined, and a fresh one opens for the new args. Pass "skip" (or a source resolving to "skip") to short-circuit: no network call, no socket. Multiple useQuery calls with identical args share a single underlying subscription (the client de-dupes by (fn, args, shardKey)).

<script setup lang="ts">
import { useQuery } from "@lunora/vue";
import { ref } from "vue";

import { api } from "./lunora/_generated/api";

const channelId = ref("general");

// Reactive getter args: changing `channelId` re-subscribes.
const messages = useQuery(api.messages.list, () => ({ channelId: channelId.value }));

const signedIn = ref(false);
const profile = useQuery(api.users.me, () => (signedIn.value ? {} : "skip"));
</script>

<template>
    <ul>
        <li v-for="m in messages" :key="m._id">{{ m.text }}</li>
    </ul>
</template>

Pass { shardKey } in options to route to a specific shard when the target function is .shardBy(...)-partitioned. The subscription tears down automatically when the owning component unmounts (or its effect scope stops), so call useQuery inside setup() or another active effect scope.

useMutation(fn)

Returns a reactive MutationHandle for a mutation reference:

interface MutationHandle<F> {
    data: Ref<ReturnOf<F> | undefined>; // latest resolved value
    error: Ref<Error | undefined>; // latest error
    pending: Ref<boolean>; // true while any call from this handle is in flight (ref-counted)
    mutate: (args, options?) => Promise<ReturnOf<F>>; // awaitable; resolves with the server value
    reset: () => void; // clear data/error back to idle
}

pending is ref-counted across overlapping invocations of the same handle, so it only flips back to false once every concurrent call has settled.

<script setup lang="ts">
import { useMutation } from "@lunora/vue";

import { api } from "./lunora/_generated/api";

const { mutate, pending, error } = useMutation(api.messages.send);

async function send(channelId: string, text: string) {
    await mutate({ channelId, text });
}
</script>

<template>
    <button :disabled="pending" @click="send('general', 'hi')">Send</button>
    <p v-if="error">{{ error.message }}</p>
</template>

Optimistic updates

Optimistic updates stay client-owned and are passed per call via mutate's MutationCallOptions: the optimistic / optimisticUpdate options pass straight through to client.mutation, which applies them against the live Lunora subscription cache and rolls them back on failure. Any useQuery / hydratePreloaded ref reading the same data reflects the change immediately and reverts if the server rejects.

await mutate(
    { channelId, text },
    {
        optimisticUpdate: (store) => {
            const current = store.getQuery(api.messages.list, { channelId }) ?? [];
            store.setQuery(api.messages.list, { channelId }, [...current, draft]);
        },
    },
);

The Vue adapter does not ship a bound withOptimisticUpdate(...) handle (React keeps one). Use mutate(args, {optimisticUpdate}) per call instead.

useAction(fn)

Returns a reactive ActionHandle for an action reference — the same shape as MutationHandle, with call in place of mutate:

interface ActionHandle<F> {
    data: Ref<ReturnOf<F> | undefined>; // latest resolved value
    error: Ref<Error | undefined>; // latest error
    pending: Ref<boolean>; // true while any call from this handle is in flight (ref-counted)
    call: (args, options?) => Promise<ReturnOf<F>>; // awaitable; resolves with the server value
    reset: () => void; // clear data/error back to idle
}
<script setup lang="ts">
import { useAction } from "@lunora/vue";

import { api } from "./lunora/_generated/api";

const { call, pending, error } = useAction(api.commands.run);
</script>

<template>
    <button :disabled="pending" @click="call({ command: 'lunora', args: ['verify'] })">Verify</button>
    <p v-if="error">{{ error.message }}</p>
</template>

Per-call options are { shardKey } only. Unlike useMutation there are no optimistic / optimisticUpdate options: an optimistic update patches the subscription cache on the assumption a write will land, and an action is not a write — it runs in the Worker, may call a third party, and has no declared effect on any query.

Reactive loaders — hydratePreloaded

The SSR-seed-to-live handoff: the Vue half of "your loaders are live". Run the query on the server with preloadQuery (from @lunora/vue/server), pass the serializable Preloaded token to the client, and hydratePreloaded seeds a ref synchronously from preloaded.value, so the first read during hydration shows the server value with no loading flash and no hydration mismatch. After seeding it opens the live WebSocket subscription on the same (functionPath, args, shardKey) the loader used, so every later delta updates the ref exactly like useQuery.

Pass { onError } as the second argument to observe a subscription-scoped error the server pushes after hydration (a session expiry, an RLS denial). Without it such an error is dropped and the ref keeps rendering the SSR snapshot as if it were live.

<script setup lang="ts">
import { hydratePreloaded } from "@lunora/vue";
import type { Preloaded } from "@lunora/vue";

const props = defineProps<{ preloaded: Preloaded<Array<{ _id: string; title: string }>> }>();

// Seeded from SSR on first read, then live.
const posts = hydratePreloaded(props.preloaded);
</script>

<template>
    <ul>
        <li v-for="p in posts" :key="p._id">{{ p.title }}</li>
    </ul>
</template>

subscribeToQuery(client, fn, args, { seed?, shardKey?, onError? }) is the lower-level primitive hydratePreloaded builds on: it subscribes with fixed args (never reactive) and seeds the ref synchronously. Reach for it only when you already hold a client and immutable args; otherwise prefer useQuery or hydratePreloaded.

@lunora/vue/server

Server-side preload helpers, re-exported from @lunora/client/ssr, the framework-neutral server contract shared by every adapter. This entry opens no WebSocket and touches no browser globals, so it is safe to import from a Nuxt/Nitro server route or any SSR context.

import { createServerClient, preloadQuery } from "@lunora/vue/server";

import { api } from "./lunora/_generated/api";

// Per request — never reuse a client across requests (token leakage).
const client = createServerClient({ url: process.env.LUNORA_URL!, token });
const preloaded = await preloadQuery(client, api.posts.list, {});
// → pass `preloaded` to the component, hand it to hydratePreloaded on the client.

Exports: createServerClient, preloadQuery, serializePreloaded, deserializePreloaded, preloadedQueryResult, getServerSession, plus the Preloaded / ServerClientOptions / ServerSession types.

Agent tool events — useAgentToolEvents(options)

Observes one agent thread's tool activity, separate from its chat transcript: which tools the model called, what they returned, which are parked on a human approval, and any in-flight ctx.reportProgress(...) updates.

options: { api, threadKey, stream?, limit? }api is the generated api (it reads api.agents.agentMessages), and stream is the same app stream reference useAgentChat takes. Returns { events }, a ComputedRef. threadKey may be a plain string, a ref, or a getter — a reactive source re-subscribes.

Each event discriminates on type:

typeFieldsSource
calltoolCallId, toolName, input, seqdurable
resulttoolCallId?, toolName?, output, status?, seqdurable
awaiting-approvaltoolCallId?, toolName?, seqdurable
progresstoolCallId, datalive stream

Durable events come first, oldest first by seq, followed by the ephemeral progress events for the in-flight turn. With no stream reference only the durable lifecycle is surfaced. The array is rebuilt on every update — treat it as derived, not identity-stable, and key rendered rows on toolCallId/seq.

<script setup lang="ts">
import { useAgentToolEvents } from "@lunora/vue";
import { api } from "@/lunora/_generated/api";

const props = defineProps<{ threadKey: string }>();
const { events } = useAgentToolEvents({ api, stream: api.chat.liveEvents, threadKey: () => props.threadKey });
</script>

<template>
    <ol>
        <li v-for="(event, index) in events" :key="event.type === 'progress' ? `p-${index}` : event.seq">
            {{ event.type }}
        </li>
    </ol>
</template>

Voice agents — useVoiceAgent(options)

Opens a full-duplex voice call against a voice-enabled agent — the api.agents.<name>Voice reference codegen emits. Microphone capture goes up the agent's WebSocket, synthesized speech comes back down, and transcripts plus barge-in are surfaced along the way.

options: { voice, threadKey, silenceThreshold?, silenceDurationMs?, interruptThreshold?, interruptChunks?, createMicrophone?, createSocket?, createSpeaker? }. threadKey is shared with the agent's text turns, so a voice call continues the very same conversation useAgentChat renders.

Returns { status, connected, transcript, interimTranscript, audioLevel, isMuted, error, startCall, endCall, sendText, toggleMute }, where every value is a readonly Ref. status is "idle" | "listening" | "thinking" | "speaking".

<script setup lang="ts">
import { useVoiceAgent } from "@lunora/vue";
import { api } from "@/lunora/_generated/api";

const { audioLevel, endCall, startCall, status, transcript } = useVoiceAgent({
    threadKey: "thread-1",
    voice: api.agents.supportVoice,
});
</script>

<template>
    <button @click="status === 'idle' ? startCall() : endCall()">{{ status === "idle" ? "Call" : "Hang up" }}</button>
    <meter :max="1" :value="audioLevel" />
    <p>{{ transcript }}</p>
</template>

Microphone and audio lifecycle

The part the type signature does not tell you:

  • Nothing opens until startCall(). Creating the handle touches neither the microphone nor the socket. startCall is what calls getUserMedia, so it has to run from a user gesture — browsers block both the permission prompt and AudioContext resumption outside one. It is idempotent while a call is active, and a denied permission lands in error rather than throwing at the call site.
  • endCall() releases everything — the socket, the microphone tracks, and the Web Audio graph — and is idempotent. onScopeDispose calls it for you, so unmounting the component (or stopping the effect scope) ends the call.
  • audioLevel is the live input RMS (0–1). It drives a mic meter, and it is also what the heuristics read: silenceThreshold + silenceDurationMs decide when an utterance auto-commits (defaults 0.01 / 1200ms), and interruptThreshold + interruptChunks decide when the user barges in on the agent mid-sentence (defaults 0.15 / 3 consecutive chunks). These are room-dependent; tune them against real hardware rather than trusting the defaults.
  • createMicrophone / createSpeaker / createSocket are injection seams for tests and non-DOM hosts. The defaults are getUserMedia + Web Audio + new WebSocket(url), so the primitive is inert (and mockable) anywhere those are missing.