Docs / architecture

Local and self-hosted architecture

One engine, two hosts, three privacy tiers. This page explains what runs where, what leaves the machine, and what does not.

Three modes from one codebase

ModeRuntimeAuthStorageWho it is for
DesktopTauri shell + a bundled Python engineNone — it is your machineSQLiteIndividuals
Self-hostedThe Compose stackCookie sessionsPostgres + RedisCompanies, homelabs
HostedThe same Compose stackCookie sessionsPostgres + RedisDemos, small teams

The middle two are the same deployment with different exposure. Only the first is a different program shape.

The engine boundary

The pipeline lives in research_engine, a package with no FastAPI, no Celery, no SQLAlchemy, and no Redis in its dependency tree. It never imports from the server application at all — a test AST-scans every engine module and fails on any host import.

backend/
  research_engine/        the engine — installable, host-independent
    runconfig.py          RunConfig: process default + per-run override
    graph.py              the compiled StateGraph
    prompts.py  schemas.py  state.py
    tools.py    net_guard.py
    retrievers.py         searches through the Cache port
    llm_factory.py  catalog.py
    events.py   fakes.py  demo_fixtures.py
    ports.py              EventSink, Cache, Embeddings, Corpus protocols
    corpus.py  chunking.py  embeddings.py
    runner.py             checkpointer-agnostic orchestration → RunOutcome
    local.py              the local host: SqliteCache, in-process events, env config
    cli.py                `research-engine` — the reference local host
    bundle.py  verify_bundle.py
  app/                    server host — FastAPI, Celery, Postgres, Redis
    runtime.py            the seam: settings → RunConfig
    adapters.py           RedisCache and the agent_logs event sink
  desktop/
    sidecar.py            the desktop host

That boundary is what makes a desktop build possible at all. It is also why the evaluation harness needs no DATABASE_URL and no JWT_SECRET_KEY to run a pipeline that touches no database.

Ports

Two protocols, plus two more for the corpus tier. Hosts supply the implementations.

class EventSink(Protocol):
    async def __call__(self, session_id: str, event: dict) -> None: ...

class Cache(Protocol):
    async def get(self, key: str) -> str | None: ...
    async def set(self, key: str, value: str, ttl: int) -> None: ...

class Embeddings(Protocol):
    @property
    def model_id(self) -> str: ...
    @property
    def dimensions(self) -> int: ...
    async def embed(self, texts: list[str]) -> list[list[float]]: ...

class Corpus(Protocol):
    async def search(self, query: str, max_results: int) -> list[dict]: ...
    async def read(self, url: str) -> dict: ...

Corpus.search returns exactly the shape the web retriever returns, so the executor needs no branch and the graph does not change when the corpus replaces the web.

A selected-files run (from 3.1) gets ScopedCorpus over the store instead: the same two methods, answering only for the document versions frozen on the run (RunConfig.corpus_allowed_ids). It filters inside the search, before the result limit, so a selection is never answered with fewer hits than it has, and read refuses any other document as if it did not exist. Both hosts install it from the run's row, never from the request.

Checkpointing needs no port — LangGraph's saver interface is the port. The host constructs it and hands the ready saver to the runner.

Every embeddings adapter should additionally expose an is_local attribute. It is not declared on the protocol; the corpus airgap guard reads it defensively and treats a missing one as remote, so an adapter that forgets to answer is handled as the unsafe case rather than waved through.

ConcernServerDesktop
CheckpointerAsyncPostgresSaverAsyncSqliteSaver
Event sinkagent_logs row → Redis publishIn-process queue → SSE
CacheRedis, 24h TTLSQLite table, 24h TTL
Provider keysDecrypted from an encrypted columnOS keychain
Run lockRedis token lockIn-process lock
Job executionCelery taskAn asyncio task

The engine's default for an uninstalled port is a no-op, never a crash, so a bare engine — a CLI run, a test, a fresh desktop build — works with no host at all.

Configuration arrives as a RunConfig: a process-wide default installed by the host, plus a per-run override in a ContextVar so concurrent runs in one worker can carry different model routing and budgets. A ContextVar rather than graph state, because tools are invoked without access to state and the retriever chain is called from inside a tool.

Desktop process architecture

┌─ Tauri shell (Rust) ────────────────────────────────┐
│  WebView → Next.js static export                    │
│  Keychain access · file dialogs                     │
│         │ spawns and supervises                     │
│         ▼                                           │
│  Python sidecar (PyInstaller, one directory)        │
│    uvicorn on 127.0.0.1:<ephemeral port>            │
│    research_engine + SQLite + local adapters        │
└─────────────────────────────────────────────────────┘
  • The frontend keeps talking to /api/*; the shell rewrites the base URL to the sidecar port. Same client code, same SSE handling, same citation UX.
  • Auth endpoints are absent, and the frontend's server-side cookie guard compiles out behind a build flag.
  • The sidecar binds to 127.0.0.1 on an ephemeral port with a per-launch bearer token. A loopback port with no token is reachable by any other process on the machine, and by any web page through DNS rebinding. This is the one security control the desktop build adds, and it is not optional.
  • The sidecar watches the shell's process id and exits when the shell does.
  • WeasyPrint is excluded from the bundle — its native dependency chain on Windows is a packaging tar pit. Desktop PDF uses the WebView's print-to-PDF. A test pins that the sidecar process never imports the server's export module at all.

The bundle is around 81 MB for the macOS .dmg, installing to roughly 182 MB. It carries no PyTorch and no sentence-transformers; its heaviest dependency is numpy.

What differs between the hosts

The pipeline, both gates, citation resolution, and the export formats are identical. These are the deliberate differences:

DesktopServer
Report chatYes, same scope contractYes
Project chat and project memoryAbsent by design — memory is pgvector-onlyYes
Durable event logAbsent; bundles record trace_available: false rather than an empty traceagent_logs
Corpus layoutOne file per project, in the app's data folder (from 3.1)One file per project
Rate limitingNone — needs Redis and a user model, and the desktop has neitherYes
PDF exportWebView print-to-PDFWeasyPrint, server-side

trace_available is worth noting as a pattern: an empty trace: [] is ambiguous between "the host has no durable log", "the log was empty", and "the log is missing". The flag disambiguates, and it is covered by the bundle hash, so the truthful state is what gets signed.

Keeping the two in step

Every shared behaviour has two homes, and the second one is what gets forgotten. When you change any of the following, change both:

  • Request fields reaching the session row
  • The per-session run config
  • A new session status, or a new pause event
  • Route validation
  • Report chat behaviour
  • Schema: a migration and the ORM model, which is what the desktop's schema sync reads

Where possible, extract the shared logic into one function instead of keeping two copies in step by discipline. The localhost → host.docker.internal rewrite is the worked example: it existed in three copies, two of which were wrong, and is now one function both hosts call.

Local endpoints and Docker

map_local_host() is the single implementation that rewrites localhost to host.docker.internal when running inside a container. Both the pipeline's model calls and the health probe go through it, so the check agrees with the thing it is checking — the probe used to dial the raw configured value and then tell you to retype it by hand.

Three privacy tiers

TierModelsEvidenceNetworkWho needs it
1 · Cloud, BYOKProvider APIsWeb searchYesPrivacy-minded default: no SaaS intermediary
2 · Local modelsOllamaWeb searchYes, for retrieval onlyNo cloud inference
3 · Local corpus, local modelsOllamaYour documents onlyNone — if every role is routed to OllamaLaw, health, defence, journalism

What leaves the machine, per tier

Tier 1. Prompts and reports go to your chosen model provider on your key. Search queries go to the retriever. Nothing is proxied through a service operated by this project, because there is no such service.

Tier 2. Model inference is fully local — prompts, reports, and chat never reach a model provider. Search queries still leave, because ordinary research still searches the web and fetches pages. This is the caveat people most often assume away, so it is stated plainly: your reasoning stays local, your queries do not.

Tier 3. Nothing — but only because tier 3 is tier 2's local models plus corpus-only mode, and the first half is your configuration, not something the mode enforces. What corpus-only mode enforces: evidence comes only from the installed corpus; read_webpage refuses every non-corpus URL; the retriever delegates exclusively to the corpus store; and the store refuses an embedder that is not local. It does not check where the research models are, and a local embedder does not make the run offline: corpus mode with a hosted planner, executor, critic or synthesizer is tier 1 with local evidence, and the question and retrieved excerpts go to that provider. A run in this mode with no corpus installed fails closed rather than falling back to the web.

Offline (from 3.1) is the half corpus-only mode leaves to you, enforced: a run that asks for it is refused before it starts, on both hosts, unless every role's route is ollama: or custom: at an address on this machine (research_engine/offline.py, one implementation, checked again at the first model call), and the run records that it asked (research_runs.offline). The check is on the address. A custom: endpoint on this machine that is a router to cloud providers passes it while prompts leave through it, and the app has no way to tell; the run form and the user guide say so rather than promising more than the check proves.

Corpus hits carry corpus://<doc-id>#chars=<start>-<end> URLs, and the synthesizer's normal source assembly turns them into ordinary [n] citations whose snippets resolve back to the exact character range in the source document. Citation provenance is more exact on a closed corpus than on the open web.

Embeddings for tier 3 go through the Embeddings port to the same Ollama, defaulting to nomic-embed-text — not a bundled sentence-transformers, whose torch dependency alone would exceed the whole bundle budget. That retrieval opens no outbound connection is asserted by a test with socket and DNS guards (test_corpus_egress.py), run with scripted models — it proves the retrieval half, not where a real model call goes.

A note on testing an absence. A test that asserts "no network calls" while injecting a fake embedder proves nothing if the query embedding was the only thing that egressed. When writing a test for an absence, check what the fixtures replaced: if the mechanism under test is the thing you mocked, the test is decorative.

What explicitly does not change across hosts

These are the product, and they hold everywhere:

  • The compiled graph and its node contracts.
  • interrupt()-based human gates with resume-from-checkpoint. Desktop keeps both gates.
  • The fail-closed critic, and explicit failure states with reasons.
  • Per-claim citation resolution and the ⚠ unverified chip.
  • Untrusted-content framing and the SSRF guard — more important locally, where the fetcher runs inside your own network.