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 model | Qwen3-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.
|
| Embedder | fetched alongside it; used for semantic search |
| Where it runs | your user's browser (CPU; WebGPU where available) |
| Where files go | nowhere — parsed and indexed in-page |
| Second visit | served 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
- Next.js and other SSR frameworks work. The client renders a frozen, all-cold snapshot on the server and comes alive on hydration, so server and client markup match. Constructing it touches no browser globals.
-
One client per page. The module-level
clientPromiseabove is doing that job. Two clients means two engines and two model downloads. - Answers are grounded or absent. The agent answers only from the passages it retrieved; asked something the documents do not cover, it says so instead of inventing an answer. That rule is not removable.
-
Pinning. This page points at
https://speedydb.org/, which always serves the current engine. If you would rather upgrade deliberately than automatically, vendor the assets into your own origin and pointassetBasethere.