Put the agent in your app

One React component, pasted into your project. No install, no build-step configuration, no API key, and no account. The engine, the embedder and the model are fetched from this domain at runtime and run entirely in your user's browser.

Your users' files never leave their machine. Documents are read, chunked, embedded and searched in the browser. Nothing is uploaded — not to you, not to us. That is what the free tier is: the whole agent, with no storage attached, because there is nothing to store.

01Paste the component

Save this as SpeedyDbChat.jsx. It is your file, so react resolves through your bundler as normal. Only SpeedyDb is fetched from this domain, and it is fetched at runtime — the webpackIgnore / @vite-ignore comments are what stop a bundler trying to resolve a URL at build time.

import { useEffect, useRef, useState, useSyncExternalStore } from "react";

const ASSET_BASE = "https://speedydb.org/";

// One client per page. Fetched at runtime, never bundled — the ignore comments
// keep Vite and webpack from resolving a URL at build time.
let clientPromise = null;
function getClient() {
  if (!clientPromise) {
    clientPromise = import(
      /* webpackIgnore: true */ /* @vite-ignore */ ASSET_BASE + "speedydb-client.js"
    ).then(({ SpeedyDbClient }) => new SpeedyDbClient({ assetBase: ASSET_BASE }));
  }
  return clientPromise;
}

export default function SpeedyDbChat() {
  const [client, setClient] = useState(null);
  useEffect(() => {
    let live = true;
    getClient().then((c) => live && setClient(c));
    return () => { live = false; };
  }, []);

  if (!client) return <p>Loading SpeedyDb…</p>;
  return <Chat client={client} />;
}

function Chat({ client }) {
  // The client is an external store built for exactly this hook: no useState
  // mirrors, no effect-driven copies, no tearing.
  const snap = useSyncExternalStore(
    client.subscribe,
    client.getSnapshot,
    client.getServerSnapshot,
  );
  const inputRef = useRef(null);
  const { agent, agentResult } = snap;

  async function onFiles(e) {
    for (const file of e.target.files) await client.ingestFile(file);
    e.target.value = "";
  }

  function onAsk(e) {
    e.preventDefault();
    const q = inputRef.current.value.trim();
    if (q) client.ask(q);
  }

  return (
    <div>
      <input type="file" multiple onChange={onFiles} />

      {agent.state === "loading" && (
        <p>Downloading the model… {agent.pct}%</p>
      )}

      <form onSubmit={onAsk}>
        <input ref={inputRef} placeholder="Ask about your documents" />
        <button disabled={agent.busy}>Ask</button>
      </form>

      {agentResult.answer && (
        <div>
          <p>{agentResult.answer}</p>
          {agentResult.lowConfidence && (
            <small>Low confidence — the passages may not answer this.</small>
          )}
          <ul>
            {agentResult.passages.map((p, i) => (
              <li key={i}>{p.text}</li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

02Use it

import SpeedyDbChat from "./SpeedyDbChat";

export default function App() {
  return <SpeedyDbChat />;
}

That is the whole integration. Drop a file in, ask a question, get an answer grounded in that file with the passages it used.

What happens on first load

Be deliberate about this rather than surprised by it — the model is a real download, and hiding that from your users is the wrong call.

Answer modelQwen3-0.6B, 4-bit GGUF — ~397 MB, downloaded once and cached by the browser
How long that takes about 30 seconds on broadband, a few minutes on mobile. Once, then cached — but it is still the first thing a new visitor waits for, so consider prefetchAgent() to start it deliberately rather than on page load.
Embedderfetched alongside it; used for semantic search
Where it runsyour user's browser (CPU; WebGPU where available)
Where files gonowhere — parsed and indexed in-page
Second visitserved from cache, no re-download

snapshot.agent.pct is the download progress and snapshot.agent.state the lifecycle, both already wired in the component above. If you would rather start the download before the first question — on a settings page, say, or behind a "prepare offline mode" button — call client.prefetchAgent() and it will be warm by the time anyone types.

The chat template

The component above is deliberately unstyled so it drops into any design system. This is the same thing with conversation history and streaming, if you want a chat surface rather than a search box.

import { useEffect, useRef, useState, useSyncExternalStore } from "react";

const ASSET_BASE = "https://speedydb.org/";

let clientPromise = null;
function getClient() {
  if (!clientPromise) {
    clientPromise = import(
      /* webpackIgnore: true */ /* @vite-ignore */ ASSET_BASE + "speedydb-client.js"
    ).then(({ SpeedyDbClient }) => new SpeedyDbClient({ assetBase: ASSET_BASE }));
  }
  return clientPromise;
}

export default function SpeedyDbChatPanel() {
  const [client, setClient] = useState(null);
  useEffect(() => {
    let live = true;
    getClient().then((c) => live && setClient(c));
    return () => { live = false; };
  }, []);
  if (!client) return null;
  return <Panel client={client} />;
}

function Panel({ client }) {
  const snap = useSyncExternalStore(
    client.subscribe, client.getSnapshot, client.getServerSnapshot);
  const [turns, setTurns] = useState([]);
  const inputRef = useRef(null);
  const { agent, agentResult } = snap;

  // Commit each finished answer to history. `streaming` goes false when the
  // agent is done, so this fires once per question rather than per token.
  const settled = !agentResult.streaming && agentResult.answer;
  const lastRef = useRef("");
  useEffect(() => {
    if (settled && agentResult.answer !== lastRef.current) {
      lastRef.current = agentResult.answer;
      setTurns((t) => [...t, { role: "agent", text: agentResult.answer }]);
    }
  }, [settled, agentResult.answer]);

  function send(e) {
    e.preventDefault();
    const q = inputRef.current.value.trim();
    if (!q) return;
    setTurns((t) => [...t, { role: "user", text: q }]);
    client.ask(q);
    inputRef.current.value = "";
  }

  return (
    <div className="sdb-chat">
      <div className="sdb-chat__log">
        {turns.map((t, i) => (
          <div key={i} className={"sdb-chat__turn is-" + t.role}>{t.text}</div>
        ))}
        {agentResult.streaming && (
          <div className="sdb-chat__turn is-agent is-streaming">
            {agentResult.answer}
          </div>
        )}
      </div>

      <label className="sdb-chat__drop">
        Add documents
        <input
          type="file"
          multiple
          hidden
          onChange={async (e) => {
            for (const f of e.target.files) await client.ingestFile(f);
            e.target.value = "";
          }}
        />
      </label>

      {agent.state === "loading" && <progress value={agent.pct} max="100" />}

      <form onSubmit={send}>
        <input ref={inputRef} placeholder="Ask a question" />
        <button disabled={agent.busy}>Send</button>
      </form>
    </div>
  );
}

Using a config you tuned here

If you have tuned a pipeline on the RAG page and saved a version, pass it straight to the constructor. The saved document is portable by design — same shape in the browser, in the tuner, and in your app.

import saved from "./my-speedydb-config.json";

new SpeedyDbClient({ assetBase: ASSET_BASE, ragConfig: saved });

Notes worth reading once