Nuxt (Vue)
Integrate Atlas into a Nuxt 3 app using @ai-sdk/vue.
Integrate Atlas into a Nuxt 3 app using @ai-sdk/vue.
Prerequisites: A running Atlas API server. See Bring Your Own Frontend for architecture and common setup.
AI SDK v6 — Vue adapter
As of AI SDK v6, @ai-sdk/vue (v3+) supports the transport-based API (DefaultChatTransport, sendMessage) shown below. Make sure you install @ai-sdk/vue@^3.0.0 and ai@^6.0.0. If you prefer not to use streaming, the sync query endpoint (POST /api/v1/query) works with any HTTP client. The @useatlas/sdk package provides a typed client for that endpoint.
1. Install dependencies
bun add @ai-sdk/vue ai2. Configure the API URL
Option A: Same-origin proxy (recommended)
Add a Nitro route rule in nuxt.config.ts to proxy /api/** to the Atlas API:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
routeRules: {
"/api/**": {
proxy: "http://localhost:3001/api/**",
},
},
},
runtimeConfig: {
public: {
atlasApiUrl: "", // empty = same-origin
},
},
});Option B: Cross-origin
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
atlasApiUrl: "http://localhost:3001",
},
},
});# Atlas API .env
ATLAS_CORS_ORIGIN=http://localhost:30003. Chat composable
Create a composable that wraps @ai-sdk/vue's useChat:
// composables/useAtlasChat.ts
import { useChat } from "@ai-sdk/vue";
import { DefaultChatTransport } from "ai";
import { ref, computed } from "vue";
export function useAtlasChat() {
const config = useRuntimeConfig();
const apiUrl = config.public.atlasApiUrl || "";
const isCrossOrigin = !!apiUrl;
const apiKey = useState<string>("atlas-api-key", () => "");
const conversationId = useState<string | null>("atlas-conv-id", () => null);
const input = ref("");
const transport = computed(() => {
const headers: Record<string, string> = {};
if (apiKey.value) {
headers["Authorization"] = `Bearer ${apiKey.value}`;
}
return new DefaultChatTransport({
api: `${apiUrl}/api/chat`,
headers,
credentials: isCrossOrigin ? "include" : undefined,
// Pass conversationId to continue an existing conversation
body: () =>
conversationId.value
? { conversationId: conversationId.value }
: {},
// Capture x-conversation-id from the response header
fetch: (async (fetchInput: RequestInfo | URL, init?: RequestInit) => {
const response = await globalThis.fetch(fetchInput, init);
const convId = response.headers.get("x-conversation-id");
if (convId && convId !== conversationId.value) {
conversationId.value = convId;
}
return response;
}) as typeof fetch,
});
});
// Pass transport.value — useChat expects a plain object, not a Vue ref.
// The transport uses getter-based headers internally, so it picks up
// apiKey changes without needing to recreate the transport instance.
const { messages, sendMessage, status, error } = useChat({
transport: transport.value,
});
function handleSend() {
if (!input.value.trim()) return;
const text = input.value;
input.value = "";
sendMessage({ text });
}
function newChat() {
conversationId.value = null;
// Clear messages by reloading — or manage via setMessages if available
}
return {
messages,
status,
error,
input,
apiKey,
apiUrl,
conversationId,
handleSend,
newChat,
};
}4. Chat page
Here is a minimal Vue page that uses the composable:
<!-- pages/index.vue -->
<script setup lang="ts">
import { isToolUIPart } from "ai";
const { messages, input, status, handleSend, error } = useAtlasChat();
const isLoading = computed(
() => status.value === "streaming" || status.value === "submitted"
);
</script>
<template>
<div class="mx-auto max-w-3xl p-4">
<h1 class="mb-4 text-xl font-bold">Atlas</h1>
<div class="space-y-4">
<div v-for="m in messages.value" :key="m.id">
<!-- User message -->
<div v-if="m.role === 'user'" class="flex justify-end">
<div class="rounded-lg bg-blue-600 px-4 py-2 text-white">
<template v-for="(part, i) in m.parts" :key="i">
<p v-if="part.type === 'text'">{{ part.text }}</p>
</template>
</div>
</div>
<!-- Assistant message -->
<div v-else class="space-y-2">
<template v-for="(part, i) in m.parts" :key="i">
<div
v-if="part.type === 'text' && part.text.trim()"
class="rounded-lg bg-zinc-100 px-4 py-2 text-sm"
>
{{ part.text }}
</div>
<AtlasToolPart v-else-if="isToolUIPart(part)" :part="part" />
</template>
</div>
</div>
</div>
<form @submit.prevent="handleSend" class="mt-4 flex gap-2">
<input
v-model="input"
placeholder="Ask a question about your data..."
class="flex-1 rounded border px-4 py-2 text-sm"
:disabled="isLoading"
/>
<button
type="submit"
:disabled="isLoading || !input.trim()"
class="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-40"
>
Ask
</button>
</form>
</div>
</template>5. Rendering tool calls
In AI SDK v6, tool parts use per-tool type names ("tool-explore", "tool-executeSQL") or "dynamic-tool" for dynamic tools. Use isToolUIPart(part) from "ai" to detect tool parts and access state, input, output directly on the part object.
Here is a basic Vue component for rendering tool calls:
<!-- components/AtlasToolPart.vue -->
<script setup lang="ts">
import { computed } from "vue";
import { getToolName } from "ai";
const props = defineProps<{ part: unknown }>();
const toolName = computed(() => {
try {
return getToolName(props.part as Parameters<typeof getToolName>[0]);
} catch {
return "unknown";
}
});
const p = computed(() => props.part as Record<string, unknown>);
const state = computed(() => (p.value.state as string) ?? "");
const input = computed(() => (p.value.input as Record<string, unknown>) ?? {});
const output = computed(() => p.value.output);
const done = computed(() => state.value === "output-available");
// SQL result helpers
const sqlResult = computed(() => output.value as Record<string, unknown> | null);
const columns = computed(() => (sqlResult.value?.columns as string[]) ?? []);
const rows = computed(() => (sqlResult.value?.rows as Record<string, unknown>[]) ?? []);
</script>
<template>
<!-- Loading state -->
<div
v-if="!done"
class="my-2 animate-pulse rounded-lg border px-3 py-2 text-xs text-zinc-500"
>
{{ toolName === "executeSQL" ? "Executing query..." : "Running command..." }}
</div>
<!-- SQL result -->
<div
v-else-if="toolName === 'executeSQL' && sqlResult"
class="my-2 overflow-hidden rounded-lg border bg-zinc-50"
>
<div class="flex items-center gap-2 px-3 py-2 text-xs">
<span class="rounded bg-blue-100 px-1.5 py-0.5 font-medium text-blue-700">SQL</span>
<span class="flex-1 truncate text-zinc-500">{{ input.explanation ?? "Query result" }}</span>
<span class="text-zinc-500">{{ rows.length }} row{{ rows.length !== 1 ? "s" : "" }}</span>
</div>
<div v-if="columns.length && rows.length" class="overflow-x-auto border-t">
<table class="w-full text-left text-xs">
<thead>
<tr class="border-b bg-zinc-100">
<th v-for="col in columns" :key="col" class="px-3 py-1.5 font-medium">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in rows" :key="i" class="border-b">
<td v-for="col in columns" :key="col" class="px-3 py-1.5 text-zinc-600">
{{ row[col] == null ? "\u2014" : row[col] }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Explore result -->
<div
v-else-if="toolName === 'explore'"
class="my-2 rounded-lg border bg-zinc-50 px-3 py-2"
>
<div class="text-xs font-mono text-zinc-500">
<span class="text-green-500">$</span> {{ input.command }}
</div>
<pre class="mt-1 max-h-40 overflow-auto whitespace-pre-wrap text-xs text-zinc-500">{{ output }}</pre>
</div>
<!-- Unknown tool -->
<div v-else class="my-2 rounded-lg border px-3 py-2 text-xs text-zinc-500">
Tool: {{ toolName }}
</div>
</template>The key data shapes:
executeSQLoutput --{ success, columns, rows, truncated }on success, or{ success: false, error }on failure. Userows.lengthto get the row count.exploreoutput -- a plain string (stdout of the command).state-- check for"output-available"to know when the result is ready. Other states:"input-streaming","input-available","output-error","output-denied".- Tool detection -- use
isToolUIPart(part)from"ai"(notpart.type === "tool-invocation").
See the Data Stream Protocol section for the full part structure and a concrete example.
6. Synchronous queries (alternative)
If you don't need streaming, use the JSON query endpoint instead:
// composables/useAtlasQuery.ts
export async function queryAtlas(question: string) {
const config = useRuntimeConfig();
const apiUrl = config.public.atlasApiUrl || "";
const res = await $fetch(`${apiUrl}/api/v1/query`, {
method: "POST",
body: { question },
headers: {
"Content-Type": "application/json",
},
});
return res;
}See Bring Your Own Frontend for the full architecture and what @atlas/web adds on top.