Atlas

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 ai

2. Configure the API URL

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:3000

3. 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:

  • executeSQL output -- { success, columns, rows, truncated } on success, or { success: false, error } on failure. Use rows.length to get the row count.
  • explore output -- 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" (not part.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.

On this page