A self-hostable HTTP service for deep research and Stanford STORM, with an Open WebUI client.
STORM turns a topic into a Wikipedia-style article with citations by simulating multi-perspective research conversations.
Alongside it, kvasir runs its own deep research pipeline, built for a small local model: it answers a question with a report whose every citation points to a quote read on the page. See Research a question.
Upstream ships a Python library and a Streamlit demo labelled for local development, but no server
and no container image. This repository supplies the missing middle: a small HTTP service, a
linux/amd64 image on GHCR, and an Open WebUI Pipe function. The service is one Rust binary.
STORM is translated into Rust in crates/kvasir-storm, keeping upstream's
prompts and algorithm. See docs/storm.md for what it must keep doing and where it
differs from upstream.
How the service fits together, from a request to its result, is in
docs/architecture.md.
The main client is the desktop app, which runs the service in its own process.
The runs page the service serves at / is deprecated: it gets no new features and does not
follow the desktop app.
- At least one OpenAI-compatible endpoint, such as a local Ollama or a hosted provider. Each is a
provider, and all model and embedding traffic goes to the providers you configure. Nothing calls
api.openai.comunless you configure it. - At least one search provider that searches the whole web: a SearXNG instance, an API key, or a scraped engine.
- A writable data directory, for
kvasir.dband the caches, encrypted underKVASIR_SECRET_KEY.
No paid search credential is needed, and no database, cache or queue.
Run kvasir from a checkout, against a local Ollama and a throwaway SearXNG.
This needs just, rustup, which installs the compiler
rust-toolchain.toml pins on first use, Node 26, pnpm, uv, Ollama, and
podman or docker for SearXNG.
-
Pull the models the quickstart stores:
ollama pull gpt-oss:20b ollama pull nomic-embed-text
The example's strong model,
deepseek-v4.1-flash:cloud, runs on Ollama's servers. It needsollama signin, and no key in kvasir. To stay local, pointmodel.stronginsettings.dev.jsonat a model you pulled, after the next step.Ollama loads a model with a 4096-token context by default, which is too small for the article and polish prompts. Start it with a larger one, for example
OLLAMA_CONTEXT_LENGTH=16384 ollama serve, or set that variable in its service unit. A larger context takes more memory, so more of the model may run on the CPU. -
Create the environment and install the dependencies:
just setup just doctor
just setupcopiesenv.exampleto.envwith a generated token and key, copiessettings.dev.example.jsontosettings.dev.json, and installs what the page and the tests need. Run again, it keeps both files.just doctorprintstools ok, and names anything missing. It also names a service not answering, such as SearXNG, which the next step starts.Keep
.env: the data directory opens only with theKVASIR_SECRET_KEYit was encrypted under. -
Start everything:
just dev
This starts SearXNG in the container
kvasir-searxng, builds and starts kvasir, storessettings.dev.jsonif nothing is configured yet, starts the page, and prints the page's address and the token. kvasir creates the data directory,data/. Ctrl-C stops kvasir and the page. SearXNG keeps running, andjust searxng-stopremoves it. -
Open http://localhost:5173 and log in with the token. Change a setting under Settings > Service, or edit
settings.dev.jsonand runjust seedwhile kvasir runs. To use another search provider, see Search providers.just devstores the file only on a first start, so what you change on the page stays. -
From another shell, check both dependencies are reachable, and set the token the examples below send:
$ curl -s localhost:8080/readyz {"providers":{"local":true},"search":{"SearXNG":{"state":"ok"}}} $ export KVASIR_TOKEN="$(just token)"
just reset deletes the data directory after asking, with every run and setting in it, and cannot be
undone. The next just dev is a first start again.
Then write an article. On one local model a run takes much longer than through a hosted gateway.
kvasir binds 0.0.0.0:8080 unless KVASIR_HOST and KVASIR_PORT say otherwise, so on a laptop it is reachable from the local network. The .env from env.example sets 127.0.0.1. The token
guards every route but /healthz, /readyz, POST /v1/login and the page itself, and nothing
limits how often it may be guessed: use a long random one.
Behind a proxy that ends TLS, set FORWARDED_ALLOW_IPS to the proxy's address, several separated
by commas, or *. kvasir then believes the X-Forwarded-Proto those addresses send, and marks the
login cookie Secure. Unset, only 127.0.0.1 is believed. Without a believed https, the cookie is
not marked Secure, and a browser sends it over plain HTTP too.
podman run --rm \
--read-only --user 65532:65532 \
-p 8080:8080 -v kvasir-data:/data \
-e KVASIR_PROVIDER_GATEWAY_API_BASE=https://gateway.example/v1 \
-e KVASIR_PROVIDER_GATEWAY_API_KEY=... \
-e KVASIR_TOKEN=... \
-e KVASIR_SECRET_KEY=... \
ghcr.io/brewcoua/kvasir:0.1.0Verify it started:
$ curl -s localhost:8080/healthz
{"status":"ok"}
$ curl -s localhost:8080/readyz
{"providers":{"local":true},"search":{"SearXNG":{"state":"ok"}}}/readyz returns 503 if a provider a model refers to is unreachable, or if no search provider can
serve, and says which. It probes only the providers a model refers to, and SearXNG if enabled. Each search
provider's state is ok, cooling_down with until in seconds since the epoch, unauthorized or
unreachable. One cooling down still counts as able to serve. On a first start it answers 503 with the settings still to
store, listed as missing: store them as in Store a setting. A
missing or unusable environment variable makes the container exit at startup, rather than starting
and failing on the first request.
Pin the digest in production. ghcr.io/brewcoua/kvasir:0.1.0@sha256:... is the intended form; the
publishing workflow writes the digest to its run summary. There is deliberately no moving latest.
Open localhost:8080/ for the runs page: what is running now, which stage it is in, how long each
stage took, and what it has spent.
The default image reads pages over plain HTTP. Some pages are empty until their scripts run, or
show a challenge first. To read those, build the browser target, which adds Chromium:
podman build --target browser -t kvasir:browser .Run it as above, with --tmpfs /tmp --init --ipc=host added: Chromium keeps its profile under
/tmp, and otherwise runs out of shared memory. With fetch.render set to auto, the default, a
page that comes back as a challenge, a block, an empty shell, a 403 or a 503 is then loaded in
Chromium. Outside the image, build with cargo build --release --features chromium. kvasir finds
Chromium at the path in CHROME, or under its usual names on the PATH.
The image runs as uid 65532 under a read-only root filesystem. Only /data needs to be writable,
and /tmp too in the browser target, for Chromium's profile and crash reports.
Every run starts at POST /v1/runs. The body names a method, the input and the method's options:
{"method": "deep", "input": {"question": "..."}, "options": {"research_minutes": 10}}| Method | Produces |
|---|---|
deep |
A cited report answering a question, or a ranking or a table comparing options. |
storm |
A Wikipedia-style article on the topic given as input.question. |
A client sends the token as Authorization: Bearer <token>, or gets 401. The runs page posts it
to POST /v1/login instead, which sets it as an HttpOnly cookie that every route accepts too.
GET /v1/methods lists each method with its stages, whether it asks clarifying questions, and its
options as a JSON Schema whose defaults are the configured ones. An option the method does not take
is refused with 422, before any run starts.
The deep method answers a question with a cited report. It splits the brief into first questions
and explores them best first for research_minutes (20 by default): a question it answers raises
one harder follow-up, and one it only partly answers is searched for again with a narrower query
and raises child questions for what is missing. It reads as much of each page in one call as
model.context_tokens holds, keeps only claims whose quote appears word for word on the page, and writes the
report one section at a time from those claims. Each sentence that cites a claim is then checked
against the claim's quote by a cascade of cheaper models first: it is taken out if the quote does
not support it, rewritten to what the quote says if it supports it in part, and marked
[unverified] if no model could judge it. In a ranking or a table, each cell is checked the same
way. Up to 4 removed
sentences are searched for again, and a section whose search finds support is written and checked
once more. See docs/research.md for how each step works and why.
-
Optionally, draft the brief first. This is one fast-model call, like
/v1/tasks: it registers no run and takes no slot.curl -s -X POST localhost:8080/v1/drafts \ -H "Authorization: Bearer $KVASIR_TOKEN" \ -H 'content-type: application/json' \ -d '{"question": "Which open-weight LLMs are best for local coding?"}'
The answer holds:
id: the draft, held by the service for an hour. It is not a run and never shows in the list.questions: at most three, each with thechoicesit expects. Empty when the question is clear.brief: the brief as one sentence, in parts. A part withchoicesis a slot: itstextis the value, and the choices are other values it could take.assumptions: what the draft decided on your behalf, each with itsvalue, otherchoices, andstatedwhen the question itself says it.marks: words of the question read as things to compare (compare) or criteria (criterion).output:report,rankingortable.
POST /v1/clarifytakes the same body and answers only the questions, as{"questions": [...]}, for a client that asks them as text. -
Start the run. To start from the draft, send it back as
input.draft, with the brief's sentence as you confirmed it, each assumption asname: value, and your answers. The run then skips writing its own brief:curl -N -X POST localhost:8080/v1/runs \ -H "Authorization: Bearer $KVASIR_TOKEN" \ -H 'content-type: application/json' \ -d '{"method": "deep", "input": {"question": "Which open-weight LLMs are best for local coding?", "draft": {"id": "<the draft id>", "brief": "Rank open-weight LLMs for coding on one 24 GB GPU.", "assumptions": ["Period: as of today"], "answers": [{"question": "What hardware?", "answer": "One 24 GB GPU"}]}}}'
A draft that has expired is refused with 422. Without a draft, send any answers as
clarifications, and the run writes its brief itself:curl -N -X POST localhost:8080/v1/runs \ -H "Authorization: Bearer $KVASIR_TOKEN" \ -H 'content-type: application/json' \ -d '{"method": "deep", "input": {"question": "Which open-weight LLMs are best for local coding?", "clarifications": [{"question": "What hardware?", "answer": "One 24 GB GPU"}]}}'
It streams like an article run. Stages are
brief,background,research,assess,writeandverify;backgroundandassessrun only for a ranking or a table. Thedoneframe carriesreport(markdown, with[n]markers, opening with an Abstract callout and an opening paragraph and, from 3 sections, a contents callout),assumptions(what the brief decided on your behalf),citations,claims(every claim gathered, each with its quote and source),questions(each question searched for, with its status, queries and supporting claims),removed(each sentence verification took out, with its section, the reason and thestagethat removed it),revised(each sentence rewritten to what its quotes say, with the sentence before and after),unverified(each sentence left in the report marked[unverified], with its section and the reason),output,criteria,matrixandduration_seconds. A claim read from a search snippet, because its page could not be fetched, hasfrom_snippetset.
Options: output, criteria, entities, research_minutes, max_searches (an optional cap on
top of the time), results_per_query, max_attempts, max_depth,
max_entities, max_criteria, ground, ground_intake, corroborate, contradict,
recall_past_runs, max_age_hours, model_fast, model_strong, model_reason, lists_on,
lists_off. Each budget left out takes its [research] default from
Configuration.
A question gets max_attempts queries, each a different shape: plain words, the exact terms its
answer would contain, a primary source (site: or filetype:pdf), then the broader topic. Each
judgement reads only the question's path from its first question and the claims found for it. A
branch, a first question and every question raised under it, closes after 2 searches in a row add
nothing. When no question is left with time to spare, a call asks what the report still lacks and
adds new first questions, until those stop finding at least 2 claims a search. For a ranking or a
table, exploring stops at the start of the last quarter of research_minutes, and the rest goes to
searching for the table's empty cells. An outline call then groups the questions into at most 7
sections, with subsections where a section has distinct parts. The report ends with an "Open
questions" section listing what the research did not settle. A run that finds no claim at all says
so in report and writes no sections. See
docs/research.md.
Two options of deep set, for one run, what the page cache gives.
| Option | Default | Effect |
|---|---|---|
recall_past_runs |
true |
false: recall reads only the pages this run read. The cache still serves downloads, and the run's pieces are still kept for later runs. |
max_age_hours |
fetch.max_age_hours |
Hours a cached page is served without asking its site. 0 downloads every page again and replaces its cached copy. |
Four options add checks past the quote check, each at a cost in calls.
| Option | Default | Effect |
|---|---|---|
ground |
on when model.checker is set |
The checker checks each cited sentence first, shown the claims it cites and their quotes together, and keeps one it supports. The fast model sees only the rest. Without a checker it does nothing. |
ground_intake |
false |
The checker also drops each claim the page piece it was read from does not support. Off by default: unless the checker stays loaded beside the fast model, each search then swaps the two twice. |
corroborate |
false |
Each claim is compared with up to 3 close claims from other sites. Those that support it go in its corroborated_by. |
contradict |
false |
Close claims from other sites that disagree with it go in its contradicted_by, both ways. |
To use MiniCheck on Ollama, pull it, then point the checker at it through the local provider:
ollama pull bespoke-minicheckThen set model.checker to local/bespoke-minicheck, as in Store a
setting.
Bespoke-MiniCheck-7B is licensed CC BY-NC 4.0. Whether Ollama keeps it loaded next to the main
model, or swaps the two on each call, depends on its memory; a swap on every check makes a run far
slower. With ground alone, the checker runs only once the report is written, so it is loaded once.
Ask for it in the question: "Rank the best phones of 2026 on price, performance and privacy". The
brief reads the output type (ranking, table or report), the criteria and the options from the
question. Criteria and options it names are used as given. What it leaves open the run chooses from
a first search: at most max_entities options, and up to max_criteria criteria weighted 1 to 3
by importance. Named
criteria weigh 1 each. The options output, criteria and entities override the question,
for clients that already have them as data.
curl -N -X POST localhost:8080/v1/runs \
-H "Authorization: Bearer $KVASIR_TOKEN" \
-H 'content-type: application/json' \
-d '{"method": "deep", "input": {"question": "Rank the open-weight coding models that fit one 24 GB GPU on coding benchmark score, VRAM at 4-bit and licence"}}'The run then assesses each option on each criterion from the claims closest to that pair, keeping
only claims that name the option. Each cell with no such claim becomes a question of its own,
searched for in the last quarter of research_minutes, at most 8 searches, and assessed again when
a search adds claims. A ranking ranks the options with a finding on each criterion twice, the second time in
reverse order, since models favour what they read first. Options with no finding tie for last place
on that criterion. The overall order is the weighted mean of those places. An option the run chooses
must appear in its first search's results.
The report opens with a table of findings, each cell cited. matrix holds the same rows: per option
its overall rank, and per criterion its finding, claims (claim ids, as in claims) and
rank. A table has no ranks. The dashboard shows a ranking as places coloured from best to worst
in each column, with the finding and its sources on hover.
curl -N -X POST localhost:8080/v1/runs \
-H "Authorization: Bearer $KVASIR_TOKEN" \
-H 'content-type: application/json' \
-d '{"method": "storm", "input": {"question": "The Antikythera mechanism"}}'The response is text/event-stream:
event: run
data: {"run_id": "0f3c9a1b7e42"}
event: progress
data: {"stage": "research", "type": "turn", "at": 1793475466.3, "done": 3, "total": 12, "seq": 7, "series": "turns"}
event: done
data: {"article": "...", "outline": "...", "citations": [...], "duration_seconds": 412}
Stages are research, outline, article and polish. A run takes minutes to tens of minutes,
so use a client that streams, and set a generous timeout.
type says what happened, and the type's own fields say the rest: a section event has its
title, a search event the question's text. The Activity enum in
crates/kvasir-types/src/runs.rs lists every type, and kvasir openapi prints the schema. An event
stored before events had types reads as {"type": "note", "text": ...}. at is the Unix time of the event. done and total count progress through the stage when it has
a countable unit, and are null otherwise: sections in article, conversation turns in research.
The research total is an upper bound, since a conversation can end early. seq numbers the run's
events from 0, and a model call's event names the event it followed. A counter event, such as the sentences checked, is sent again with the same seq and its original at as it advances; updated_at is the time of its latest update.
Every streaming response opens with a run frame. Use its run_id to follow the run at
/v1/runs/{id} if the client stops reading, and to attribute cost afterwards. A client that does
not know the event name ignores it.
Options: search_top_k, max_conv_turn, max_perspective, do_polish_article, model_fast,
model_strong, lists_on, lists_off. Each falls back to the configured default.
The runs page is deprecated in favour of the desktop app. It still works, but gets no new features and does not follow the desktop app.
GET / serves the runs page. New, Runs and Knowledge in its header lead to its views, and each view has its
own address, so a reload or a shared link keeps it.
-
New (
#/) starts a run. Enter the question or topic, pick the method from the badge at the bottom right of the input, and press Enter or the arrow. For research, name the options and criteria in the question itself, as in "rank the Pixel 9a and the Fairphone 5 on price and updates"; the brief reads them from there. To fix the shape of the answer, open the options at the bottom left and pick report, ranking or table. It shows as a token in the input, and removing the token leaves the shape to the question. Deep research runs draft their brief before anything is searched. The page first asks the draft's questions one at a time: a choice's number picks it, "Something else…" takes your own answer, and Esc leaves the question to the brief. Then it shows the brief as one sentence. Click an underlined part to pick another value or type your own, and change what the brief assumes in the rows below it. Start, or Enter, starts the run from that brief. Meanwhile the question in the input underlines what the draft read in it: solid for things to compare, dotted for criteria. Click the question to change it, which drops the draft.The Plan tab on the right edge opens the plan: the stages with the budget the settings give each, the most the run can search, how long finished runs of the method took, and the models. A change made there is kept as in the settings. Below the input, the method's recent runs are listed. The page opens on the method last picked, and
/from any page comes back to it with the input focused. -
Reports (
#/reports) lists every run kept, as a list beside runs per day and why runs failed, or as a wall of cards. Each shows the report's first sentence, its source and claim counts, and the lead image of its most cited page that has one, else its citations per source as a figure. Hovering the image names the page and its site. Filter by state, method, or words of the topic or run id. -
A report (
#/reports/<id>) has a tab per view in the page header, each with its own address, such as#/reports/<id>/calls. Older#/runs/...links open the same page.- Report is where the run opens. Beside the text, a rail lists the outline and the sources with
their favicons. Each
[n]is a badge showing only the number; hovering or focusing one shows the source's title, URL and quote. While the run writes, the planned sections show faded, the one being written is marked, and each fills in once written. - Process shows the stages as a band, the current one filling with its progress, then one of two
views. Notebook lists the events newest first under a header per stage, beside the run's
questions; filter it by kind, or pick a question to narrow it to that question's events until
you remove its chip. Picking an event or a question opens it in place of the questions, with
the model calls an event made; Esc closes it. Follow live keeps the newest event in view until
you scroll away. Flow lays the same steps out as cards, each question with its searches and
its claims as dots, ringed where another question's claim agrees or disputes. A question card
counts those as "agrees with N" and "disputes N". The links between claims, and from each
section to its question, show only while you hover a card they touch; the cards it does not
touch fade. Older
overview,stepsandgraphlinks open Notebook and Flow. - Searches lists every query sent to the search providers, newest last, with the question it
worked on, its results, the pages it read, the claims it added and how long it took. Filter by
text, stage or question, and sort by time, results or new claims. Opening one shows its engine,
why it failed if it did, and its results, when
runs.keep_call_contentkept them. Beside the list are the totals, the engines with their failures and, for a run whose searches say what they added, a chart of new claims per search. - Usage shows the totals for tokens, embedding tokens, model calls, searches and cost, then every
model call over the run on one lane per model, the tokens by stage, and the usage by model and
by role with the time spent in calls, output tokens per second and each one's share of tokens.
Below them, the model calls, newest first, in a box that scrolls under its heading. Opening
one shows what it sent and what it answered, when
runs.keep_call_contentkept it. An event's "calls" link opens Usage on the calls it made. Oldercallslinks open Usage.
- Report is where the run opens. Beside the text, a rail lists the outline and the sources with
their favicons. Each
-
Knowledge (
#/knowledge) draws the page cache,pages.db, as a graph of sites joined by the links in their pages' content; links in a nav, header or footer do not count, and a site no content link joins is left out. Size by pages or by connected sites, filter by Trust and Kind, and find a site by name. Hovering a site lights it and the sites it links with. Pick a site to open its drawer: its kind and trust, how it was classified, the sites it links to and from, and its pages. Pick a page (#/knowledge/<id>) to see the text a run reads from it, its links by region, and the stored runs that cite it. Fetch again reads it whatever its age. Delete, on a page or a site, removes it with its pieces, and cannot be undone. Test recall takes a question, lists the cached pieces a run would recall for it with their similarity, and rings the sites of those that clearRECALL_MIN. Withsources.enabledset totrue, a site, a citation and a search result show a badge with its trust (Trusted, Standard, Unrated, Doubtful or Blocked), its kind and tags in the badge's title, linked to its Wikidata item when it has one. The drawer adds the kind, why the site has its trust, and its tags as reasons, notes and marks, and in the graph a site is filled in its trust's colour. Mark, in a site's drawer, setsallow,denyor a kind, or clears the mark; a site the config sets shows Locked. With sources off, none of this shows. What the cache takes on disk, and Flush cache, are in Settings, Storage.
Figures update live while the run is in progress. Settings, Appearance offers the themes
system, light, dark, paper, riso, amber, moss and navy, and the choice is kept in
the browser. The page loads nothing from another origin, since the image is read-only and offline.
Its source is in clients/web/; see Work on the runs page.
The same data is available as JSON.
| Request | Effect |
|---|---|
GET /v1/runs |
Every run kept, newest first. has_result says whether its detail carries a result, and digest holds what the reports index shows of it: the first sentence, the source and claim counts, and how often each source is cited, most cited first. Runs stored before digest existed have none. project, archived_at and trashed_at say where the run is filed. A run in the trash is left out; ?trashed=true lists only those. |
PATCH /v1/runs/{id} |
File a run: {"project": "<id>" | null, "archived": true | false, "trashed": true | false}, each field optional. A run archived or in a project is kept past runs.ttl_days and runs.max; one in the trash is deleted runs.trash_days later. 204. A run still going is ended first, failed with the error stopped. 404 for an unknown run or project; 409 when runs.store is not sqlite. |
DELETE /v1/runs/{id} |
Delete a run for good, with what it kept, ending it first if it is going. 204; 404 for an unknown run. |
GET /v1/projects |
Every project by name: id, name, created_at and runs, its runs out of the trash. 409 when runs.store is not sqlite, as for the routes below. |
POST /v1/projects, PATCH /v1/projects/{id} |
Add a project, or rename one, with {"name": "..."}. 201 or 200 with the project; 422 for a blank name; 404 for an unknown project. |
DELETE /v1/projects/{id} |
Delete a project. Its runs stay, in no project, so retention applies to them again. 204; 404 for an unknown project. |
GET /v1/runs/{id} |
One run, with per-stage timings, usage, its last 2000 model calls, its last 1000 searches, its events (the last 1000 steps, 2000 pages and parts, and 500 log lines; a counter is one event), the questions a graph run has asked so far, the report's sections as they are written (draft), and its result. |
GET /v1/runs/{id}/calls/{n} |
What call n sent and answered: messages, response and reasoning. 404 unless runs.keep_call_content kept it. |
GET /v1/runs/{id}/searches/{n} |
What search n returned: results, each with title, url and snippet, before the run drops what it already read. With sources on, each result has a source. 404 unless runs.keep_call_content kept it. |
GET /v1/runs/{id}/lead |
The picture the report leads with: url, title and site of its most cited page whose og:image or twitter:image was kept at fetch, and image, where the service serves it. 404 when no cited page has one. |
POST /v1/runs/{id}/decide |
Answer a run stuck on a step, which stuck in its summary shows: {"decision": "retry"} calls the step again, "skip" takes its fallback, and "stop" takes the fallback and searches no more, so the run writes from what it has. 204. 409 when the run waits on no step, or on a step with no fallback for skip; 404 when no running run has the id. |
POST /v1/runs/{id}/pause |
Hold a running run before its next model call or search; the call or search under way finishes. paused and paused_since in its summary show it, and paused_seconds adds up time paused, which the research time limit leaves out. 204; 404 when no running run has the id. |
POST /v1/runs/{id}/continue |
Go on with a run a restart cut off, which resumable in its summary shows. It runs again from the start under the same id, and each model call and search it already made is answered from its journal. Streams as POST /v1/runs does. 409 when the run is not resumable or is already running; 429 when every slot is busy. |
POST /v1/runs/{id}/resume |
Let a paused run go on. 204; 404 when no running run has the id. |
POST /v1/runs/{id}/stop |
Ask a running run to search no more and write from what it has, as "stop" to decide does, answering any step it is stuck on and resuming it if paused. stopping in its summary shows it. 204; 404 when no running run has the id. |
GET /v1/runs/{id}/events |
Follow a run live. Streams. Opens and closes with a snapshot, and sends a status with the last 50 calls and the last 50 searches after each progress and every 2 seconds in between. |
GET /v1/sites |
Each site in the page cache, with its pages, pieces, last fetch and engines, the one fetched from last first. With sources on, each has a source, as do the nodes of GET /v1/sites/graph and the citations of a result. |
GET /v1/pages?site= |
The cached pages of a site, newest first; without site, the latest 200 of all. |
GET /v1/pages/{id} |
One cached page: its text, its links by region, and the stored runs that cite it. |
GET /v1/pages/{id}/image |
The page's lead image, fetched on first request and kept in favicons.db as served, with the same checks and favicons.ttl_days as icons. 404 when the page has none, it is not PNG, ICO, GIF, JPEG or WebP, it is over 1 MiB, its host is not public, or favicons are off. |
GET /v1/pages/recall?q= |
The cached pieces recall would take for the question q, with their similarity. Marks nothing read. 503 without an embedding model. |
DELETE /v1/pages/{id}, DELETE /v1/sites/{domain} |
Remove a page, or every page of a site, with links and pieces. Cannot be undone. |
GET /v1/favicons/{host} |
The icon of a site, fetched on first request and kept in favicons.db. 404 when the site has none, the host is not a public DNS name, or favicons are off. |
GET /v1/search/challenges |
The blocks a person can clear: each with its provider, url and expires_in in seconds. Empty without the chromium feature. |
GET /v1/search/challenges/{provider}/live |
A WebSocket on a browser tab on the challenge. Sends frame (base64 JPEG) as the page changes and at the end state, one of cleared, expired, left or failed. Takes mouse, key and text input as JSON. 403 from another site's page. Needs the chromium feature. |
GET /v1/sources/{host} |
The kind, tags, trust, label, Wikidata qid and origin (config, mark, list, rule, wikidata or survey) of a site; Wikidata is asked when nothing else decides it. 404 unless sources.enabled. |
GET /v1/sources/tags?host=a.com&host=b.org, POST /v1/sources/tags |
{host: {kind, tags, trust}} for each host, from host in the query or {"hosts": [...]} in the body, at most 1,000. Answers from what is known and never waits for Wikidata; a name that is not a host is unknown. 404 unless sources.enabled. |
GET /v1/sources/{host}/explain |
{source, lists, entries}: what GET /v1/sources/{host} answers, each list naming the site with its state (applies, off, except or guard), and what each download holds of the site or a parent down to its registrable domain: {source, domain, value, rank}, with a Tranco rank or a perennial sources value. 404 unless sources.enabled. |
GET /v1/sources/lists, POST /v1/sources/lists |
Every list with hosts, skipped, fetched_at, error, and importing, the hosts written so far while a download is read in, or add your own, which answers 201. See Lists. 404 unless sources.enabled. |
PATCH /v1/sources/lists/{id}, DELETE /v1/sources/lists/{id} |
Change a list, or delete your own. Deleting a catalog list puts it back as it ships. A catalog list keeps its url and entries, 422 otherwise. A list in sources.lists answers 404. |
POST /v1/sources/lists/{id}/refresh |
Download a list now, however young its copy. |
GET /v1/jobs |
Each background job: {id, label, group, state, step, done, total, held, last_at, next_at, error, runnable, stoppable, pausable}. group is lists for a download, sources for the sources' other jobs (left out unless sources.enabled), and upkeep for the daily cleanup of old runs and the trash (runs) and pruning of the page cache (pages, off until fetch.cache_days or fetch.cache_mb is set). state is one of idle, running, paused, failed or off. While a job runs, step is waiting for a download slot (see sources.downloads), downloading with done of total bytes, or reading with done rows read. |
GET /v1/jobs/events |
Server-sent events: a jobs event holding what GET /v1/jobs answers, sent at once and again on each change, progress at most every 250 ms. |
POST /v1/jobs/{id}/run |
Start a job now, paused or not: a download however young its copy (a list, tranco, rsp or crawl), runs or pages. Answers 202 at once; GET /v1/jobs shows it running. 404 for an id it cannot start. |
POST /v1/jobs/{id}/stop |
Stop a running job that is stoppable. A download keeps what it held before; Common Crawl keeps the part it downloaded and goes on from there next time. Answers 204, or 404 when it is not running. |
POST /v1/jobs/{id}/pause, POST /v1/jobs/{id}/resume |
Pause a job that is pausable, running or not: it stops, and does not start on its own until resumed, across restarts. Resume starts it again if it is due. Answers 204, or 404 for a job unknown or off. |
PUT /v1/sources/{host}/mark, DELETE /v1/sources/{host}/mark |
Mark a site with {"value": "allow" | "deny" | "exempt" | <kind>}, or clear the mark. 422 for another value, 409 for a host set in sources.allow or sources.deny. A mark takes effect for runs in flight. 404 unless sources.enabled. |
DELETE /v1/sources |
Forget the cached Wikidata answers. Marks stay. 404 unless sources.enabled. |
DELETE /v1/favicons |
Forget every icon and lead image, including the sites remembered as having none, and free the disk space. Use it to retry misses after changing favicons.providers. |
POST /v1/pages/{id}/refetch |
Fetch the page again whatever its age. 502 with the reason when it cannot be read. |
POST /v1/tasks |
One short completion on the fast model. Body {"messages": [...]}, answers {"content": "..."}. |
POST /v1/tasks is the one route that is not a run: it registers nothing and takes no slot. It
exists because a chat client generates a title, tags and follow-up prompts by asking whichever
model the chat uses, and those must not queue behind — or displace — the research they describe.
A run is queued, running, done, failed or rejected. rejected means it never got a slot,
so a 429 is visible as a run rather than only as a status code.
Usage is split three ways: by_model, by_role and by_stage. A call counts toward the stage the
run was in when the call finished, and calls made before the first progress event count as
unstaged. A run can change stage while a call is in flight, so the stage split is approximate.
reasoning_tokens is part of completion_tokens, not added to it, and stays zero when the gateway
does not report it. A stage entered more than once has one span per entry, and its seconds sums
them. A usage row's seconds sums the time its successful calls took, from request to answer:
nothing is streamed, so it includes the time the model spent reading the prompt.
Cost comes from what the call reports: a LiteLLM proxy's x-litellm-response-cost response header,
or else a cost field in the response's usage object, either a number or split into
prompt_cost and completion_cost as Bifrost reports it. A gateway that reports neither leaves it
at zero, which is not the same as free.
Runs are held in memory, capped at the runs.max most recent, and by default do not survive
a restart. With runs.store set to sqlite they are also written to $KVASIR_DATA_DIR/kvasir.db,
once when they start and once when they end, and the list and detail routes read from it as well.
A run still queued or running there at startup was cut short by the restart, and is marked
failed with the error interrupted by a restart. While a run goes, the store also keeps its request and what each
model call answered and each search found; such a run is marked resumable, and
POST /v1/runs/{id}/continue picks it up without asking the model again. The journal is dropped
once the run ends. A resumed run gets a fresh research time limit. Retention is applied at startup, after each
run ends, and once a day at 03:00 local time, or at the next start when the service was down then: older than runs.ttl_days, or beyond the runs.max most recent, is deleted, unless it
is archived or in a project. A run in the trash is deleted runs.trash_days after it went there.
Only a run in this process's memory can be followed live; following a stored one sends its snapshot
and ends. What calls sent and answered, and what searches returned, is kept only with
runs.keep_call_content set to true, in the same store as the run and for as long as it. Their timings,
token counts, queries and result counts are kept either way.
Memory is the default because a client such as Open WebUI keeps its own record of every
answer, so the store is for debugging the service.
Settings come from two places, and each setting has one:
- The environment holds the token, the providers a deployment fixes, and what the process needs to start: where its data lives, where it listens, its thread count and its logging. An unusable value stops startup.
kvasir.db, in the data directory, holds every other setting, edited through the API. Only a setting changed from its default is stored. A stored value the service cannot use is logged at startup and its default used. A setting has no environment variable: aKVASIR_variable kvasir does not read is named in a warning at startup.
The page's Settings (#/settings, the gear in the header) holds every stored setting, saved as it changes.
Its rail lists the panes and finds a setting by any word of its name or help; a setting found can be changed
in the results, or opened where it lives. A dot marks a setting changed from its default. Storage shows what pages.db holds and takes on disk, and empties it.
From a client, GET /v1/settings lists every stored setting with its value, its default, its type and what it
accepts. PATCH /v1/settings sets the keys it is sent, and null sets one back to its default:
curl -s -X PATCH localhost:8080/v1/settings \
-H "Authorization: Bearer $KVASIR_TOKEN" -H 'Content-Type: application/json' \
-d '{"research.max_attempts": 6, "sources.allow": ["example.org"]}'
curl -s -X DELETE localhost:8080/v1/settings/research.max_attempts \
-H "Authorization: Bearer $KVASIR_TOKEN"Both answer with the same list as GET. A change applies at once. Most settings are read when a
run starts, but fetch.*, model.context_tokens and runs.max_concurrent also reach runs
already going. A runs.* change prunes the stored runs to the new limits at once, and a
runs.store change opens the new store. No setting waits for a restart. A change is checked with every other stored setting first, and nothing is
written when one is unusable: the answer is 422 with a message naming it. DELETE also removes a
key this version does not know, which otherwise blocks every PATCH.
A setting of type secret is never returned: its value is {"set": true} or {"set": false}.
PATCH with a string sets it, DELETE removes it, and a PATCH that leaves it out keeps it.
A nullable text setting, such as model.reason_effort, is turned off by storing "".
With model.fast, model.strong or model.embedding unset, or no web search provider enabled with
its key or URL (listed as search.provider), the service starts and logs which are missing, and the page opens on a setup form asking for them. Until they are stored, /readyz answers 503 with
{"setup":"required","missing":[...]}, and every route that starts a run or a task answers 409. A model whose provider is no longer in the environment counts as unset.
KVASIR_PROVIDER_<NAME>_API_BASE declares a provider, and KVASIR_PROVIDER_<NAME>_API_KEY is its
optional key. A name is lowercase letters, digits, _ and -; the variable carries it in upper
case. Every provider is an OpenAI-compatible endpoint. Without a key, a placeholder is sent, which
Ollama ignores. A signed-in local Ollama also serves :cloud models through localhost with no
key. A second provider, such as https://ollama.com/v1, takes its key from
KVASIR_PROVIDER_CLOUD_API_KEY.
A provider can also be stored, and changed while the service runs. providers.<name>.api_base is
its address and providers.<name>.api_key its optional key, which is kept like any secret:
curl -s -X PATCH localhost:8080/v1/settings \
-H "Authorization: Bearer $KVASIR_TOKEN" -H 'Content-Type: application/json' \
-d '{"providers.cloud.api_base": "https://ollama.com/v1", "providers.cloud.api_key": "..."}'GET /v1/settings lists every provider under providers. A stored one shows its api_base and
api_key as {"set": true}; one from the environment shows neither. A name the environment also
declares is refused, and so is removing a provider a model setting still names. Send null for
its keys to remove one. A run keeps the address and key it started with.
providers.<name>.max_concurrent caps the completions sent to a provider at once, for one from
the environment or a stored one; unset or 0 is no limit. Ollama Cloud's Pro plan allows 3. A call
past the limit waits for one in flight to end, and its time starts once it is sent.
GET /v1/settings shows each provider's max_concurrent.
GET /v1/providers lists the providers by name, with whether each listed its models, and never
its address or key. GET /v1/providers/<name>/models lists what one serves. Both read the same
list, kept for 5 minutes once a provider gave it in full; a provider that failed is asked again on
the next call:
$ curl -s -H "Authorization: Bearer $KVASIR_TOKEN" localhost:8080/v1/providers/local/models
[{"id":"gpt-oss:20b","ref":"local/gpt-oss:20b","context_length":131072,"capabilities":["completion","tools","thinking"]}, ...]ref is what a model setting takes. Every provider gives the ids. A provider that answers
/api/version as Ollama does also gives capabilities, and context_length, the window the model was trained with; a provider whose list
carries context_length, as OpenRouter's does, gives that. It is not the window the model is served
with. kvasir asks Ollama for model.context_tokens as num_ctx, through Ollama's own /api/chat,
since its OpenAI-compatible API ignores num_ctx. For any other server, model.context_tokens
must be the window it serves: a larger value cuts prompts off without an error.
A model is written <provider>/<name>. Only the first / splits, so the name may hold more:
cloud/org/model:tag. kvasir calls the provider's endpoint with the name. A request that overrides
model_fast, model_strong or model_reason with an unknown provider gets 400. An override may
name a different provider.
A query goes to every enabled provider at once. Their lists are fused by rank, with each provider's
weight, and a URL found by several counts once. A run needs one provider that searches the whole
web; the specialized ones only add to it. Startpage and Mojeek HTML are on by default, so a fresh
install searches the web with no setup. The rest are off until enabled, under Settings > Service or
as in Store a setting.
| Kind | Providers | Needs |
|---|---|---|
| Whole web, API | Brave, Tavily, Exa, Mojeek | An API key. |
| Whole web, self-hosted | SearXNG | An instance with the JSON output format enabled. One serving HTML only fails as an empty result set rather than an error, which looks like a topic with no sources. Add json to search.formats in its settings.yml. |
| Whole web, scraped | DuckDuckGo, Bing, Startpage, Mojeek HTML | Nothing. |
| Specialized | Wikipedia, arXiv, Crossref, Europe PMC, Semantic Scholar, OpenAlex | Nothing, except OpenAlex's key. |
The scraped providers read each engine's own pages, which is against its terms: keeping one on is
your choice and your risk. Turn Startpage and Mojeek HTML off with search.startpage.enabled and
search.mojeek_html.enabled set to false. An engine may block them or change its pages without notice. They send a
browser's User-Agent but not its TLS fingerprint, one request at a time and at least 2 seconds
apart.
A provider that fails rests and the others carry the query: 60 seconds after a rate limit unless it
says otherwise, an hour after a CAPTCHA. Its state in /readyz says so.
DuckDuckGo and Startpage can be unblocked by a person, in a build with Chromium such as the image's
browser target. The banner stays for 10 minutes. A block met after that raises it again.
- When a banner on the runs page names the provider, select Solve.
- Solve the challenge in the dialog, which shows a browser running on the service.
The dialog closes when the block is gone. kvasir keeps the provider's cookies in kvasir.db, sends
them with its later requests, and ends the rest at once. Whether an engine accepts cookies won in
Chromium from kvasir's own HTTP client has not been tested against a real engine; if the provider
blocks again right away, it does not.
The other providers offer nothing to solve: they rest and try again.
Every database file in KVASIR_DATA_DIR is encrypted with SQLCipher under KVASIR_SECRET_KEY. A
copy of the directory, a snapshot or a backup is unreadable without the key, so store the key apart
from them. Encryption does not help against someone with root on the host or a shell in the
container: the key is in the process's environment and memory. The downloaded source lists stay
plain, since they are public: index.db, which holds the lists, the Tranco list and the perennial
sources page, and crawl.db, which holds Common Crawl's ranks. A copy of a list, the Tranco list
or the perennial sources page that an older version left as sources-<hash>.txt is read into
index.db, then deleted. A list no longer named, or the Tranco list or the perennial sources page
once off, leaves index.db at the next start or list change. index.db names a list by a digest
of its URL, not the URL.
A lost key loses the data. Nothing can recover it. A wrong key stops startup with
<file>: KVASIR_SECRET_KEY does not open it; it was encrypted with another key.
Upgrading from a version without encryption: back up the data directory first. At the first
start, each plain file, and any .pre-rust.bak beside it, is replaced by an encrypted copy, and the
plain file is deleted. This needs free disk for one more copy of each file. A crash during the
conversion loses nothing, and the next start tries again.
To read a file by hand, use the sqlcipher command line, not sqlite3:
PRAGMA key = "x'<KVASIR_SECRET_KEY>'";| Variable | Default | Purpose |
|---|---|---|
KVASIR_TOKEN |
required | The token every route but health, POST /v1/login and the page needs. See Start a run. |
KVASIR_SECRET_KEY |
required | The key every database file is encrypted under: 64 hex characters, from openssl rand -hex 32. See Encryption at rest. |
KVASIR_PROVIDER_<NAME>_API_BASE |
required per provider | Provider base URL, such as http://localhost:11434/v1. |
KVASIR_PROVIDER_<NAME>_API_KEY |
unset | Provider credential. |
KVASIR_DATA_DIR |
/data |
Writable directory for kvasir.db and the caches. |
KVASIR_HOST |
0.0.0.0 |
Address to listen on. |
KVASIR_PORT |
8080 |
Port to listen on. |
KVASIR_MAX_THREADS |
10 |
How many intake calls, page reads, STORM conversations and sections run at once, and the cap on outbound search and page requests. For Ollama, set OLLAMA_NUM_PARALLEL to match. |
KVASIR_LOG_LEVEL |
INFO |
DEBUG, INFO, WARNING, ERROR or CRITICAL. Another value stops startup. |
KVASIR_LOG_FORMAT |
json |
json or text. text is for reading logs by eye. |
| Setting | Default | Purpose |
|---|---|---|
model.fast |
required | Conversation simulation, question asking, polishing, and research intake. |
model.strong |
required | Outline and article generation, and the research outline, report and summary. |
model.embedding |
required | Embedding model. Used by both modes. |
model.reason |
unset | Research: the judgements, such as whether the claims gathered answer a question, and the last word on a sentence the cheaper checks leave in doubt. Unset uses the strong model. |
model.checker |
unset | Research: a grounding checker such as MiniCheck, which answers Yes or No. Unset leaves every check to the fast model. |
model.reasoning_effort |
unset | Sent as reasoning_effort in every completion's body, except the reason model's. Unset sends nothing. |
model.reason_effort |
medium |
Sent as reasoning_effort to the reason model instead of model.reasoning_effort. |
model.context_tokens |
16384 |
The context window the models are served with: sent to Ollama as num_ctx, and for another server the window it serves. Sizes how much of a page one intake call reads, and the room for its answer. |
model.parse_retries |
3 |
How many more times a step asks after a reply it cannot read in either format, each time showing the model its reply and saying it could not be read. Each retry shows in the run's activity. |
search.top_k |
3 |
Search results per query. |
search.searxng.enabled |
false |
Search through a SearXNG instance. |
search.searxng.url |
unset | SearXNG base URL. |
search.searxng.engines |
empty | A list of the instance's engines to ask, by name. Empty asks those it enables itself. |
search.searxng.weight |
1.0 |
Its weight when results are fused. |
search.brave.enabled |
false |
Search through the Brave Search API. |
search.brave.api_key |
unset | Brave Search API key. A secret: never returned. |
search.brave.weight |
1.0 |
Its weight when results are fused. |
search.tavily.enabled |
false |
Search through the Tavily API. |
search.tavily.api_key |
unset | Tavily API key. A secret: never returned. |
search.tavily.weight |
1.0 |
Its weight when results are fused. |
search.exa.enabled |
false |
Search through the Exa API. |
search.exa.api_key |
unset | Exa API key. A secret: never returned. |
search.exa.weight |
1.0 |
Its weight when results are fused. |
search.mojeek.enabled |
false |
Search through the Mojeek API. |
search.mojeek.api_key |
unset | Mojeek API key. A secret: never returned. |
search.mojeek.weight |
1.0 |
Its weight when results are fused. |
search.wikipedia.enabled |
false |
Search English Wikipedia. |
search.wikipedia.weight |
1.0 |
Its weight when results are fused. |
search.arxiv.enabled |
false |
Search arXiv's preprints. |
search.arxiv.weight |
1.0 |
Its weight when results are fused. |
search.crossref.enabled |
false |
Search Crossref's scholarly works. |
search.crossref.mailto |
unset | Address for Crossref's polite pool, which has a higher limit. |
search.crossref.weight |
1.0 |
Its weight when results are fused. |
search.europe_pmc.enabled |
false |
Search Europe PMC's life sciences literature. |
search.europe_pmc.weight |
1.0 |
Its weight when results are fused. |
search.semantic_scholar.enabled |
false |
Search Semantic Scholar's papers. |
search.semantic_scholar.api_key |
unset | Semantic Scholar API key, optional: without one, requests share a public pool. A secret: never returned. |
search.semantic_scholar.weight |
1.0 |
Its weight when results are fused. |
search.openalex.enabled |
false |
Search OpenAlex's scholarly works. |
search.openalex.api_key |
unset | OpenAlex API key. A secret: never returned. |
search.openalex.weight |
1.0 |
Its weight when results are fused. |
search.duckduckgo.enabled |
false |
Search DuckDuckGo by reading its pages, which is against its terms. |
search.duckduckgo.weight |
1.0 |
Its weight when results are fused. |
search.bing.enabled |
false |
Search Bing by reading its pages, which is against its terms. |
search.bing.weight |
1.0 |
Its weight when results are fused. |
search.startpage.enabled |
true |
Search Startpage by reading its pages, which is against its terms. |
search.startpage.weight |
1.0 |
Its weight when results are fused. |
search.mojeek_html.enabled |
true |
Search Mojeek by reading its pages, solving its proof-of-work. No key, but slow. |
search.mojeek_html.weight |
1.0 |
Its weight when results are fused. |
runs.store |
memory |
memory, or sqlite to keep runs across restarts in kvasir.db. |
runs.max |
100 |
Runs kept, most recent first, in either store. Archived runs and runs in a project are kept besides. |
runs.ttl_days |
30 |
Age past which a stored run is deleted, unless it is archived or in a project. sqlite only. |
runs.trash_days |
7 |
Days a run stays in the trash before it is deleted. 0 deletes it at once. sqlite only. |
runs.keep_call_content |
false |
Keep each model call's messages and answer, and each search's results, with its run, to read on the runs page. true or false. |
runs.max_concurrent |
1 |
Concurrent runs. Beyond this, requests get 429. |
runs.stuck_wait_minutes |
10 |
How long a run waits on a step that failed past its retries, for POST /v1/runs/{id}/decide. Then it skips the step, or fails when the step has no fallback, and waits on no later step. 0 never waits: the run fails. A reply that cannot be read never waits: the step falls back at once. |
storm.max_conv_turn |
3 |
Questions per perspective. |
storm.max_perspective |
3 |
Perspectives to research. |
research.results_per_query |
5 |
Research: pages read per search. |
research.max_attempts |
4 |
Research: queries tried on one question before it is given up. |
research.max_depth |
4 |
Research: levels of raised questions below the first questions. |
research.max_entities |
8 |
Research: options a ranking or table compares, when the run chooses them. |
research.max_criteria |
5 |
Research: criteria, when the run chooses them. |
research.fact_check |
false |
Research: send the text of each claim a deep or quick report cites, at most 60, to Google Fact Check Tools, and show what fact-checkers said of it under the claim in the evidence table. Claim text leaves the machine. A rating is the fact-checker's own words and changes no trust. Needs research.fact_check_key. |
research.fact_check_key |
none | Research: a Google API key from a Cloud project with the Fact Check Tools API enabled. Free of charge. |
fetch.contact |
unset | Contact named in the user agent of page requests, as kvasir/<version> (+https://github.com/brewcoua/kvasir; <contact>). Unset names none. |
fetch.host_interval_seconds |
1.0 |
Least time between the starts of two page requests to one host. At most 2 are in flight to a host regardless. |
fetch.render |
auto |
auto renders a page that needs JavaScript in Chromium, in a build with Chromium such as the image's browser target; off never does. |
fetch.stealth |
false |
Start Chromium without the launch flags that name automation. Getting past a challenge can break a site's terms. |
fetch.max_age_hours |
48 |
Hours a page cached in pages.db is served without asking its site; then it is revalidated with If-None-Match or If-Modified-Since. 0 fetches every page again. |
fetch.cache_days |
0 |
Days a page stays in pages.db after it was last read; the daily upkeep at 03:00 drops older ones. Pruned pages leave Knowledge and recall too. 0 keeps every page. |
fetch.cache_mb |
0 |
About the most the pages in pages.db take, in MB, counting their text, pieces and links; the daily upkeep drops the oldest past it. The file runs larger, with its text index. 0 sets no limit. |
favicons.providers |
direct |
A list of where site icons come from, tried in order; the first icon found wins. direct, duckduckgo, google or custom. An empty list turns favicons off and creates no favicons.db. See below for what each reveals. |
favicons.custom_url |
empty | URL template for the custom provider, with {domain} where the site's host goes. Required to be an http(s) URL containing {domain} when custom is listed, or favicons keeps its defaults. |
favicons.cache |
true |
Keep icons in favicons.db, so they survive a restart. Off, every request asks the providers again. |
favicons.ttl_days |
30 |
Days a found icon is served before it is looked up again. |
favicons.miss_ttl_days |
3 |
Days a site found to have no icon is remembered before it is asked again. |
favicons.retry_minutes |
30 |
Minutes a site no provider answered for is left alone, across restarts. 0 asks it on every request. |
sources.enabled |
false |
Classify the sites of search results by kind and trust. Off, nothing is classified, downloaded or sent to Wikidata. See Source trust. |
sources.wikidata |
true |
Ask Wikidata about hosts that no rule, mark or list decides. false sends no domain anywhere. |
sources.endpoint |
https://query.wikidata.org/sparql |
SPARQL endpoint asked. |
sources.ttl_days |
30 |
Days a Wikidata answer is kept, and a downloaded list is served before it is fetched again. |
sources.miss_ttl_days |
7 |
Days a host Wikidata does not know is remembered. |
sources.allow |
empty | A list of hosts, with their subdomains, always trusted (trusted). Beats any mark. |
sources.deny |
empty | A list of hosts, with their subdomains, never read (blocked). Beats sources.allow and any mark. |
sources.rsp |
false |
Also tag hosts by Wikipedia's perennial sources list, fetched from en.wikipedia.org and refreshed with the lists. Needs sources.enabled. |
sources.survey |
false |
Tag sites nothing else decides as likely_farm, from their own sitemaps and the Tranco list, in the background, with sources.rdap as young, and with sources.safe_browsing as malicious. Needs sources.enabled. |
sources.link_trust |
false |
Rate sites nothing else decides as standard when the content of stored pages from 2 trusted or standard sites links to them. Reads pages.db only. Needs sources.enabled. |
sources.crawl_rank |
false |
Let the survey spare sites in Common Crawl's top 1,000,000 domains by harmonic centrality, downloaded at runtime: a 2.5 GB download and 4 GB on disk, see Source trust. Needs sources.survey. |
sources.rdap |
false |
Let the survey ask each site's registry, over RDAP, when its domain was registered, and tag one under 2 years old young, of trust doubtful. The registry sees each domain asked. Needs sources.survey. |
sources.safe_browsing |
false |
Let the survey ask Google Safe Browsing about each site, sending 4-byte hash prefixes only, and tag a listed one malicious, of trust blocked. Needs sources.survey and sources.safe_browsing_key. |
sources.safe_browsing_key |
none | A Google API key from a Cloud project with the Safe Browsing API enabled. Free of charge; Google's terms allow non-commercial use only. |
sources.guard_rank |
10000 |
A list with its guard on spares sites this high on the Tranco list. 0 spares none for their rank. See Lists. |
sources.downloads |
2 |
How many lists and source files download at once. The rest wait their turn. |
sources.lists |
none | A list of <label>=<url> lists to download, the label being a kind (government) or a tag (ai_generated, content_farm, predatory). Hosts files, adblock and uBlacklist files and CSVs with the host first are read. An empty list uses none. The Lists page shows them but changes them only here. |
Favicons are fetched by the server and served from /v1/favicons/{host}, so a browser never contacts
the icon provider. What each provider learns:
directcontacts the site itself:/favicon.ico, then the icons its homepage declares.duckduckgoandgooglelearn each domain looked up.customis your own template, such ashttps://icons.example.net/{domain}. Its first request is not checked against the public-address rule, so it may be a service on your network.
An icon must be PNG, ICO, GIF, JPEG, WebP or SVG, judged by its content, and at most 256 KiB. The
direct provider prefers an SVG the homepage declares, then its largest declared image, then
/favicon.ico, then the same at www. or the registrable domain. An SVG is kept only after
everything but its drawing is removed. Do not use as custom a service that answers 200 with a placeholder for a missing icon,
such as allesedv, Yandex, icon.horse or Bitwarden (as probed): kvasir caches the placeholder as the
site's real icon. A provider that answers 404 for a missing icon is fine.
With sources.enabled set to true, each host resolves to a kind (government, academic, news and so
on), tags such as community or ai_generated, and a trust: trusted, standard, unrated, doubtful or
blocked. Runs stored before these names were high, normal, unknown, low and
avoid still load, and are shown with the new ones.
A host resolves in this order: sources.allow and sources.deny, a mark, trust lists, the
bundled list (crates/kvasir-sites/src/sources.toml), then other lists, suffix rules (.gov, .edu,
.int, europa.eu and the like), the cache in sources.db, then Wikidata. Wikidata is asked for the
domains of search results in one query per 50 hosts, so its operators see those domains; set
sources.wikidata to false to avoid that. A host still unanswered after 5 seconds counts as
unrated for that search and is known by the next. CISA's .gov list is in the catalog, on until you turn it off.
With sources.rsp set to true as well, kvasir fetches Wikipedia's perennial sources
list (WP:RSP) from
en.wikipedia.org through the MediaWiki API, nothing of it shipped, and refreshes it with the other
lists. A site it names gets a tag from its status: rsp_reliable (generally reliable, trusted),
rsp_mixed (no consensus, no effect), rsp_unreliable (generally unreliable, doubtful) or
rsp_deprecated (deprecated or blacklisted, blocked). The tag joins the kind the host resolves to,
and does not replace it; sources.allow, sources.deny and a mark ignore it. A site in several rows,
such as a newspaper and its blogs, takes the least reliable status; a row with no site, or only a
part of one (example.com/news), is skipped. Wikipedia's own row is skipped too: the page calls it
unreliable because an article citing an article is circular (WP:CIRCULAR), which a run citing it
is not. Rows name publications, not domains, so only sites
the page lists are tagged.
With sources.survey set to true as well, a site a run met and nothing else decides is surveyed
in the background, and again after sources.ttl_days. kvasir reads up to 8 of its sitemap files and
downloads the Tranco top 1M, nothing of it shipped. One of Tranco's
sources is CC BY-NC 4.0, so check that it suits your use. A site publishing 5 or more URLs per
dated day, over 100 or more dated URLs and leaving out its busiest day, and outside Tranco's top
300,000, gets the tag likely_farm, of trust doubtful: it is ranked lower and gets no site: search, but
it is still read. Its evidence says why, and a mark or sources.allow overrides it. These
thresholds come from 25 sites, so treat them as a first guess.
With sources.crawl_rank set to true as well, a site in Common Crawl's top 1,000,000 domains by
harmonic centrality also counts as visited, so the survey spares it. kvasir reads the newest web
graph release from index.commoncrawl.org/graphinfo.json, checked once per sources.ttl_days, and
downloads its whole domain ranks file from data.commoncrawl.org when the release is new. Every
domain goes into $KVASIR_DATA_DIR/crawl.db with its rank, and GET /v1/sources/{host}/explain
shows it. Nothing of it ships. Common Crawl's terms of use
apply.
The ranks take disk and time. Measured on 2026-11-08 for cc-main-2026-aug-sep-oct
(134,979,706 domains, 134,967,898 once www. and case fold some together), on 16 cores and an
NVMe disk:
| Size | Time | |
|---|---|---|
Download, crawl-<release>.gz |
2.5 GB | 3.5 min at 12 MB/s |
Read into crawl.db |
4.0 GB | 2.5 min |
Peak in $KVASIR_DATA_DIR, while reading |
10.6 GB |
The download resumes where it stopped, after a restart too, and is deleted once read. While it is
read, a staging file sits beside crawl.db, and SQLite sorts in its temporary directory:
SQLITE_TMPDIR, else TMPDIR, else /var/tmp, which needs space too (not measured apart). If
that directory is a small tmpfs, set SQLITE_TMPDIR to a directory on disk. The read takes
about 40 MB of memory. With sources.crawl_rank or sources.survey off, crawl.db and any
download of it are deleted at the next start.
With sources.rdap set to true as well, a surveyed site the survey does not call a likely farm,
and outside Tranco's top 300,000 (and Common Crawl's top 1,000,000 with sources.crawl_rank), is
looked up over RDAP. kvasir reads which registry answers for its top-level domain from IANA's
data.iana.org/rdap/dns.json, once per sources.ttl_days, then asks that registry for the
registrable domain, once per survey. A domain registered less than 2 years ago gets the tag young,
of trust doubtful, with the date as its evidence. The registry sees each domain asked, so this is
off by default. A top-level domain IANA lists no registry for, such as .io, and a name under a private suffix,
such as x.github.io, give no age, and nothing is sent for them. A registry that does not answer
leaves a young site its tag until the next survey. Farms
also buy old domains, so an old domain says nothing and never raises a site.
With sources.safe_browsing set to true as well, and sources.safe_browsing_key set, each site
the survey reads, ranked or not, is checked against Google Safe Browsing through its v5
hashes.search method, once per survey. kvasir sends the first 4 bytes of the SHA-256 of the
site's root (example.org/, and up to four parent names of a subdomain) and matches the full
hashes Google answers itself, so Google sees hash prefixes many sites share, not the site. A site
listed for malware, social engineering, unwanted software or a harmful app gets the tag
malicious, of trust blocked, so it is not read again. It covers malware and phishing, not
content farms. To get a key, create one under APIs & Services, then Credentials, in a Google Cloud
project with the Safe Browsing API enabled; no billing account is needed. Google's terms allow
non-commercial use only. A check that fails leaves a malicious site its tag until the next survey.
With sources.link_trust set to true as well, kvasir reads the main and related links of the
pages in pages.db once an hour. A site nothing else decides, linked from at least 2 registrable
domains of trust trusted or standard, gets the tag cited_by_trusted, of trust standard. Only
the linking sites' own trust counts, never one this tag gave, so farms that link to each other gain
nothing. Its evidence names the linking sites. Any other tag, the survey's included, decides first.
Nothing is sent anywhere. The threshold of 2 is a first guess.
Open #/lists in the dashboard, or use the routes under /v1/sources/lists, to choose which
community blocklists apply and to add your own. The catalog
(crates/kvasir-sites/src/sources/catalog.toml) names 23 lists by URL, all off. kvasir downloads a
list only once it is on, from where the list is published, and nothing of them ships.
| Effect | Result |
|---|---|
block |
Trust blocked, with the tag block_listed: never read. Wikidata is not asked about the site. |
demote |
Trust doubtful: ranked lower and still read. It joins what the rest of the order finds. |
exempt |
Cancels block and demote lists and the bundled list. Trust does not change. |
trust |
Trust trusted. |
kind:<kind> |
Sets the kind. |
An entry matches the host and its parents, down to the registrable domain. A list's except names
hosts it does not apply to. To unblock one site, mark it exempt: allow would also raise it to
trusted. exempt does not cancel a Wikidata tag, an RSP verdict or sources.deny.
With a list's guard on, its block or demote does not reach a site that a suffix rule decides,
or that is in the Tranco top sources.guard_rank. kvasir downloads the Tranco list once a guarded
list is on; see sources.survey above for its licence. Until that download lands, only the suffix
rule spares a site.
A list can hide any site its guard does not spare. A remote trust or exempt list lets its
maintainer promote or unblock any site, so the catalog ships none, and the page warns before adding
one. The catalog's licences are as each list states, and several state none: check they suit your
use.
To try one for a single run, pass list ids in the options lists_on and lists_off. An unknown id
answers 422. A list turned on this way is downloaded when the run starts if it has no copy yet, and
the dashboard has no field for these options yet. Badges on a stored run use the lists as they stand,
not the run's choice. A run's steps name the sites it left out and the lists behind them.
Set model.reasoning_effort to none for a thinking model on Ollama. Its reasoning counts
against each role's token budget, which is a few hundred tokens for the outline and article roles,
so the answer is cut off before it starts. The reason model is meant to
think, so its roles have budgets of 2000 to 3000 tokens and their own effort,
model.reason_effort. Without model.reason, those roles run on the strong model at
that effort. With the reason model on a cloud provider, each sentence it is asked about and its
quotes go to that provider, and runs.keep_call_content does not govern that.
Each log line is one JSON object. A line logged during a run carries its run_id and run_kind,
and a line about its progress the stage, which is what makes concurrent runs separable in a log.
Each request is logged too, with its method, path and status, but not its query.
Each provider's base URL and key are passed explicitly to its model and embedding calls; neither is
exported into the environment, so there is no path by which a default reaches api.openai.com.
Saturation returns 429 rather than queueing, because a queued run would outlast any sensible client timeout.
Not measured here. No provider was reachable while this was built, so no honest figure for tokens or wall-clock time on a default run can be given. The defaults come from upstream's own examples rather than from measurement.
Measure against your own provider. GET /v1/runs/{id} reports a finished run's tokens and cost split
by stage, by role and by model, which is enough to decide whether the defaults suit you. Note that
max_conv_turn and max_perspective multiply: the research stage is roughly
max_perspective * max_conv_turn conversations.
crates/kvasir-desktop is a native window over the same crates as the
service, without HTTP: it opens kvasir.db in the system data directory (~/.local/share/kvasir
on Linux) and keeps its keys in the OS keyring. Where no keyring answers, it asks for a
passphrase and derives them from it. It hosts no model: first run finds Ollama on this
machine, or another provider is set in Settings.
cargo run -p kvasir-desktopOne Pipe function lives under clients/owui/, exposing research and STORM
as two models. Research asks its clarifying questions as a first reply. It writes a run's progress into the message as it happens, turns sources into citations,
and reports what the run spent. Open WebUI's own title, tag and follow-up generation is routed to
POST /v1/tasks, so naming a chat does not cost a run. Installation instructions and a valve
reference are in clients/owui/README.md.
The service is the Cargo workspace in crates/. just lists every task, after
just setup:
just check # what CI runs, but for the image build
just lint # rustfmt, clippy, cargo-deny, ruff, mypy, svelte-check
just test # test-rust, test-owui and test-web
just fmt # format the Rust and the Python
just build # the page, then the release binary that embeds it
just image # the container imagejust setup installs git hooks from .pre-commit-config.yaml, run by
prek. A commit runs the formatters and linters for the files it changes,
and a push runs just test-rust. Neither checks the web client, which is no longer kept up to
date. CI runs the Rust and Open WebUI suites and the image build, only on the release-please PR
and on Renovate's PRs, so the hooks are what checks an ordinary commit. prek run --all-files
runs the commit hooks on every file, and --hook-stage pre-push runs the push hooks.
just test-rust and just lint need cargo-nextest and
cargo-deny. just setup installs prek with
cargo install. The tests need no network and no
credentials, and do not read .env. Only just dev, api, web, seed, eval and the other
recipes that run the service do.
A few tests are ignored by default because they need something outside the repository: a real
Chromium (cargo test -p kvasir-browser --features chromium -- --ignored), or a real provider, as
the top of crates/kvasir-llm/tests/live.rs shows.
just features=chromium dev builds the service with Chromium, to render pages and clear a
block. just api, web and mock run one part on its own.
crates/kvasir/tests/contract/ drives the built binary over
HTTP, with fakes for the providers, SearXNG and the sites. just test-rust runs it with the other
Rust tests. Each test starts its own service, and compares the bodies it reads with the shapes in
shapes.json. After a deliberate change to a body, write them again with
KVASIR_CONTRACT_RECORD=1 cargo nextest run -p kvasir --test contract --test-threads 1.
clients/owui/tests/ drives the Open WebUI pipe against the built binary,
with the same fakes written in Python. just test-owui runs it. It is the only Python left, since
Open WebUI runs only Python.
Each prompt is pinned byte for byte by tests/golden/signatures.json, as dspy 3.3.0 rendered it
for the Python service this replaced. Draft came after, so its entry is what this service renders. A change to a prompt changes that file in the same commit.
docs/storm.md records what STORM must keep doing and where it differs from
upstream. Read it before changing crates/kvasir-storm.
The runs page is deprecated: change it only to keep it working.
The page is a Svelte app in clients/web/, built with Vite into clients/web/dist/, which is not
committed.
just mock # the page on localhost:5173, its API answered from fixtures/
just web # the same, with /v1 proxied to the kvasir on KVASIR_PORTjust mock needs no gateway, search engine or run. Each file in clients/web/fixtures/ is a run named
after it, and #/reports/<name>/result opens its result directly: #/reports/ranking/result shows the
ranking table. Two more runs are made from ranking.json: running, frozen partway, and live,
replayed at 20 times its real pace and started again once it ends. Set MOCK_SPEED to change the
pace. graph-live replays graph.json the same way, its questions appearing as the run goes. A
run started from the page replays a fixture the same way: wal.json for research, and
article.json for an article.
<name>.calls.json holds what a fixture's calls sent and answered, keyed by call id, and
<name>.searches.json what its searches returned, keyed by search id. Only wal has the first and
only graph the second, both recorded with runs.keep_call_content set to true.
A fixture is a run's detail as GET /v1/runs/{id} returns it. To add one from a run kept in the
SQLite history:
curl -s -H "Authorization: Bearer $KVASIR_TOKEN" localhost:8080/v1/runs/<run id> > clients/web/fixtures/<name>.jsonTo include its calls' content, with the run kept by a service started with
runs.keep_call_content set to true:
run=<run id>
for n in $(jq -r '.calls[] | select(.has_content) | .id' clients/web/fixtures/<name>.json); do
curl -s -H "Authorization: Bearer $KVASIR_TOKEN" localhost:8080/v1/runs/$run/calls/$n | jq -c --arg n "$n" '{($n): .}'
done | jq -s -c add > clients/web/fixtures/<name>.calls.jsonIts searches' results are fetched the same way:
for n in $(jq -r '.searches[] | select(.has_content) | .id' clients/web/fixtures/<name>.json); do
curl -s -H "Authorization: Bearer $KVASIR_TOKEN" localhost:8080/v1/runs/$run/searches/$n | jq -c --arg n "$n" '{($n): .}'
done | jq -s -c add > clients/web/fixtures/<name>.searches.jsonThe TypeScript types in clients/web/src/lib/schema.d.ts are generated from the service's OpenAPI schema.
Run just types after changing a type in crates/kvasir-types/ or crates/kvasir-api/; it builds
and runs kvasir openapi, and CI fails when the types differ.
just e2e runs Playwright against the mock and against the built page served by the service,
installing its Chromium first. just lint runs svelte-check, and just test-web runs Vitest.
eval/questions.toml holds 21 questions: 11 reports, 6 rankings and 4
tables. Each lists the output type its wording asks for and a few checks a reader of the question
would need answered. To evaluate, start the service, then run:
just eval data/eval/baselineIt loads .env as the service does, and reads the judge's model from the same kvasir.db.
It researches each question through the service at http://localhost:8080 (--url to change),
without clarifications, and saves each result as <id>.json. --method picks the method, deep
by default, and --options takes its options as a JSON object, such as '{"research_minutes": 10}'.
Save each method or set of options to its own directory to compare them. Scoring reads report,
claims and citations, so deep results score and storm results do not. A saved result is not researched
again, so an interrupted evaluation resumes; --only <id>... limits it to some questions. A run
takes minutes, so the full set takes hours on a local model.
Every saved result is then scored, with the reason model, the judge role, as judge:
- Output. Whether the brief chose the expected output type.
- Checks. A yes or no for each of the question's checks, against the report.
- Citations. Each cited sentence of the final report, against the quotes of the claims read from the sources it cites. Verification already removed what it judged unsupported, so this measures what slipped past it.
Scores, with every failed check and unsupported sentence and the judge's reason, go to
scores.json beside the results. A summary table is printed. The judge samples at temperature
1.0 like every role, so a score can move by a check or two between two scorings of the same
result.
Dual MIT and Apache-2.0, at your option. See LICENSE-MIT and LICENSE-APACHE.
STORM is the work of Stanford OVAL. Their
knowledge_storm package is translated into Rust in crates/kvasir-storm, which stays MIT only, as
upstream is. See crates/kvasir-storm/NOTICE for the commit it was
translated from and docs/storm.md for what was changed.