Atlas

SvelteKit

Integrate Atlas into a SvelteKit app using @ai-sdk/svelte.

Integrate Atlas into a SvelteKit app using @ai-sdk/svelte.

Prerequisites: A running Atlas API server. See Bring Your Own Frontend for architecture and common setup.

AI SDK v6 — Svelte adapter

As of AI SDK v6, @ai-sdk/svelte (v4+) supports the transport-based API (DefaultChatTransport, sendMessage) shown below. Make sure you install @ai-sdk/svelte@^4.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/svelte ai

2. Configure the API URL

In vite.config.ts, proxy /api to the Atlas API during development:

// vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [sveltekit()],
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:3001",
        changeOrigin: true,
      },
    },
  },
});

Option B: Cross-origin

# .env
PUBLIC_ATLAS_API_URL=http://localhost:3001
# Atlas API .env
ATLAS_CORS_ORIGIN=http://localhost:5173

3. Chat store

Create a reusable chat module using @ai-sdk/svelte's useChat:

// src/lib/atlas-chat.ts
import { useChat } from "@ai-sdk/svelte";
import { DefaultChatTransport } from "ai";
import { writable, get } from "svelte/store";
import { env } from "$env/dynamic/public";

const apiUrl = env.PUBLIC_ATLAS_API_URL ?? "";
const isCrossOrigin = !!apiUrl;

export function createAtlasChat(apiKey: () => string) {
  const conversationId = writable<string | null>(null);

  const transport = new DefaultChatTransport({
    api: `${apiUrl}/api/chat`,
    get headers() {
      const key = apiKey();
      const h: Record<string, string> = {};
      if (key) h["Authorization"] = `Bearer ${key}`;
      return h;
    },
    credentials: isCrossOrigin ? "include" : undefined,
    // Pass conversationId to continue an existing conversation
    body: () => {
      const convId = get(conversationId);
      return convId ? { conversationId: convId } : {};
    },
    // Capture x-conversation-id from the response header
    fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
      const response = await globalThis.fetch(input, init);
      const convId = response.headers.get("x-conversation-id");
      if (convId && convId !== get(conversationId)) {
        conversationId.set(convId);
      }
      return response;
    }) as typeof fetch,
  });

  const { messages, sendMessage, status, error } = useChat({ transport });

  return { messages, sendMessage, status, error, conversationId };
}

4. Chat page

Here is a minimal Svelte component that uses the chat store:

<!-- src/routes/+page.svelte -->
<script lang="ts">
  import { isToolUIPart } from "ai";
  import { createAtlasChat } from "$lib/atlas-chat";
  import ToolPart from "$lib/components/ToolPart.svelte";

  let apiKey = $state("");
  const { messages, sendMessage, status, conversationId } = createAtlasChat(
    () => apiKey
  );
  let input = $state("");

  const isLoading = $derived(
    $status === "streaming" || $status === "submitted"
  );

  function handleSubmit() {
    if (!input.trim()) return;
    const text = input;
    input = "";
    sendMessage({ text });
  }
</script>

<div class="mx-auto max-w-3xl p-4">
  <h1 class="mb-4 text-xl font-bold">Atlas</h1>

  <div class="space-y-4">
    {#each $messages as m (m.id)}
      {#if m.role === "user"}
        <div class="flex justify-end">
          <div class="rounded-lg bg-blue-600 px-4 py-2 text-white">
            {#each m.parts ?? [] as part, i}
              {#if part.type === "text"}
                <p>{part.text}</p>
              {/if}
            {/each}
          </div>
        </div>
      {:else}
        <div class="space-y-2">
          {#each m.parts ?? [] as part, i}
            {#if part.type === "text" && part.text.trim()}
              <div class="rounded-lg bg-zinc-100 px-4 py-2 text-sm">
                {part.text}
              </div>
            {:else if isToolUIPart(part)}
              <ToolPart {part} />
            {/if}
          {/each}
        </div>
      {/if}
    {/each}
  </div>

  <form on:submit|preventDefault={handleSubmit} class="mt-4 flex gap-2">
    <input
      bind:value={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>

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 getToolName(part) to extract the name.

Here is a basic Svelte component for rendering tool calls:

<!-- src/lib/components/ToolPart.svelte -->
<script lang="ts">
  import { getToolName } from "ai";

  const { part } = $props<{ part: unknown }>();

  let toolName = $derived.by(() => {
    try {
      return getToolName(part as Parameters<typeof getToolName>[0]);
    } catch {
      return "unknown";
    }
  });

  const p = $derived(part as Record<string, unknown>);
  const state = $derived((p.state as string) ?? "");
  const input = $derived((p.input as Record<string, unknown>) ?? {});
  const output = $derived(p.output);
  const done = $derived(state === "output-available");

  // SQL helpers
  const sqlResult = $derived(output as Record<string, unknown> | null);
  const columns = $derived((sqlResult?.columns as string[]) ?? []);
  const rows = $derived((sqlResult?.rows as Record<string, unknown>[]) ?? []);
</script>

{#if !done}
  <div class="my-2 animate-pulse rounded-lg border px-3 py-2 text-xs text-zinc-500">
    {toolName === "executeSQL" ? "Executing query..." : "Running command..."}
  </div>
{:else if toolName === "executeSQL" && sqlResult}
  <div 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>
    {#if columns.length && rows.length}
      <div class="overflow-x-auto border-t">
        <table class="w-full text-left text-xs">
          <thead>
            <tr class="border-b bg-zinc-100">
              {#each columns as col}
                <th class="px-3 py-1.5 font-medium">{col}</th>
              {/each}
            </tr>
          </thead>
          <tbody>
            {#each rows as row, i}
              <tr class="border-b">
                {#each columns as col}
                  <td class="px-3 py-1.5 text-zinc-600">
                    {row[col] == null ? "\u2014" : row[col]}
                  </td>
                {/each}
              </tr>
            {/each}
          </tbody>
        </table>
      </div>
    {/if}
  </div>
{:else if toolName === "explore"}
  <div 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>
{:else}
  <div class="my-2 rounded-lg border px-3 py-2 text-xs text-zinc-500">
    Tool: {toolName}
  </div>
{/if}

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. Conversation management

Atlas persists conversations server-side (requires DATABASE_URL). Here is a minimal Svelte module for listing, loading, and deleting conversations:

// src/lib/atlas-conversations.ts
import { writable } from "svelte/store";
import { env } from "$env/dynamic/public";

const apiUrl = env.PUBLIC_ATLAS_API_URL ?? "";

export interface Conversation {
  id: string;
  userId: string | null;
  title: string | null;
  surface: string;
  connectionId: string | null;
  starred: boolean;
  createdAt: string;
  updatedAt: string;
}

export interface Message {
  id: string;
  conversationId: string;
  role: "user" | "assistant" | "system" | "tool";
  content: unknown;
  createdAt: string;
}

export function createConversationStore(apiKey: () => string) {
  const conversations = writable<Conversation[]>([]);
  const loading = writable(false);

  function headers(): Record<string, string> {
    const key = apiKey();
    const h: Record<string, string> = {};
    if (key) h["Authorization"] = `Bearer ${key}`;
    return h;
  }

  async function list() {
    loading.set(true);
    try {
      const res = await fetch(`${apiUrl}/api/v1/conversations`, {
        headers: headers(),
      });
      if (!res.ok) return;
      const data = await res.json();
      conversations.set(data.conversations ?? []);
    } finally {
      loading.set(false);
    }
  }

  async function load(id: string): Promise<Message[]> {
    const res = await fetch(`${apiUrl}/api/v1/conversations/${id}`, {
      headers: headers(),
    });
    if (!res.ok) throw new Error("Failed to load conversation");
    const data = await res.json();
    return data.messages ?? [];
  }

  async function remove(id: string) {
    await fetch(`${apiUrl}/api/v1/conversations/${id}`, {
      method: "DELETE",
      headers: headers(),
    });
    conversations.update((prev) => prev.filter((c) => c.id !== id));
  }

  return { conversations, loading, list, load, remove };
}

To resume a conversation, pass the conversationId in the chat request body. The useChat composable from @ai-sdk/svelte does not manage this automatically, but the chat store in section 3 shows how to track the ID from the x-conversation-id response header and include it in subsequent requests via the transport's body option.

7. Synchronous queries (alternative)

If streaming is not needed, use the JSON query endpoint:

// src/lib/atlas-query.ts
import { env } from "$env/dynamic/public";

const apiUrl = env.PUBLIC_ATLAS_API_URL ?? "";

export async function queryAtlas(question: string, apiKey?: string) {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
  };
  if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;

  const res = await fetch(`${apiUrl}/api/v1/query`, {
    method: "POST",
    headers,
    body: JSON.stringify({ question }),
  });

  if (!res.ok) throw new Error(`Atlas query failed: ${res.status}`);
  return res.json();
}

See Bring Your Own Frontend for the full architecture and what @atlas/web adds on top.

On this page