Nuxt
Build Better Agent apps in Nuxt.
Build a Better Agent app in Nuxt with a typed client, useAgent(...), durable conversations, approvals, client tools, and stream recovery.
Create the app
Start with a normal Better Agent server module.
import { betterAgent, defineAgent } from "@better-agent/core";
import { createOpenAI } from "@better-agent/providers/openai";
const openai = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const assistant = defineAgent({
name: "assistant",
model: openai.text("gpt-5-mini"),
instruction: "You are a concise assistant. Keep replies short and natural.",
});
const app = betterAgent({
agents: [assistant],
baseURL: "/api/agents",
secret: "dev-secret",
});
export default app;Mount the route
Create a Nuxt server route and forward the request to Better Agent.
import { defineEventHandler, toWebRequest } from "h3";
import app from "~~/lib/better-agent/server";
export default defineEventHandler(async (event) => {
const request = toWebRequest(event);
return app.handler(request);
});Keep baseURL and the route path aligned. If the app uses baseURL: "/api/agents", the route should live under /api/agents.
Create the typed client
Import the server app type so agent names, context, tools, and output stay aligned.
import { createClient } from "@better-agent/client";
import type app from "./server";
export const client = createClient<typeof app>({
baseURL: "/api/agents",
secret: "dev-secret",
});Build a basic chat
In Nuxt, use useAgent(...) from a page or component. The Vue adapter returns reactive refs and actions.
<script setup lang="ts">
import { ref } from "vue";
import { useAgent } from "@better-agent/client/vue";
import { client } from "~~/lib/better-agent/client";
const input = ref("");
const agent = useAgent(client, {
agent: "assistant",
});
</script>
<template>
<div>
<form
@submit.prevent="
input.trim() &&
agent.sendMessage(input).then(() => {
input = '';
})
"
>
<input v-model="input" placeholder="Ask something..." />
<button type="submit" :disabled="agent.status !== 'ready'">
Send
</button>
<button
type="button"
@click="agent.stop()"
:disabled="agent.status !== 'submitted' && agent.status !== 'streaming'"
>
Stop
</button>
</form>
<p>Status: {{ agent.status }}</p>
<p v-if="agent.error">{{ agent.error.message }}</p>
<ul>
<li v-for="message in agent.messages" :key="message.localId">
{{ message.role }}:
{{ message.parts.map((part) => part.type === 'text' ? part.text : '').join('') }}
</li>
</ul>
</div>
</template>Keep one conversation across refreshes
When you use server-managed persistence, give the chat a conversationId, hydrate the saved transcript, and resume the active stream on page load.
<script setup lang="ts">
import { useAgent } from "@better-agent/client/vue";
import { client } from "~~/lib/better-agent/client";
const agent = useAgent(client, {
agent: "assistant",
conversationId: "support-demo",
hydrateFromServer: true,
resume: true,
});
</script>
<template>
<div>
<p>Status: {{ agent.status }}</p>
<p>Conversation: {{ agent.conversationId }}</p>
<p>Messages: {{ agent.messages.length }}</p>
</div>
</template>Use resume: true when you want the hook to reconnect to the active conversation stream on init. Use resume: { streamId, afterSeq } when you already track a stream cursor yourself.
For resume: true, Better Agent needs persistence to find the active stream for a conversation. With only a StreamStore, use resume: { streamId }. Add runtime state to enable conversation-based resume. See Persistence.
Pass context and tune a run
Use hook options for stable per-chat defaults like context, modelOptions, and delivery.
<script setup lang="ts">
import { useAgent } from "@better-agent/client/vue";
import { client } from "~~/lib/better-agent/client";
const agent = useAgent(client, {
agent: "assistant",
context: {
userId: "user_123",
plan: "pro",
},
modelOptions: {
reasoningEffort: "medium",
},
delivery: "stream",
});
</script>
<template>
<button @click="agent.sendMessage('Summarize my plan.')">
Ask
</button>
</template>Add browser-side tools
Register client tool handlers on the hook when the agent uses tools that should run in the browser.
<script setup lang="ts">
import { useAgent } from "@better-agent/client/vue";
import { client } from "~~/lib/better-agent/client";
const agent = useAgent(client, {
agent: "assistant",
toolHandlers: {
show_confetti: async ({ color }) => {
console.log("Confetti color:", color);
return { shown: true };
},
},
});
</script>
<template>
<button @click="agent.sendMessage('Celebrate the upgrade.')">
Run
</button>
</template>Use onToolCall instead when you want one per-run function instead of a handler map.
Handle approvals in the UI
When a tool needs human approval, useAgent(...) exposes pending requests and a helper to answer them.
<script setup lang="ts">
import { useAgent } from "@better-agent/client/vue";
import { client } from "~~/lib/better-agent/client";
const agent = useAgent(client, {
agent: "assistant",
});
</script>
<template>
<div>
<div v-for="approval in agent.pendingToolApprovals" :key="approval.toolCallId">
<div>{{ approval.toolName }}</div>
<pre>{{ JSON.stringify(approval.meta, null, 2) }}</pre>
<button
@click="
agent.approveToolCall({
toolCallId: approval.toolCallId,
decision: 'approved',
})
"
>
Approve
</button>
<button
@click="
agent.approveToolCall({
toolCallId: approval.toolCallId,
decision: 'denied',
})
"
>
Deny
</button>
</div>
</div>
</template>Watch events and final results
Use callbacks when you want analytics, data parts, disconnect handling, or typed final output.
<script setup lang="ts">
import { useAgent } from "@better-agent/client/vue";
import { client } from "~~/lib/better-agent/client";
useAgent(client, {
agent: "assistant",
onResponse: (response) => {
console.log("HTTP status", response.status);
},
onEvent: (event) => {
console.log("Event", event.type);
},
onData: (part) => {
console.log("Data part", part.data);
},
onDisconnect: ({ error, streamId }) => {
console.error("Stream disconnected", streamId, error);
},
onError: (error) => {
console.error("Run failed", error);
},
onFinish: ({ finishReason, response }) => {
console.log("Finished", finishReason);
console.log(response?.output);
},
});
</script>If your agent has a default outputSchema, onFinish also receives typed structured output.
Shape local replay
Use these options when you want to control how local message history is turned back into model input.
<script setup lang="ts">
import { useAgent } from "@better-agent/client/vue";
import { client } from "~~/lib/better-agent/client";
const agent = useAgent(client, {
agent: "assistant",
initialMessages: [
{
role: "assistant",
parts: [{ type: "text", text: "Welcome back." }],
},
],
prepareMessages: ({ messages, input }) => {
return [
{
type: "message",
role: "system",
content: `Reuse the important context from the last ${messages.length} local messages.`,
},
{ type: "message", role: "user", content: [{ type: "text", text: String(input) }] },
];
},
});
</script>
<template>
<div>
<button @click="agent.regenerate()">
Regenerate last turn
</button>
<button
@click="
agent.setMessages((messages) =>
messages.filter((message) => message.role !== 'assistant')
)
"
>
Hide assistant messages
</button>
</div>
</template>Use retryMessage(localId) when you want to rerun one earlier user turn, and resumeStream(...) or resumeConversation(...) when you want to reconnect manually instead of using resume on init.
Optimistic UI
Use optimistic insertion when you want the user message to appear in the chat before the request finishes.
<script setup lang="ts">
import { useAgent } from "@better-agent/client/vue";
import { client } from "~~/lib/better-agent/client";
const agent = useAgent(client, {
agent: "assistant",
optimisticUserMessage: {
enabled: true,
onError: "remove",
},
onOptimisticUserMessageError: ({ error }) => {
console.error("Optimistic message failed", error);
},
});
</script>
<template>
<div>
<button @click="agent.clearError()">
Clear error
</button>
<button @click="agent.reset()">
Reset chat
</button>
</div>
</template>