Search and chat on a website
The same retrieval behind an internal assistant can sit on a public site: a search box that understands a question, or a chat widget that answers from your published pages. This page is the client side of that.
The chatbot page covers the agent itself. Here the question is where the code runs and what the browser is allowed to hold.
The one rule: the key stays on the server
The client speaks to RaisinDB with credentials. A browser cannot hold a key that can read your whole repository — anyone can open dev tools.
So the retrieval call runs on the server: an Astro endpoint, a route
handler, an edge function. The browser talks to your route, your route talks to
RaisinDB. The same shape you already use for server-side reads, and the key
stays in the environment where maravilla dev and your deploy put it.
// src/pages/api/ask.ts — runs on the server
import { RaisinClient } from '@raisindb/client';
const client = new RaisinClient(import.meta.env.RAISINDB_URL);
await client.connect();
await client.authenticate({ apiKey: import.meta.env.RAISINDB_API_KEY });
const db = client.database('my-repo');
export async function POST({ request }) {
const { question } = await request.json();
const { answer, citations, grounded } = await db.ask(question, {
workspaces: 'stories',
});
return new Response(JSON.stringify({ answer, citations, grounded }), {
headers: { 'content-type': 'application/json' },
});
}
workspaces is required on both ask and search, and on a public page it is
the argument to think hardest about. It takes one name, a comma-separated list
('stories, handbook'), a glob ('content-*'), or 'ALL READABLE' — the full
grammar is in
Choosing the workspaces.
Row-level security still decides what any caller may read, so an unscoped bot is not leaking what a visitor could not fetch themselves. The risk is narrower and easier to miss: it answers from a corpus nobody chose. Whatever identity your route authenticates as can typically read more than your public pages, and the first question that happens to match an internal document gets it paraphrased back with a citation.
Name the workspace. On a site that publishes to a separate branch, point the client at the published branch too, so drafts cannot be quoted back to a visitor.
Site search
For a search box you want passages, not an essay — and no model call at all:
const passages = await db.search(query, { workspaces: 'stories' });
passages[0].text; // the passage that matched
passages[0].path; // the page it came from
passages[0].chunkIndex; // where in that page
The visitor’s text is bound as a parameter, not pasted into a query, so a search box cannot become SQL.
Render text as the result snippet. It is the passage that actually matched —
which is why these snippets read like an answer rather than the first two lines
of a page.
Several workspaces in one call is a comma-separated list:
await db.search(query, { workspaces: 'stories, handbook' });
A chat widget
For a conversation rather than one question, talk to an agent. The client has a conversation API with streaming built in:
const convo = await db.conversations.create({
participant: '/agents/research-assistant',
});
for await (const event of db.conversations.sendMessage(
convo.conversationPath,
'What is your refund window?',
{ stream: true },
)) {
if (event.type === 'text_chunk') {
process.stdout.write(event.text); // or append to the DOM
}
}
chat(participant, message) is the one-shot form when you do not need
streaming, and returns the finished text.
Stream if you can. Retrieval plus a model answer takes a few seconds, and a widget that prints nothing for four seconds reads as broken; one that starts typing immediately reads as fast, for the same total time.
A conversation is ordinary content — a node with its messages beneath it. That means history survives a page reload, you can read what your assistant told people, and a member of staff can pick up a thread in the Inbox. It also means conversations accumulate: decide early whether a public widget keeps them or sweeps them.
Showing the sources
ask returns citations, each with a path and a chunkIndex. Render them
as links to the page they came from.
Do it even when the design would rather not. An answer a visitor can check is a different product from one they have to trust, and the citation is what turns a chatbot on your marketing site from a liability into a shortcut to the right page.
When grounded is false, nothing relevant was found and the model was never
asked. Show the “I could not find that” answer as-is and offer a human — do not
fall back to a model without retrieval, which is exactly the path that invents a
refund policy you do not have.
When to reach for invoke instead
db.search and db.ask are methods because retrieval is a capability of the
database: they are typed, they own the argument and result shapes, and how they
are served can change without touching your code.
db.functions().invoke('my-function', ...) is the escape hatch, and the right
call for functions you wrote. Using it for ask would mean naming a
server-side function in a string, rebuilding its arguments by hand, and being
unable to move it later — for nothing gained.
Next
- Build a chatbot on your content — the agent, its tools and its prompt.
- Rendering a site — how server-side reads are wired on a Studio site.
Pilot documentation. Studio is in a partner pilot. Screens and YAML shown here are current as of the version noted on each page; tell your Maravilla contact when something does not match what you see.