# passageway > Retrieval for Claude over MCP: connectors feed passages, and a relevance gate keeps it quiet when nothing fits. # Start # Claude finds the right passage in your sources passageway indexes what your connectors report and gives Claude the best few passages for a question, with links. When nothing is relevant enough, it says nothing. [Install and run](https://passageway.marceltov.de/install/index.md) [Write a connector](https://passageway.marceltov.de/guides/write-a-connector/index.md) ## What it is passageway is the retrieval half of a RAG system, served as an MCP tool. It does no generation: the model that calls it reads the passages and writes the answer. - **The core knows no source.** A connector is any executable that lists items and prints their text as JSON Lines. Anyone can write one, in any language. - **It is quiet on purpose.** A relevance gate lets it return nothing instead of padding a prompt with weak matches. - **It runs small.** Keyword and embedding search in one SQLite file per source, on the CPU, with no vector database to run. ``` [[connector]] name = "deep-learning" command = ["/path/to/python", "-m", "passageway_connector_wikipedia"] env = { LANGUAGE = "de", CATEGORIES = "Kategorie:Deep Learning" } ``` ``` flowchart LR A[Your connectors] -- JSON Lines --> B[passageway] B -- "recall tool" --> C[Claude] ``` Before anything else, [what passageway stands for](https://passageway.marceltov.de/values/index.md): the lines it does not cross and what you can rely on. ## Available today - The [connector protocol](https://passageway.marceltov.de/reference/connector-protocol/index.md) and a [guide to writing a connector](https://passageway.marceltov.de/guides/write-a-connector/index.md) - The [Wikipedia connector](https://passageway.marceltov.de/connectors/wikipedia/index.md), in English and German - The [Markdown connector](https://passageway.marceltov.de/connectors/markdown/index.md), for a folder of markdown files with front matter - The [Trilium connector](https://passageway.marceltov.de/connectors/trilium/index.md), the notes of a Trilium server with the LLM-wiki structure of trilium-recall - The [Confluence connector](https://passageway.marceltov.de/connectors/confluence/index.md), the pages of chosen spaces of a Confluence Data Center - Hybrid search with a relevance gate, described in [How it works](https://passageway.marceltov.de/how-it-works/index.md) - A [Claude Code plugin](https://passageway.marceltov.de/guides/claude-code/index.md) with the prompt hook and skills that install, connect, add sources, check and evaluate, a [container image](https://passageway.marceltov.de/deploy/index.md) with the model baked in, and `passageway init` for a local service - An [evaluation harness](https://passageway.marceltov.de/guides/evaluate/index.md), `passageway eval`, that measures retrieval quality on your own questions, compares embedding models and builds a report you can publish - Three built-in models (`passageway model list`), fetched pinned and checksummed; any other through the [embedder protocol](https://passageway.marceltov.de/reference/embedder-protocol/index.md) or the bundled [OpenAI-compatible embedder](https://passageway.marceltov.de/embedders/openai/index.md) ## Coming What is left on the [roadmap](https://passageway.marceltov.de/roadmap/index.md): trilium-recall pointing here. [Choosing a model](https://passageway.marceltov.de/models/index.md) compares the built-in models on the sample corpus. # What passageway stands for passageway is a retrieval service for Claude: connectors feed it the text of your sources, it indexes them, and it serves the best short passages for a question as one `recall` tool. This page says what it will stay, whoever runs it and whatever it grows into. Everything else follows from it: the features, the design decisions in `docs/adr`, and the [roadmap](https://passageway.marceltov.de/roadmap/index.md). It is written for you, if you are deciding whether to trust passageway with your notes, your wiki or your company's documentation. It is also the yardstick for every change: a contribution, from a person or an AI, is judged against these points, and a review names the one it touches ("this breaks V2"). Each point says what it rules out, so that it can be used that way. ## Lines never crossed These three come first. When a feature would be easier, or would read better in a benchmark, by bending one of them, the feature changes or is not built. ### V1. The core knows no source, and no model beyond its default The core speaks two protocols and nothing else: connectors tell it what exists and what the text is, embedders turn text into vectors. A connector is a program of its own, in any language, and a bundled one has no privileges a third-party one lacks. Knowledge of a particular source (how Trilium nests notes, how Confluence pages are typed) reaches the core only as the plain fields of the item record: weight, path, labels, links. The core ships one built-in embedder, the multilingual e5-small model, as a default that works offline; it sits behind the same interface as a program speaking the embedder protocol, and any other model replaces it with one line of configuration. **Rules out:** a source-specific import in the core, a branch for a bundled connector, ranking code that behaves differently for one model by name, a protocol field only one connector understands. **ADRs:** 0001, 0002, 0003. ### V2. Nothing leaves your machine unless you send it The built-in model runs locally and the index is a local file in passageway's data folder. The first start fetches the built-in model from huggingface.co, pinned to a revision and checksummed; that request tells Hugging Face your address and which model you chose, and nothing of your text or questions. Set `download = false` and copy the files in yourself to keep the server from ever contacting it. Text and questions go to a third party only when you configure an embedder that does, and the documentation says what it sends. passageway reports nothing home and never logs a prompt: a recall leaves one log line with the number of hits and the time, nothing of the question. A measurement report holds no question, title or address from your sources unless you ask for them. **Rules out:** telemetry, a cloud model as the default, a report that quotes sources by default, logging prompts or passages, a check that calls a server to see whether a version is current. **ADRs:** none yet; the [evaluate guide](https://passageway.marceltov.de/guides/evaluate/index.md) states what a report holds. ### V3. Numbers are measured, never tuned Retrieval quality is measured with a public harness on a public corpus and a public question set. The headline rates are published with their confidence intervals, and the whole threshold sweep is shown, not the best row. The question set and the measurements page are in git, so every change to a question or a number is visible; when a published figure turns out to be wrong, the correction is its own commit and the page says what changed. What the numbers cannot show is said next to them. **Rules out:** a threshold chosen without showing the sweep, a question changed after seeing a result without a commit that says so, a headline rate without its interval, a measurement page that hides its history, a claim about quality that no published run supports. **ADRs:** none yet; the [measurements page](https://passageway.marceltov.de/measurements/index.md) is the practice. ## What you can rely on Why you can trust passageway between your sources and your model: what it returns, what it keeps, and what happens when a piece breaks. ### V4. Passages, not answers passageway returns short passages, each with its title, its place in the source and its link, and up to three of them. The model that asked writes the answer; passageway never does. When nothing passes the relevance gate, the answer is silence, which the `/recall` endpoint and the tool report as "the sources have nothing", not as an error. **Rules out:** generation or summarisation inside passageway, a "best guess" below the gate, a passage without its source, more than a page of context per question. **ADRs:** none yet; [How it works](https://passageway.marceltov.de/how-it-works/index.md) describes the gate and the budget. ### V5. The index is disposable, the sources stay the truth Everything indexed can be rebuilt from the connectors, so deleting an index file loses nothing, and passageway never writes to a source. A connector's listing decides what exists; a listing that would delete more than a configured share of a source (30 % by default), or all of it, is refused, because it is more likely incomplete than true. **Rules out:** an index as the only copy of anything, a connector that writes, a migration that cannot be redone from the sources, silently emptying a source. **ADRs:** 0001 records the deletion guard. ### V6. A broken piece breaks only itself One source failing to sync fails only its own sync, and a hanging connector delays the others until its timeout; neither stops the server, and nothing hides: `/health` names what is failing, the already indexed items keep serving, and the embedder failing makes `recall` fail rather than fall silent, because silence would look like "nothing relevant". **Rules out:** a server that crashes because a program it started misbehaved, errors swallowed into empty answers, a health check that says "ok" while a part is failing. **ADRs:** 0001, 0003, 0008. ### V7. Secrets are references Secrets in the configuration are `env:NAME` references, never values. Each connector and embedder runs with only `PATH` and `HOME` from the server's environment, plus its own settings and the secrets it names; it never sees another's, nor the server's password. Secrets are masked in logs and errors, and nothing in the documentation asks anyone to print one. **Rules out:** a secret in `passageway.toml`, a connector that can read another's token, credentials in an error message or a log line, a setup step that pastes a key into a chat. **ADRs:** 0001. ### V8. Self-hosted and open passageway is AGPL-3.0-or-later and everything in its documentation runs on your own machine or your own container. There is no hosted service it depends on and no feature kept back for one. The sample corpus is Wikipedia text under CC BY-SA, and it says so. **Rules out:** a feature that only a hosted version has, a license check, a dependency on a service of the author's, content whose license is not stated. **ADRs:** none yet; the licence files are the practice. ## Principles that follow ### V9. Protocols are written down before others build on them A protocol is a page, a JSON Schema and a contract check that anyone can run against their own program, and it carries a version. After v1, a change is a migration with a new version, never a quiet edit; a connector that speaks another version is refused with a message that says so. **Rules out:** a field the core relies on that the page does not mention, a behaviour only the bundled connectors know about, breaking a third-party connector without a version bump. **ADRs:** 0001, 0003. ### V10. Any language, in an afternoon A connector is any executable that prints JSON Lines, and the documentation always shows the bare protocol before the Python SDK, so that nobody needs the core installed to write one. The SDK removes boilerplate; it never adds a capability the protocol lacks. **Rules out:** a connector that must import the core, an SDK-only feature, a guide whose example does not run as a plain script. **ADRs:** 0001, 0003. ### V11. One honest page beats three vague ones The documentation says what exists today and what is coming, and tests keep the main things true: the connector and embedder examples in the guides run and pass the contract checks, every environment variable and every option of `passageway eval` is documented, the bundled connectors' and embedders' settings are in their READMEs, and the agent files say the same as the pages. When the roadmap and a page disagree, the page about what exists wins. **Rules out:** documenting a planned feature as existing, an example that is not tested, a skill that denies what exists, a measurement quoted without its page. **ADRs:** none yet; `tests/test_docs.py` and `tests/test_agent_files.py` are the practice. ## Changing this page Points keep their numbers, so that reviews and issues citing them stay right; a point that no longer holds is marked as withdrawn rather than renumbered. A change needs the maintainer's approval and says why in its pull request. Weakening a line never crossed is a change to what passageway is, and is treated as one. # Install and run passageway runs as a small server on your machine. You give it connectors, it indexes what they report, and an MCP client such as Claude Code asks it for passages. ## In Claude Code The [Claude Code plugin](https://passageway.marceltov.de/guides/claude-code/index.md) does every step on this page for you. Install it first: ``` /plugin marketplace add Marceltov/passageway /plugin install passageway@passageway ``` Then run `/passageway-install`. It asks which sources you want, installs passageway with their connectors, writes each source with `/passageway-add-source`, runs the server as a service that survives reboots, and connects Claude Code with `/passageway-connect`. Run it again later and it upgrades an existing installation to the latest release, with the same connectors. Later, `/passageway-add-source` adds a source and `/passageway-status` checks the whole chain when something is off. No password or token goes through the chat. The steps below are the same setup by hand, for any other MCP client or if you want to see what the skills do. ## 1. Install ``` uv tool install \ --with 'passageway-connector-wikipedia @ git+https://github.com/Marceltov/passageway#subdirectory=connectors/wikipedia' \ git+https://github.com/Marceltov/passageway ``` This installs passageway and the Wikipedia connector into one environment. A connector is a separate package, so add `--with` for every bundled connector you want to run. The [Markdown](https://passageway.marceltov.de/connectors/markdown/index.md), [Trilium](https://passageway.marceltov.de/connectors/trilium/index.md) and [Confluence](https://passageway.marceltov.de/connectors/confluence/index.md) connectors are installed the same way, with `connectors/markdown`, `connectors/trilium` or `connectors/confluence` as the subdirectory and the packages `passageway-connector-markdown`, `passageway-connector-trilium` and `passageway-connector-confluence`. ## 2. The embedding model (`passageway init` prepares the configuration folder with a generated password; [Run it](https://passageway.marceltov.de/deploy/index.md) has the service and container setups.) This is the built-in model. To use another one, see [the embedder](https://passageway.marceltov.de/configuration/#the-embedder). passageway embeds text with the multilingual `e5-small` model in ONNX form, about 120 MB. The server fetches it on first start when it is missing, pinned to a revision and verified by checksum, into `/models/e5-small` (or `PASSAGEWAY_MODEL_DIR`). The first start waits for that download before it answers, so `/health` is reachable only afterwards. To fetch it yourself, or to prepare a machine without network access (download on another machine, copy the folder over, set `download = false`): ``` passageway model list # the built-in presets passageway model download # the default, multilingual-e5-small passageway model download jina-v2-base-de ``` Another preset or model is a line in `passageway.toml`, see [the embedder](https://passageway.marceltov.de/configuration/#the-embedder). ## 3. Say what to index In Claude Code, `/passageway-add-source` writes this for you. Create `~/.config/passageway/passageway.toml` with one `[[connector]]` per source. This one indexes a small corner of English Wikipedia: ``` [[connector]] name = "deep-learning" command = ["/path/to/python", "-m", "passageway_connector_wikipedia"] env = { LANGUAGE = "en", CATEGORIES = "Deep learning", CATEGORY_DEPTH = "0" } ``` A connector runs with an empty environment, so `python` on your `PATH` is usually not the interpreter that has passageway installed. Use the tool's own interpreter, `$(uv tool dir)/passageway/bin/python`, which is the environment from step 1. See [Configuration](https://passageway.marceltov.de/configuration/index.md) for every setting and the [Wikipedia connector](https://passageway.marceltov.de/connectors/wikipedia/index.md) for its options. ## 4. Start it ``` PASSAGEWAY_LOGIN_PASSWORD=choose-one passageway ``` The first start indexes every source, which can take a few minutes. Watch it with: ``` curl http://127.0.0.1:8082/health ``` `"status": "ok"` means every source is built, `"indexing"` means one is still building its first index (the server already answers from what is indexed), and `"degraded"` means a source is failing. Each source has its own `state` (`ok`, `indexing`, `failing`) and, while it fetches, `fetched` and `of`: how far the build is. Add `-H 'Authorization: Bearer choose-one'` to the request to see why; a missing connector package, a wrong `command` path and an unreachable service all show up there. In Claude Code, `/passageway-status` reads the same report and says what to do. ## 5. Connect a client In Claude Code, `/passageway-connect` from the [plugin](https://passageway.marceltov.de/guides/claude-code/index.md) does this and adds the prompt hook. Point an MCP client at `http://127.0.0.1:8082/mcp` and authenticate with `Authorization: Bearer choose-one`. The server offers one tool, `recall`: ask it a question and it returns up to three short passages with links, or says that nothing relevant is indexed. For remote access with a login page instead of a static token, set `PASSAGEWAY_BASE_URL`; see [How it works](https://passageway.marceltov.de/how-it-works/#authentication). # Configuration ## passageway.toml Default location `~/.config/passageway/passageway.toml`, or the path in `PASSAGEWAY_CONFIG`. One `[[connector]]` entry is one source with its own index file, so two servers of the same kind are two entries with the same `command`. Secrets are references to environment variables, never values. In Claude Code, `/passageway-add-source` from the [plugin](https://passageway.marceltov.de/guides/claude-code/index.md) writes the entry for you. ``` [embedder] min_similarity = 0.845 # optional: the relevance gate for the model in use [[connector]] name = "notes" # starts with a-z or 0-9, then a-z, 0-9, _ or -; it names the index file command = ["/path/to/python", "-m", "passageway.testing.fake_connector"] env = { FAKE_ITEMS = "/path/to/items.json" } secrets = { TOKEN = "env:NOTES_TOKEN" } timeout = 300 # seconds per call max_line_bytes = 4194304 # longest line a connector may print max_output_bytes = 67108864 # most output one call may produce max_delete_fraction = 0.3 # a reconcile that would delete more than this share is refused ``` A connector runs with an empty environment (only `PATH` and `HOME`, plus its `env` table and named secrets), so `python` on `PATH` is usually not the interpreter that has passageway installed. For the example above use the tool's own interpreter, `$(uv tool dir)/passageway/bin/python`. See the [connector protocol](https://passageway.marceltov.de/reference/connector-protocol/index.md) for what a connector prints and the [Wikipedia connector](https://passageway.marceltov.de/connectors/wikipedia/index.md) for a bundled one. ## The embedder Without an `[embedder]` section passageway uses its built-in default, the multilingual e5-small, and fetches it once (pinned to a revision, every file verified by checksum) when it is missing. `model` picks another built-in preset: ``` [embedder] model = "jina-v2-base-de" # see `passageway model list` device = "auto" # auto | cpu | coreml download = true # fetch the pinned files when missing (false for an air-gapped machine) ``` To use another model, point `[embedder]` at a program that speaks the [embedder protocol](https://passageway.marceltov.de/reference/embedder-protocol/index.md); the bundled [OpenAI-compatible embedder](https://passageway.marceltov.de/embedders/openai/index.md) is one, for Ollama, LM Studio, vLLM, OpenAI and Voyage: ``` [embedder] kind = "command" # "onnx" (the default) or "command" command = ["/path/to/python", "/path/to/my_embedder.py"] env = { MODEL = "bge-m3" } secrets = { API_KEY = "env:EMBED_KEY" } min_similarity = 0.80 # the relevance gate for this model batch_size = 32 # texts per request timeout = 60 # seconds per request ``` | Key | Kinds | Meaning | | --------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | both | `onnx` is the built-in model; `command` runs your program as one long-lived process | | `min_similarity` | both | The relevance gate, from 0 to 1. Similarity scales differ between models, so measure it with [`passageway eval --sweep`](https://passageway.marceltov.de/guides/evaluate/index.md); a `command` embedder without it gets the built-in model's value and a warning | | `model` | `onnx` | A built-in preset: `multilingual-e5-small` (the default), `multilingual-e5-base`, `jina-v2-base-de`; `passageway model list` shows them with their measured relevance gate | | `device` | `onnx` | `auto` (CPU) or `cpu`; `coreml` is opt-in on a Mac, measured four times slower on these int8 models and giving different vectors, so only for your own experiments | | `download` | `onnx` | `true` fetches the preset's files from huggingface.co (or the mirror in `PASSAGEWAY_MODEL_HUB`) when they are missing or fail their checksum, once, before the server answers; `false` makes a missing model an error, with `passageway model download ` as the step to run | | `model_dir` | `onnx` | Where the preset's files are. Default: `PASSAGEWAY_MODEL_DIR` for the default preset (itself `/models/e5-small`), else `/models/`; `passageway model` uses the same rule. Set, it wins over `PASSAGEWAY_MODEL_DIR` and `--model-dir` | | `command`, `env`, `secrets` | `command` | The program, its settings and named secrets (references to environment variables), run with an empty environment like a connector | | `batch_size`, `timeout` | `command` | At most this many texts per request (1 to 1024); seconds per request, after which the process is killed. The first request, which loads the model, is under the same limit | A typo in a key is an error. Changing the embedder rebuilds every index at the next start, because vectors of different models cannot be compared. To compare models, add `[embedders.NAME]` tables with the same keys. The server ignores them; [`passageway eval compare`](https://passageway.marceltov.de/guides/evaluate/#compare-embedding-models) runs your questions with each: ``` [embedders.e5] kind = "onnx" [embedders.bge] kind = "command" command = ["/path/to/python", "/path/to/my_embedder.py"] min_similarity = 0.80 ``` ## Environment | Variable | Default | Meaning | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PASSAGEWAY_LOGIN_PASSWORD` | required | Static Bearer token, and the OAuth login page's password | | `PASSAGEWAY_LOGIN_PASSWORD_FILE` | unset | A file holding the password, read when the variable is unset; `passageway init` writes one (see [Run it](https://passageway.marceltov.de/deploy/index.md)) | | `PASSAGEWAY_INSTANCE` | unset | Names one of [several instances](#several-instances) on this machine; it moves the default config and data folders to `passageway-`. For a service; on the command line `--instance NAME` says the same and wins | | `PASSAGEWAY_CONFIG` | `~/.config/passageway/passageway.toml` | Connector file | | `PASSAGEWAY_BASE_URL` | unset | Public https URL; set, it turns on OAuth for `/mcp` | | `PASSAGEWAY_DATA_DIR` | `~/Library/Application Support/passageway` on macOS, `~/.local/share/passageway` elsewhere | Index files and OAuth state | | `PASSAGEWAY_MODEL_DIR` | `/models/e5-small` | The embedding model | | `PASSAGEWAY_HOST`, `PASSAGEWAY_PORT` | `127.0.0.1`, `8082` | Where to listen | | `PASSAGEWAY_POLL_SECONDS`, `PASSAGEWAY_RECONCILE_SECONDS` | `60`, `1800` | Change polling and full reconcile | | `PASSAGEWAY_TOOL_DESCRIPTION`, `PASSAGEWAY_INSTRUCTIONS` | neutral text | What MCP clients read about the `recall` tool | | `PASSAGEWAY_LOG_LEVEL` | `INFO` | | | `APP_VERSION` | the installed version | Version string shown by `/health` and `status`; set it only to override the package's version | ## Several instances Several Claude setups side by side (yours, your work's, a customer's) can each have their own passageway, with their own sources, index and password. Give each instance a name and a port of its own: ``` passageway --instance work init PASSAGEWAY_PORT=8083 PASSAGEWAY_LOGIN_PASSWORD_FILE=~/.config/passageway-work/password passageway --instance work passageway --instance work eval cases.toml ``` The instance reads `~/.config/passageway-work/passageway.toml` and keeps its index in the data folder with `-work` appended (`~/Library/Application Support/passageway-work` on macOS). The built-in models stay in the shared data folder, so they are downloaded once. `/health` names the instance. Every command takes `--instance NAME`, anywhere on the line; a service sets `PASSAGEWAY_INSTANCE` instead, and the argument wins when both are there. An instance must exist: `--instance wrok` is an error that lists the instances there are, and only `init` creates one. Without an instance nothing changes, unless the machine has only named instances: then a bare `passageway` or `passageway eval` asks you to pick one rather than run against an empty default. In Claude Code, `/passageway-install` asks for the name, picks a free port and runs each instance as its own service. Each Claude setup connects to the instances you choose, one or several: their hook files and MCP registrations live in its own `CLAUDE_CONFIG_DIR`, so `/passageway-connect` run in that setup touches no other. The hook asks every connected instance at once and joins their passages; the `recall` tool of instance `work` is the MCP server `passageway-work`. This keeps sources, indexes and connections apart; it is not a security boundary. Under one operating-system user, a Claude with a shell can read another instance's password file and call its port, so which Claude connects to which instance is up to you. For a boundary that holds, run each instance as its own user or in its own [container](https://passageway.marceltov.de/deploy/index.md). ## Certificates Every HTTPS request passageway makes, from the core's model download and from the bundled connectors and embedder, checks the server's certificate against your operating system's trust store: the Keychain on macOS, the certificate store on Windows, `/etc/ssl` on Linux. A server whose certificate comes from a company or internal CA works as soon as that CA is trusted there, the way Safari and curl see it; no exported bundle in `SSL_CERT_FILE` is needed. Only the Confluence connector can turn checking off, with `VERIFY_TLS = false`. ## Rebuilding a source Each source is one file, `/.db`, and it can always be rebuilt from the connector: stop the server, delete the file, start it again. Do this after changing the embedding model, after removing a connector from the config (its items stay searchable until then), and when the deletion guard keeps refusing a legitimate bulk delete (it refuses to remove more than `max_delete_fraction` of a source, and always allows a single item; it also refuses to empty a source of two or more items). ## Health `GET /health` is open so a health check can use it. It reports each source's item count and whether it is failing; the error text itself is shown only when the request carries the Bearer password. The same report, errors included, is the MCP tool `status`, so a connected client asks the server itself instead of curl; a server started with a broken configuration answers `{"status": "error", "error": ...}` from both. # Run it: as a service or as a container Two ways to keep passageway running. Both use the same code; only defaults differ. In Claude Code, `/passageway-install` from the [plugin](https://passageway.marceltov.de/guides/claude-code/index.md) sets up the service below for you, with launchd or systemd. ## On your own machine `passageway init` prepares the configuration folder once: ``` passageway init ``` It creates `~/.config/passageway/` with a generated `password` (readable only by you; the Bearer token for the hook and the tool), and a `passageway.toml` to fill in with your connectors if there is none yet. It prints the two things to do next: edit the file, and start the server with the password file: ``` PASSAGEWAY_LOGIN_PASSWORD_FILE=~/.config/passageway/password passageway ``` The server binds to `127.0.0.1:8082`, fetches the default model on first start, and answers at `http://127.0.0.1:8082/health`. Connect Claude Code with the [plugin](https://passageway.marceltov.de/guides/claude-code/index.md). ### Keep it running with launchd (macOS) Save this as `~/Library/LaunchAgents/de.marceltov.passageway.plist`, with the path of your `passageway` (`which passageway`): ``` Labelde.marceltov.passageway ProgramArguments /Users/you/.local/bin/passageway EnvironmentVariables PASSAGEWAY_LOGIN_PASSWORD_FILE/Users/you/.config/passageway/password PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin RunAtLoad KeepAlive StandardOutPath/Users/you/Library/Logs/passageway.log StandardErrorPath/Users/you/Library/Logs/passageway.log ``` Then `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/de.marceltov.passageway.plist`. The connectors run with an empty environment plus `PATH` and `HOME`, so the `PATH` above must reach the interpreter named in your `passageway.toml`. On Linux, a systemd user unit with the same two environment variables does the same (`EnvironmentFile=-%h/.config/passageway/env` is the place for the connectors' tokens). The plugin's `/passageway-install` skill writes either one for you, with a small wrapper that reads `~/.config/passageway/env` (`NAME=value` lines, readable only by you) so the tokens your connectors reference as `env:NAME` never appear in the plist. ## As a container The image `ghcr.io/marceltov/passageway` is built from the repository's `Dockerfile` on every release, for amd64 and arm64: the server with every bundled connector and embedder, and the default model baked in and verified by checksum, so the container fetches nothing at start as long as it uses the default model (another preset is fetched into `/data/models` once). It runs as an unprivileged user. ``` services: passageway: image: ghcr.io/marceltov/passageway:latest ports: ["8082:8082"] volumes: - passageway-data:/data - ./passageway.toml:/config/passageway.toml:ro environment: PASSAGEWAY_LOGIN_PASSWORD: ${PASSAGEWAY_LOGIN_PASSWORD} PASSAGEWAY_BASE_URL: https://passageway.example.org # the public address, for claude.ai's login TRILIUM_TOKEN: ${TRILIUM_TOKEN} # whatever your connectors' secrets reference volumes: passageway-data: ``` Inside the container the defaults are `PASSAGEWAY_HOST=0.0.0.0`, `PASSAGEWAY_DATA_DIR=/data`, `PASSAGEWAY_CONFIG=/config/passageway.toml` and `PASSAGEWAY_MODEL_DIR=/models/e5-small`; the health check calls `/health` every minute. The bundled connectors are installed, so a `command` of `["python", "-m", "passageway_connector_trilium"]` works as written (the venv's `python` is first on the `PATH`). A connector of your own needs a derived image that adds it, or a mount. `:latest` moves only on pushed version tags, so a watcher that pulls `latest` installs each release on its own. The image is a few hundred megabytes, most of it the model and the ONNX runtime. # Concepts # How it works ## The pieces ``` flowchart LR subgraph Sources A[Wikipedia connector] B[Your connector] end A -- JSON Lines --> S B -- JSON Lines --> S subgraph passageway S[Sync] --> I[("Index per source
keywords + vectors")] E[Embedder] --> I I --> R[Ranking and gate] R --> M["recall tool
and /recall hook"] end M --> C[Claude] ``` Each connector is one **source** with its own SQLite file in the data directory. The sync loop asks the connector what exists and what changed, the embedder turns every section into a vector, and the index keeps both the text (for keyword search) and the vectors. The server answers questions from the indexes. ## A connector call The core never imports a connector. It starts a process, reads its output, and stores something only if the call succeeded completely. ``` sequenceDiagram participant Core as passageway participant Conn as connector process Core->>Conn: start "command fetch" with an empty environment Core->>Conn: ids on stdin, one JSON object per line Conn-->>Core: one item per line on stdout Conn-->>Core: exit status 0 Note over Core: only now are the items stored Core-xConn: on a timeout the whole process group is killed and nothing is stored ``` The environment contains only `PATH`, `HOME`, `PASSAGEWAY_PROTOCOL`, the connector's own `env` table and the secrets it names, so one connector never sees another's credentials or the server's password. A line over `max_line_bytes`, output over `max_output_bytes`, a malformed line, an id that was not requested, a non-zero exit and a timeout all fail the call. `fetch` is called with at most 50 ids at a time, so a failing call loses only its own ids: the next calls still run, the sync is reported as failed, and the missing items are asked for again at the next one. The [protocol reference](https://passageway.marceltov.de/reference/connector-protocol/index.md) has the details. ## Keeping the index current - **Start:** a source with no index yet (or one built with another embedder) is built in full; an existing index is reconciled instead. - **Reconcile**, every 30 minutes (`PASSAGEWAY_RECONCILE_SECONDS`): the connector's `list` returns every item with its version. Items with a new version are fetched and re-embedded, items that disappeared are deleted. - **Poll**, every minute (`PASSAGEWAY_POLL_SECONDS`): the connector's `changed --since` returns recent changes, so edits show up quickly. A connector that cannot answer this cheaply prints nothing and its edits wait for the next reconcile. - **Deletion guard:** a reconcile that would remove more than `max_delete_fraction` of a source (30 % by default), or empty a source of two or more items, is refused and reported in `/health`. A broken listing therefore cannot wipe an index. A source that fails keeps serving what it has; the other sources are not affected. ## Answering a question ``` flowchart TD Q[question] --> F["keyword search
top 50"] Q --> V["embedding search
top 50"] F --> X["fuse by rank,
scale by item weight"] V --> X X --> B[best section per item] B --> G{"similarity at or above the gate,
or three query words match?"} G -- yes --> T["top 3 passages
and related links"] G -- no --> N[say nothing] ``` 1. **Candidates.** Keyword search (SQLite FTS5, BM25) and embedding search (cosine similarity on the vectors) each return their 50 best sections. 1. **Fusion.** The two rankings are combined by reciprocal rank, together with the cosine similarity. The score is multiplied by the item's `weight`, which a connector can use to rank one kind of content above another. The hook adds a small boost when a word from the last two folder names of the client's working directory appears in an item's title, path or labels. 1. **One hit per item.** Only the best section of each item competes. 1. **The gate.** A hit must reach `min_similarity` (the cosine threshold of the embedding model) or match at least three distinct query words. Anything below returns nothing at all. Similarity scales differ between embedding models, so the threshold belongs to the model. 1. **The answer.** Up to three passages with title, path, link and a short excerpt, plus up to three related items, trimmed to a budget of roughly 400 tokens. ## The hook endpoint Besides the MCP tool, the server has `POST /recall` for a Claude Code prompt hook. It takes `{"prompt", "session_id", "cwd"}` and returns the context to inject. It skips prompts that are not questions (slash commands, "ok", "weiter", short follow-ups without a content word) and remembers per session which items it already sent. It accepts only the static bearer password. The Claude Code plugin that installs the hook is not shipped yet. ## Authentication - **Bearer password (always):** `Authorization: Bearer ` works for every API route except `/health`. (With OAuth on, the OAuth discovery, registration, authorize and token routes are open by design, as the protocol requires.) - **OAuth login (optional):** set `PASSAGEWAY_BASE_URL` to the public https address and `/mcp` also accepts tokens from a login page protected by the same password. This is what remote MCP clients such as claude.ai need. The hook endpoint stays on the static password. - **`/health`** is open, but shows connector error text only to a caller with the bearer password. # Guides # Work with an AI agent passageway publishes three entry points for an AI agent, all built from this repository. | Entry point | What it is | Use it when | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | [`/SKILL.md`](https://passageway.marceltov.de/SKILL.md) | A general guide for agents: what passageway is, how to install and run it, which route to take for what the human wants. The plugin installs it as the skill `/passageway` | You tell any agent that can fetch a URL: "Read passageway.marceltov.de/SKILL.md and help me set up passageway" | | The plugin skill [`/create-passageway-connector`](https://passageway.marceltov.de/skills/create-passageway-connector/SKILL.md) | A step-by-step skill for writing, testing and registering a connector | You use Claude Code and want a connector for a new source | | The plugin skills [`/passageway-install`](https://passageway.marceltov.de/skills/passageway-install/SKILL.md), [`/passageway-connect`](https://passageway.marceltov.de/skills/passageway-connect/SKILL.md) and [`/passageway-add-source`](https://passageway.marceltov.de/skills/passageway-add-source/SKILL.md) | Install the server and run it as a service; link Claude Code to a running server (the hook and the MCP server); write a source into `passageway.toml`; [`/passageway-evaluate`](https://passageway.marceltov.de/skills/passageway-evaluate/SKILL.md) measures retrieval quality on the user's own questions; [`/passageway-status`](https://passageway.marceltov.de/skills/passageway-status/SKILL.md) checks the server, the service, the hook and the registration | You use Claude Code and want it set up without reading the docs yourself | | [`/llms.txt`](https://passageway.marceltov.de/llms.txt) and [`/llms-full.txt`](https://passageway.marceltov.de/llms-full.txt) | An index of the pages, and the whole documentation as one file | An agent should have all the details in its context | ## Install the plugin in Claude Code ``` /plugin marketplace add Marceltov/passageway /plugin install passageway ``` The plugin brings seven skills: `/passageway`, the general guide, `/passageway-install`, `/passageway-connect`, `/passageway-add-source`, `/passageway-evaluate`, `/passageway-status` and `/create-passageway-connector`, plus the prompt hook ([Claude Code](https://passageway.marceltov.de/guides/claude-code/index.md)). Every skill names the documentation index and reads the page it needs before acting. Without the plugin, the connector skill is at [`/skills/create-passageway-connector/SKILL.md`](https://passageway.marceltov.de/skills/create-passageway-connector/SKILL.md); an agent can read it from there. ## What the skills do and do not do - They tell the agent the rules of a connector (stable ids, a version that changes exactly with the content, `list` as the truth for deletions) and how to prove them with the shared contract check. - They never ask for a secret. Credentials stay in environment variables and `env:NAME` references in `passageway.toml`; the human starts the server and runs the commands that need the password, and the agent reads their output. - They describe only what exists. The planned features are named as not available. ## Trust and pinning A skill is a set of instructions that an agent follows, and the file on the site can change with every release. Read it before you hand it to an agent. To use exactly the version of a release, fetch it from that release's tag on GitHub: `https://raw.githubusercontent.com/Marceltov/passageway/v/skills/passageway/SKILL.md`. That works from the first release that includes the skills, which is the one after v0.1.0. The version in the file's header, the plugin and the release tag are checked to be equal when a release is made. ## How the files stay true The example connector in the skill is the same text as the one in [Write a connector](https://passageway.marceltov.de/guides/write-a-connector/index.md), and a test runs it through the contract check. Other tests check the skill headers, that no skill contains a credential, and that every page of the documentation is in `llms-full.txt`. # Claude Code: the hook and the plugin Claude Code can use passageway in two ways, and the plugin sets up both: - **The `recall` tool** over MCP, which the model calls when it decides to search. The server sends instructions on connect that say when to call it. - **A prompt hook** (`UserPromptSubmit`) that sends every prompt to the server and adds up to three short passages to the context when the sources have something, and nothing otherwise. The hook exists because a model only calls a tool when it thinks it needs one, and skips it when it believes it knows the answer; the hook does not ask. ## Install the plugin ``` /plugin marketplace add Marceltov/passageway /plugin install passageway@passageway ``` This gives you the hook, `/passageway-install` that installs the server on this machine and runs it as a service (and, run again, upgrades it to the latest release), the `/passageway-connect` skill that connects it, `/passageway-add-source` that writes a source into `passageway.toml`, `/passageway-evaluate` that measures retrieval quality on your own questions, `/passageway-status` that checks the whole chain when something is off, the `/passageway` guide and `/create-passageway-connector`. Run `/passageway-install` for a local server, or `/passageway-connect` for one that already runs, and give it the server's URL; it checks `/health`, writes the hook's config and registers the MCP server. A fresh `passageway.toml` has no source yet: `/passageway-add-source` writes the `[[connector]]` for a folder of markdown, Trilium, Confluence or Wikipedia, with secrets as `env:` references, and tells you how to restart. Never paste the password into the chat: the skill reads it from `~/.config/passageway/password` for a local server, or you pass it through a variable. ## How the hook behaves - It reads `passageway.json` (`{"url", "token"}`), and one `passageway-.json` per further [instance](https://passageway.marceltov.de/configuration/#several-instances), from the plugin's data folder or from `${CLAUDE_CONFIG_DIR:-~/.claude}/plugin-data/passageway/`, where the connect skill writes them, readable only by you. With several, it asks them all at once and joins their passages. A `recall.json` left by trilium-recall is used until it is replaced. - It posts the prompt (the first 20,000 characters), the session id and the working directory to `POST /recall`. The password and the prompt go through files only you can read, never on a command line. - It waits at most 1.5 s. A slow or broken server, no config, a bad answer: no output, exit 0. It never blocks a prompt. - What comes back is the rendered passages (`context`), which it hands to Claude as additional context. The server remembers per session what it already showed, so a passage is not repeated. - `bash "$CLAUDE_PLUGIN_ROOT/scripts/test-recall-hook.sh"` checks the hook against a stub server: a hit, no hit, errors, a slow server, a huge prompt, both config folders, several instances, and that neither the password nor the prompt shows up in the process table; `scripts/test-recall-hook.sh` in the repository is the same file. ## Without the plugin The hook is one shell script, `scripts/recall-hook.sh`; any hook runner can call it with the Claude Code hook input on stdin. The MCP registration is one command: ``` claude mcp add --transport http passageway "http://127.0.0.1:8082/mcp" -H "Authorization: Bearer $(cat ~/.config/passageway/password)" -s user ``` On claude.ai, the desktop app or Cowork there is no hook: add the server's public HTTPS URL ending in `/mcp` as a custom connector and log in with the password (the server needs `PASSAGEWAY_BASE_URL` for that login page, see [Install](https://passageway.marceltov.de/install/index.md)). # Measure retrieval quality `passageway eval` asks your sources the questions you care about and reports how often the right passage comes back, and how often passageway correctly stays silent when nothing fits. Run it before you change the embedding model or the relevance threshold, and in CI to catch a regression. The numbers it prints are yours to publish: by default a report contains no question, no title and no address from your sources (it does carry the source names and group labels you chose, see below). ## Write the questions In Claude Code, `/passageway-evaluate` from the [plugin](https://passageway.marceltov.de/guides/claude-code/index.md) does this with you: it drafts questions per area of your sources, confirms the right note for each through `recall`, writes the file and reads the result back. The rest of this page is what it does, for doing it by hand. A cases file is TOML. Each `[[case]]` is a question and the answers that count as right. ``` [targets] # optional: what --enforce checks hit_at_3 = 0.8 silence = 0.8 p95_ms = 300 [[case]] query = "what is the vanishing gradient problem" expect = ["Vanishing gradient problem"] [[case]] query = "wie funktioniert Backpropagation" expect = ["Backpropagation"] # matches the English and the German article group = "german questions" [[case]] query = "what is on the cafeteria menu" # no expect: nothing should be found ``` - **`expect`** lists texts that must be part of a hit's title, heading or address (case does not matter). Any one of them in any of the top three hits counts as a hit. A title that exists in two languages is matched by one entry. - **`expect_id`** lists exact item ids, for a title that is ambiguous. - **No `expect` at all** marks a question the index must stay silent about. Any hit counts against the silence score. - **`group`** is an optional label; the report shows the scores per group. - Questions never say which source to search, because `recall` searches all of them. A typo in a key is an error (it would otherwise silently turn a question into one that must stay silent). ## Run it ``` passageway eval cases.toml ``` It reads your `passageway.toml` and the embedding model like the server does (`PASSAGEWAY_CONFIG` and `PASSAGEWAY_MODEL_DIR`), builds every source into a **throwaway index** so it measures a fresh build and never touches your live one, asks the questions in-process, and prints a report. No server, token or password is needed. | Option | What it does | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--config PATH`, `--model-dir DIR` | Use another `passageway.toml`, or another folder for the default built-in model (a non-default preset keeps `/models/` or its `model_dir`) | | `--data-dir DIR` | Keep the index in `DIR` and reuse it on the next run, which is faster while you refine questions. An index built with another embedding model is refused. Do not point it at your server's live data folder: eval would sync that index while the server is using it | | `--threshold X` | Use `X` as the relevance threshold (`min_similarity`) for this run | | `--sweep 0.80,0.84,0.88` | Also run at each of these thresholds and show them side by side. The index is built once, so a sweep costs little. This is how you choose a threshold for a model | | `--enforce` | Exit 1 when a value in `[targets]` is not met, for CI. A cases file that has no `[targets]` gives a warning and exit 0, because there is nothing to check | | `--out DIR` | Write `report.json` (for machines) and `report.md` into `DIR` | | `--write PAGE` | Update the block between `` and `` in your own page, and keep a history of runs next to it. The page is checked before the run, so a missing marker does not cost a measurement | | `--include-cases` | Also report every question, what was found and its rank. This quotes your sources, so leave it off for anything you publish | Exit codes: 0 for a finished run, 1 for `--enforce` below the targets or a source that failed to sync (a partial index would give misleading numbers), 2 for a bad cases file, option or configuration. ## Read the report | Metric | Meaning | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Hit@1, Hit@3 | Share of answerable questions whose first, or one of the first three, hits is accepted | | MRR | Mean of 1 / rank of the first accepted hit; 1.0 means always first | | Silence | Share of unanswerable questions for which nothing was returned. Low silence means noise reaches the model | | False silence | Share of answerable questions for which nothing was returned. High means the threshold is too strict | | Latency p50 / p95 | Time to answer, including embedding the question; the report also shows the embedding's share. A warm-up question is not counted | Every rate is followed by its **95% interval** in brackets, for example `85.0% (77–91%)`: given this many questions, the true rate could lie anywhere in it (a Wilson interval; it is also in `report.json` under `intervals`). The interval is not symmetric near 0% and 100%. Two runs on the same questions are paired, so overlapping intervals can hide a real change: they warn against over-reading a difference, they do not test for one. The sweep and history tables show point values only. With 40 answerable questions a rate of 85% has an interval of about ±11 points, with 100 about ±7, and a group of 8 questions is only a hint. A metric with no question behind it shows `n/a` and fails a target on it: with no unanswerable question, silence is not 100%, it is unknown. Silence and false silence pull against each other as the threshold moves, and `--sweep` shows where they cross for your sources and your model. One rule limits what a threshold can do: a passage that shares **three or more words** with the question is returned whatever its similarity (the keyword rescue in [How it works](https://passageway.marceltov.de/how-it-works/index.md)). A long, specific question therefore stays found at any threshold, and a sweep silences only short or loosely worded ones. Word your unanswerable questions the way a user would, not as strings of rare words, or they will not measure the threshold. A target is compared with the value the report shows: two of three questions is 66.7%, which meets a target of 0.667. ## Publish the numbers Put the two markers, each **on a line of its own**, in a page of your docs, say `docs/measurements.md`, and run: ``` passageway eval cases.toml --write docs/measurements.md ``` The block between the markers is replaced with the run, the metrics, the groups and the targets, followed by a table of the last ten runs. Markers inside a code block are ignored, so a page can explain them, and the page keeps its line endings. Running it again with the same date, model, threshold and passageway version replaces that run instead of adding one. The history lives in `docs/measurements.history.json` and belongs in version control. The report records the date, the passageway version, the embedding model, the threshold, the number of items and sections per source (under the source names from your `passageway.toml`), the **group labels** you chose, and a machine line (operating system, processor architecture and Python version, so that latencies can be put in context). It never records a host name. The source names and group labels are your own words, so pick ones you are happy to publish. To fail a CI job on a regression, put `[targets]` in the cases file and add `--enforce`. ## Compare embedding models ``` passageway eval compare cases.toml ``` runs the same questions once per embedder and puts them side by side. The candidates are the `[embedders.NAME]` tables of your `passageway.toml` (see [Configuration](https://passageway.marceltov.de/configuration/#the-embedder)), and `default` is the `[embedder]` the server uses. Each candidate gets its **own fresh index** and runs at **its own `min_similarity`**, because similarity scales differ between models; comparing them at one shared threshold would favour whichever model that value happens to suit. The report names the threshold of each. | Option | What it does | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `--embedder NAME` | Compare only these candidates; repeat it. At least two are needed. Default: `default` and every `[embedders.NAME]` | | `--config PATH`, `--model-dir DIR` | As for a single run | | `--threshold X` | Use one threshold for every candidate instead of their own | | `--sweep 0.80,0.84,0.88` | Also show every candidate at these thresholds, one table each | | `--out DIR` | Write `report.json` (`"kind": "compare"`, one entry per candidate) and `report.md` | | `--write PAGE` | Update the marked block of your page with the comparison, and add one history line per embedder | Each row shows the model's name and dimensions, the **load** time (starting the model) and the **index** time (embedding every source), then the quality metrics with their intervals. Every embedder is started before the first index is built, so one that cannot start costs nothing; it also means they are all loaded at once for a moment. Exit codes: 0 for a finished comparison, 1 for a source that failed to sync or an embedder that failed on the questions, 2 for bad input or an embedder that cannot start. The comparison holds no question, title or address. ## Not yet The [sample corpus](https://passageway.marceltov.de/reference/corpus/index.md) has a public set of 100 questions, the [published measurements](https://passageway.marceltov.de/measurements/index.md), which show the whole sweep, and a [comparison of the built-in models](https://passageway.marceltov.de/models/index.md). The [roadmap](https://passageway.marceltov.de/roadmap/index.md) has the order of what comes next. # Write a connector A connector is a program that answers three questions about a source: *what exists*, *what is in it*, and optionally *what changed*. It can be written in any language. This guide builds one in Python for a folder of text files. ## The connector ``` #!/usr/bin/env python3 """A passageway connector for a folder of .txt files. Setting: FOLDER.""" import hashlib import json import os import sys from pathlib import Path folder = Path(os.environ["FOLDER"]).resolve() files = sorted(p for p in folder.rglob("*.txt") if p.is_file()) def item_id(path): return path.relative_to(folder).as_posix() def version(path): return hashlib.sha256(path.read_bytes()).hexdigest()[:12] def emit(record): print(json.dumps(record)) mode = sys.argv[1] if mode == "check": emit({"ok": True, "count": len(files)}) elif mode == "list": for p in files: emit({"id": item_id(p), "version": version(p)}) elif mode == "fetch": wanted = {json.loads(line)["id"] for line in sys.stdin if line.strip()} for p in files: if item_id(p) in wanted: emit({"id": item_id(p), "title": p.stem, "url": p.as_uri(), "version": version(p), "sections": [{"heading": None, "text": p.read_text(encoding="utf-8")}]}) # "changed" is optional: print nothing, and edits show up at the next reconcile. ``` passageway runs it as ` `: | Mode | What it must print | | ------------------- | ------------------------------------------------------------------------- | | `check` | `{"ok": true, "count": n}` | | `list` | one `{"id", "version"}` line for **every** item that exists | | `fetch` | an item record for each id it receives on stdin, for ids that still exist | | `changed --since T` | optional: items changed since the UTC time `T` | ## The same connector with the SDK The script above shows the whole protocol, and a connector in any language can be written that way. In Python, the `passageway-sdk` package does the plumbing for you: it dispatches the mode, reads the ids from stdin, buffers the output so that a failed call prints nothing, exits 2 for bad settings and 1 for a failing source, validates each item before printing it, and keeps the JSON ASCII-safe. You write what is specific to the source: ``` #!/usr/bin/env python3 """The folder connector with the SDK. Setting: FOLDER.""" import hashlib from pathlib import Path from passageway_sdk import Connector, SettingsError class Folder(Connector): name = "folder connector" def __init__(self, env): self.root = Path(env.get("FOLDER", "")).resolve() if not self.root.is_dir(): raise SettingsError("FOLDER must be an existing folder") def files(self): return {p.relative_to(self.root).as_posix(): p for p in sorted(self.root.rglob("*.txt"))} def version(self, path): return hashlib.sha256(path.read_bytes()).hexdigest()[:12] def versions(self): return {i: self.version(p) for i, p in self.files().items()} def fetch(self, ids): for i, p in self.files().items(): if i in ids: yield {"id": i, "title": p.stem, "url": p.as_uri(), "version": self.version(p), "sections": [{"heading": None, "text": p.read_text(encoding="utf-8")}]} Folder.main() ``` Try it by hand with the SDK added for one run: `FOLDER=notes uv run --with 'passageway-sdk @ git+https://github.com/Marceltov/passageway#subdirectory=sdk' python folder_sdk.py list`. To run it under the server, install the SDK into passageway's own environment by adding `--with 'passageway-sdk @ git+https://github.com/Marceltov/passageway#subdirectory=sdk'` to the `uv tool install` command in [Install](https://passageway.marceltov.de/install/index.md), and register the connector with that environment's interpreter, `$(uv tool dir)/passageway/bin/python` (a connector runs with an empty environment, so the `python` on your `PATH` has neither passageway nor the SDK). In a project of your own, `uv add` the same address instead. The methods, the errors and the exit codes are in the [SDK reference](https://passageway.marceltov.de/reference/sdk/index.md). Implement `changed(self, since)` as well if your source can answer it cheaply; without it, edits appear at the next reconcile. ## Try it by hand ``` mkdir notes && echo "Lighthouses guide ships." > notes/a.txt FOLDER=notes python folder_connector.py list echo '{"id": "a.txt"}' | FOLDER=notes python folder_connector.py fetch ``` ## Test it passageway ships the checks every connector must pass. Call them from a pytest test: ``` import sys from pathlib import Path from passageway.protocol import Connector from passageway.testing.contract import check_connector def test_folder_connector(tmp_path): (tmp_path / "a.txt").write_text("Lighthouses guide ships.") script = str(Path(__file__).with_name("folder_connector.py")) connector = Connector("notes", [sys.executable, script], env={"FOLDER": str(tmp_path)}) check_connector(connector) ``` ## Register it ``` [[connector]] name = "notes" command = ["/usr/bin/python3", "/path/to/folder_connector.py"] env = { FOLDER = "/path/to/notes" } ``` ## Rules that keep it correct - **Ids are stable**, and unique within the connector. - **The version changes when the content changes**, and only then. passageway fetches only items whose version differs from the one it has, so a version that changes on every call re-embeds everything every time. - **`list` is the source of truth for deletions.** Print every existing item; an item missing from `list` is deleted. If your listing can be incomplete, exit non-zero instead of printing less. - **`fetch` returns only requested ids**, and skips ids that no longer exist. - **A failure means a non-zero exit and nothing worth keeping on stdout.** passageway stores nothing from a failed call. Write diagnostics to stderr, never secrets. - **Optional fields** shape the ranking and the answer: `weight` (a multiplier), `path` (a breadcrumb), `labels`, `links` (ids of related items) and `updated`. See the [protocol reference](https://passageway.marceltov.de/reference/connector-protocol/index.md). - **Secrets** come in through the environment from the `secrets` table in `passageway.toml`, never as arguments. # Reference # Connector protocol A connector is any executable that lists items and returns their text as JSON Lines. passageway runs it, indexes the result and never needs to know the source. This page is the protocol reference (v1). A machine-readable version of the messages is the [JSON Schema](https://passageway.marceltov.de/reference/connector-protocol.schema.json); validate a connector's output against it from any language. The core runs ` `. stdout is one JSON object per line. stderr goes to the log with secrets masked. A non-zero exit fails the call. | Mode | Input | Output | | --------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `check` | none | `{"ok": true, "count": 42}`, optionally with `"protocol": "1"` (a connector that names another version is refused) | | `list` | none | `{"id", "version"}` for every existing item | | `fetch` | `{"id"}` lines on stdin | an item record per requested id that still exists | | `changed --since ` | none | an item record per item changed since then; optional, a connector that cannot answer cheaply prints nothing and its edits appear at the next reconcile | Item record: ``` {"id": "abc", "title": "Rotating certificates", "url": "https://…", "version": "3f2a", "sections": [{"heading": "Steps", "text": "Rotate the signing cert every year…"}], "weight": 1.0, "path": ["Ops", "Security"], "links": ["def"], "labels": ["decision"], "updated": "2026-10-28T09:00:00Z"} ``` - Required: `id`, `title`, `url`, `version`, `sections` (may be empty; a section has optional `heading` and required `text`). - Optional: `weight` (multiplier on the ranking score, default 1.0), `path` (breadcrumb), `links` (ids of related items, shown with a hit when they are indexed), `labels` (short tags), `updated`. - The core builds the embedded text as `title › path › heading: text` and splits any section over a size cap at paragraph boundaries, so a connector cannot send an oversize section. - `version` is any string that changes when the content changes; the core fetches only items whose version differs. - The connector receives an empty environment plus `PATH`, `HOME`, `PASSAGEWAY_PROTOCOL=1`, its `env` table and its named secrets. It never sees the core's password or another connector's secrets. - A call succeeds completely or stores nothing. The core asks `fetch` for at most 50 ids per call and stores each call on its own, so a call that fails costs only its own ids: the core goes on with the next ones, reports the failure, and asks again at the next sync. Two failing calls in a row stop the fetch until then. - `list` decides deletions. The deletion guard refuses to remove more than 30 % of a source (configurable, and never less than one item) or all of a source that has two or more items, and reports an error instead. - A failing connector never stops other sources; its already indexed items stay searchable. Line size, call size and timeout have configurable defaults. ## Try it Save a list of item records (see above) as `items.json`, then: ``` FAKE_ITEMS=items.json python -m passageway.testing.fake_connector list FAKE_ITEMS=items.json python -m passageway.testing.fake_connector check ``` and point a `[[connector]]` at that command in `passageway.toml` (see [Install](https://passageway.marceltov.de/install/index.md)). In Python, the [SDK](https://passageway.marceltov.de/reference/sdk/index.md) does the plumbing of this protocol for you. To test your own connector, call `passageway.testing.contract.check_connector(Connector("mine", [...]))` from a pytest test: it checks the rules above that a test can check. # Sample corpus A small, fixed set of Wikipedia articles on **deep learning**, 30 in English (`en/`) and 30 in German (`de/`), so that measurements of retrieval quality can be repeated by anyone against the same text. The articles are CC BY-SA 4.0, not AGPL: see [LICENSE.md](https://github.com/Marceltov/passageway/blob/main/corpus/LICENSE.md). The files are the corpus. Each has front matter with its title, address, revision and licence, and its sections are `##` headings. Wikipedia changes every day, so nothing is fetched at test time: the revision in each file says which version it is. The [markdown connector](https://passageway.marceltov.de/connectors/markdown/) indexes a folder of them: ``` [[connector]] name = "corpus-en" command = ["/path/to/python", "-m", "passageway_connector_markdown"] env = { FOLDER = "/path/to/passageway/corpus/en" } ``` Use one connector per language folder (`en` and `de`). A question is then answered from both languages together, as it will be in real use. ## The articles Chosen by hand: the core concepts of deep learning (backpropagation, convolutional and recurrent networks, attention and the transformer, regularisation, language models). The German list is not a translation of the English one, because several topics have no German article of their own; each folder's `ATTRIBUTION.txt` lists exactly what it holds. ## The questions and the measurements `questions.toml` holds 100 questions for the corpus: 70 with accepted answers and 30 that nothing in the corpus answers, in English and German, none naming a language or a source. `measure.sh` runs them with `passageway eval` and updates the [measurements page](https://passageway.marceltov.de/measurements/); it needs the embedding model, and `measure.sh --no-write --include-cases --out DIR` shows every question without touching the page. The accepted answers are file names, so an answer such as `backpropagation.md` accepts the article of that name in both languages. ## Refreshing it `snapshot.py` writes the files. It is a maintenance script, not part of the Wikipedia connector, and uses the connector as a library: ``` OUT=corpus/en LANGUAGE=en TITLES="$(paste -sd'|' corpus/en/titles.txt)" FORCE=1 uv run python corpus/snapshot.py ``` Each folder's `titles.txt` is the list of articles it holds, so this repeats the same selection. `FORCE=1` replaces the whole folder, and the new files are written next to it first, so a failed run leaves the old ones. Its settings are the Wikipedia connector's (`LANGUAGE`, `TITLES`, `CATEGORIES`, `CATEGORY_DEPTH`, `SKIP_SECTIONS`, `CONTACT` and the others) plus `OUT` (the folder to write), `LIMIT` (keep only the N longest articles) and `FORCE=1` (overwrite existing files). A refresh fetches today's revisions, so it changes the corpus: do it deliberately and expect measurements to move. # Embedder protocol An embedder turns texts into vectors. The built-in one runs the multilingual e5-small model; any other model is a program that speaks this protocol, configured as `kind = "command"`. The core starts it once and keeps it running, because loading a model takes seconds. This page is the protocol reference (v1); the messages are also described by a [JSON Schema](https://passageway.marceltov.de/reference/embedder-protocol.schema.json). The core writes one JSON object per line to the program's stdin and reads one line from its stdout in answer, in order: | Request | Reply | | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `{"op": "info"}` | `{"name": "bge-m3", "dim": 1024}`, optionally with `"protocol": "1"` | | `{"op": "embed", "kind": "query", "texts": ["…"]}` (or `"kind": "passage"`) | `{"vectors": [[0.1, …], …]}`: one vector per text, in the same order, each with `dim` numbers | | (any) | `{"error": "message"}` if the request failed; the process stays in use | ``` {"op": "info"} {"name": "toy-hash-64", "dim": 64} {"op": "embed", "kind": "passage", "texts": ["Lighthouses guide ships."]} {"vectors": [[0.0, 1.0, 0.0, 2.0]]} ``` ## Rules - **The name identifies the model.** The index stores the name and the dimension; a different name rebuilds the index, and the core refuses a program that, after a restart, answers with another name or dimension. Change the name when you change the model. - **A query and a passage are told apart by `kind`.** Models that need a prefix for each (the e5 family wants `query:` and `passage:`) add it themselves. Pooling and tokenisation are the program's business too. - **The core normalises the vectors** to unit length, so a program may send them as they are. A vector of zero length is an error. - **Batches:** the core sends at most `batch_size` texts per request (32 by default) and puts the answers together in order. - **Time and failure:** a request that takes longer than `timeout` seconds (60 by default) kills the process and fails the call; the next call starts it again. The first `info` is under the same timeout, so a model that takes longer than that to load needs a larger `timeout`. Within one call the process is started at most once more: if it was found dead, or dies while answering, it is started again and the request repeated; if it dies again the call fails with the program's error output (secrets masked, in the error reply too). A line on stdout that is not a reply, or a reply of the wrong shape, stops the process as well, because the next reply could otherwise be read as the answer to the wrong request. While the embedder is failing, `recall` returns an error rather than silence, and `/health` reports it. - **Isolation:** the program runs with an empty environment plus `PATH`, `HOME`, `PASSAGEWAY_PROTOCOL=1`, the `env` table and the named `secrets` from its configuration. It never sees the core's password, and a timeout kills its whole process group. - **Nothing else on stdout.** Log to stderr. ## Configure it ``` [embedder] kind = "command" command = ["/path/to/python", "/path/to/my_embedder.py"] env = { MODEL = "bge-m3" } secrets = { API_KEY = "env:EMBED_KEY" } # references to environment variables, never values min_similarity = 0.80 batch_size = 32 timeout = 60 ``` `min_similarity` is the relevance gate for this model. Similarity scales differ from model to model, so measure it with `passageway eval --sweep`: without it the default of the built-in model is used, which is probably wrong for yours. ## An example A toy that hashes words into 64 buckets, to show the loop. Replace `embed` with your model. ``` #!/usr/bin/env python3 """A toy embedder: words hashed into 64 buckets. Replace embed() with your model.""" import hashlib import json import re import sys DIM = 64 def embed(texts, kind): vectors = [] for text in texts: vector = [0.0] * DIM for word in re.findall(r"\w+", text.lower()): vector[int.from_bytes(hashlib.blake2b(word.encode(), digest_size=4).digest(), "big") % DIM] += 1.0 vectors.append(vector) return vectors for line in sys.stdin: request = json.loads(line) try: if request["op"] == "info": reply = {"name": "toy-hash-64", "dim": DIM} else: reply = {"vectors": embed(request["texts"], request["kind"])} except Exception as error: reply = {"error": str(error)} print(json.dumps(reply), flush=True) ``` ## Test it ``` import sys from passageway.embed_command import CommandEmbedder from passageway.testing.embedder_contract import check_embedder def test_my_embedder(): embedder = CommandEmbedder("mine", [sys.executable, "my_embedder.py"]) try: check_embedder(embedder) finally: embedder.close() ``` `check_embedder` checks the dimension, finite numbers, unit length, determinism, the same vector alone and in a batch, and that the embedder tells a passage about lighthouses from one about bananas. # Connector SDK `passageway-sdk` is a small Python package (no dependencies) that does the protocol plumbing of a [connector](https://passageway.marceltov.de/reference/connector-protocol/index.md), so that a connector is the logic of its source. It is a convenience for Python; the protocol stays the contract, and a connector in any language is written against that. ``` from passageway_sdk import Connector, SettingsError, SourceError ``` ## Connector Subclass it and call `YourConnector.main()`. | Member | What it does | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Prefix of the messages on stderr, e.g. `"folder connector"`. | | `__init__(self, env)` | Read and validate the settings from `env`; raise `SettingsError` if they are wrong. | | `versions(self)` | **Required.** Every item that exists, as `{id: version}`. Answers `list` and, by default, `check`. | | `fetch(self, ids)` | **Required.** The item record (a dict, see the protocol) for each requested id that still exists. | | `changed(self, since)` | Optional. Items changed since `since`, a timezone-aware `datetime`. Leave it out if that is not cheap; `--since` is then not required. | | `count(self)` | Optional. The number printed by `check`; by default `len(self.versions())`. | | `warn(self, message)` | Report something odd on stderr without failing the call. | | `expected_errors` | Optional tuple of exception types that mean "the source failed", exit 1 with the message, like `SourceError`. | The method is called `versions`, not `list`, so that a `list[str]` annotation in your class keeps working. ## What it does for you - Dispatches `check`, `list`, `fetch` and `changed --since T`; an unknown mode or a bad `--since` exits 2 with a usage message. - Reads the ids for `fetch` from stdin and skips blank lines. - **All or nothing:** the output is written only after the whole call succeeded, so a failure leaves stdout empty. - **Exit codes:** `SettingsError` exits 2 and `SourceError` (or an `expected_errors` type) exits 1, each with `name: message` on stderr and no traceback. Any other exception is a bug in the connector: it propagates, the process exits 1 with a traceback, and stdout is still empty. - Validates every item with the core's rules before printing it; an invalid item exits 1 and names the problem. - Prints `check` as `{"ok": true, "count": n, "protocol": "1"}` and keeps the JSON ASCII-safe. ## Markdown helpers For connectors whose source is (or converts to) markdown: | Function | What it does | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `split_markdown(text, skip_first_h1=False)` | The sections of a document: one per heading at any level (`#` to `######`), with the heading as the trail of its parents (`Setup › Linux`). Text before the first heading is a section without a heading; a `#` inside a fenced code block is not a heading; sections without text are dropped. With `skip_first_h1` the first `# heading`, the title, is left out of the trail. | | `first_h1(text)` | The text of the first level-1 heading outside code, or `None`. Use it as the title. | | `strip_code(text)` | The text without fenced code blocks and inline code, for finding the links a document really contains. | `Connector.warn(message)` reports something odd (a skipped file) on stderr as `name: warning: message` without failing the call. ## Testing `Connector.run(argv, env, stdin, stdout, stderr, **options)` runs one mode in-process and returns the exit code; `options` go to `__init__`. For the contract, run your script as a real subprocess with `passageway.testing.contract.check_connector`, as the [guide](https://passageway.marceltov.de/guides/write-a-connector/index.md) shows. # Measurements How well passageway finds the right passage and stays silent when there is none, measured with `passageway eval` on the [sample corpus](https://passageway.marceltov.de/reference/corpus/index.md): 60 Wikipedia articles on deep learning and neighbouring topics, 30 in English and 30 in German, indexed by the [markdown connector](https://passageway.marceltov.de/connectors/markdown/index.md) as two sources. The 100 questions are in [`corpus/questions.toml`](https://github.com/Marceltov/passageway/blob/main/corpus/questions.toml): 70 have accepted answers and 30 do not, and none of them names a language or a source, because one `recall` searches every source. [Measure retrieval quality](https://passageway.marceltov.de/guides/evaluate/index.md) explains the metrics and the options. The three built-in models are compared on the same questions in [Choosing a model](https://passageway.marceltov.de/models/index.md). Reproduce a run with `corpus/measure.sh` (it needs the embedding model, see [Install](https://passageway.marceltov.de/install/index.md)). The block below is written by that script. The history at the end of it gets one line per day, model, threshold and passageway version; a repeated run replaces its line. ## How to read this - **Intervals.** The rates in the first and in the group table carry their 95% interval in brackets. With 70 answerable questions a rate of 85% could really be anywhere from about 75% to 92%; with 30 unanswerable questions a silence of 90% could be anywhere from about 74% to 97%, which is wider; a group of 7 to 25 questions is only a hint. The sweep and history tables show point values only. Overlapping intervals are a warning against over-reading a difference, not a test of it. - **The threshold.** The relevance threshold decides when a passage is similar enough to be returned. A passage that shares three or more words with the question is returned whatever the threshold. Raising it therefore silences loosely worded questions, but it also loses real answers (false silence reaches 27% at 0.90), and two unanswerable questions are never silenced, because they share words with the corpus. The tables show the whole sweep, not only the best row. - **The questions.** They were drafted with an AI assistant that had also chosen the corpus, and every accepted answer was checked against the text of the articles before the first run. An independent review after that run found accepted answers that were incomplete and six questions called cross-language that were not, because the other language explains the topic too. Those were corrected in a separate commit and the run was repeated; both are in the git history, and the notes below give the first run's figures. The accepted answers are file names, so one accepts the article of that name in both languages. A miss can still be a correct article that is not on the list. - **The corpus.** The German list includes general articles; "Neuronales Netz", for example, is the neuroscience article. It is one embedding model (multilingual-e5-small, quantised), 60 articles and one machine. Wikipedia text on deep learning is a friendly domain: do not read these numbers as what your own notes will score. Measure those with your own questions. 2026-10-29 · passageway 0.1.0 · multilingual-e5-small-int8 (384 dimensions) · relevance threshold 0.845 Darwin arm64, Python 3.14.3 · index built in 20.9 s (corpus-en: 30 items, 818 sections, corpus-de: 30 items, 516 sections) | Metric | Value | | ------------------------------------- | ------------------------------------ | | Questions | 100 (70 answerable, 30 unanswerable) | | Hit@1 | 77.1% (66–85%) | | Hit@3 | 88.6% (79–94%) | | MRR | 0.826 | | Silence on unanswerable questions | 80.0% (63–90%) | | False silence on answerable questions | 1.4% (0–8%) | | Latency p50 / p95 | 10.0 ms / 14.0 ms | | Embedding the question | 3.4 ms (34.0% of the latency) | In brackets: the 95% interval of a rate, the range that holds the true rate in 95 of 100 repetitions with other questions of the same kind. Overlapping intervals are a warning against over-reading a difference, not a test of it. The tables below show point values only. ### Relevance threshold | Threshold | Hit@1 | Hit@3 | MRR | Silence | False silence | p95 | | --------- | ----- | ----- | ----- | ------- | ------------- | ------- | | 0.8 | 78.6% | 90.0% | 0.840 | 46.7% | 0.0% | 13.9 ms | | 0.81 | 78.6% | 90.0% | 0.840 | 53.3% | 0.0% | 14.2 ms | | 0.82 | 78.6% | 90.0% | 0.840 | 63.3% | 0.0% | 13.6 ms | | 0.83 | 77.1% | 88.6% | 0.829 | 73.3% | 0.0% | 14.1 ms | | 0.84 | 78.6% | 88.6% | 0.833 | 80.0% | 0.0% | 14.3 ms | | 0.845 | 77.1% | 88.6% | 0.826 | 80.0% | 1.4% | 13.8 ms | | 0.85 | 72.9% | 84.3% | 0.783 | 83.3% | 5.7% | 13.7 ms | | 0.86 | 70.0% | 77.1% | 0.736 | 90.0% | 8.6% | 13.5 ms | | 0.87 | 67.1% | 74.3% | 0.707 | 90.0% | 11.4% | 13.9 ms | | 0.88 | 65.7% | 72.9% | 0.693 | 93.3% | 14.3% | 14.1 ms | | 0.89 | 62.9% | 68.6% | 0.657 | 93.3% | 17.1% | 13.9 ms | | 0.9 | 51.4% | 57.1% | 0.543 | 93.3% | 27.1% | 13.9 ms | A passage that shares three or more words with the question is returned whatever the threshold (see How it works), so a high threshold only silences short or loosely worded questions. ### Groups | Group | Questions | Hit@3 | Silence | | -------------- | --------- | ---------------- | ---------------- | | definition | 25 | 96.0% (80–99%) | n/a | | how-why | 20 | 100.0% (84–100%) | n/a | | paraphrase | 18 | 61.1% (39–80%) | n/a | | cross-language | 7 | 100.0% (65–100%) | n/a | | near-topic | 15 | n/a | 60.0% (36–80%) | | off-topic | 15 | n/a | 100.0% (80–100%) | ### History | Date | Embedder | Threshold | Questions | Hit@3 | Silence | p95 | | ---------- | -------------------------- | --------- | --------- | ----- | ------- | ------- | | 2026-10-29 | multilingual-e5-small-int8 | 0.845 | 100 | 88.6% | 80.0% | 14.0 ms | ## Notes on the runs (29 October 2026) Written by hand, so it does not change when the block above is rewritten. The numbers above are the baseline for the work on embedding models. - **The first run and the corrected one.** The first run, before the review, scored hit@3 80.0% (69–88%), hit@1 70.0% and MRR 0.748, with 8 of 13 cross-language questions found. The corrections added articles that really explain a topic to the accepted answers of 14 questions, regrouped 6 of the 13 cross-language questions, and replaced one unanswerable question whose topic the German overview describes (k-nearest neighbours became Metropolis-Hastings). Silence did not change. - **Plain questions work; reworded ones less.** 24 of 25 definition questions and all 20 how-and-why questions found an accepted article in the top three, but only 11 of 18 paraphrased ones did ("a network that squeezes its input into a small code and then rebuilds it" did not find the autoencoder article). - **The seven cross-language questions were all found,** but they are few, and several carry a word that is the same in both languages ("Residual Network", "Hyperparameter", "Boltzmann"), which helps a word-based match. Read it as "does not fail", not as a measure of cross-language retrieval. - **Off-topic questions are always silent; near-topic ones are not.** All 15 off-topic questions returned nothing, but 6 of 15 near-topic ones returned a passage: a technique the corpus does not cover still finds a passage that shares words with it (a question about gradient boosting finds the vanishing gradient problem). - **The threshold.** At the default 0.845, hit@3 is 88.6% and silence 80.0%. At 0.85 they are 84.3% and 83.3%, at 0.86 77.1% and 90.0%: from 0.84 to 0.86 hit@3 loses 11.5 points and silence gains 10. 0.84 gives the same hit@3 and silence as 0.845 and no false silence, a difference far inside the intervals. The sweep gives no reason to change the default; it does not show that 0.845 is the best value. - **Latency:** about 10 ms at the median and 14 ms at the 95th percentile on a Darwin arm64 machine, a third of it for embedding the question. - **Strict on purpose:** a hit counts only if it is one of the listed articles, so hit@1 is a hard measure. Some hits at rank 2 or 3 are arguably good answers that are not on the list. # Choosing a model The three built-in models compared on the [sample corpus](https://passageway.marceltov.de/reference/corpus/index.md) with the same 100 questions as the [measurements](https://passageway.marceltov.de/measurements/index.md), each with its own fresh index and its own relevance threshold. `corpus/compare.sh` reproduces it and writes the block below. [Measure retrieval quality](https://passageway.marceltov.de/guides/evaluate/index.md) explains the metrics; the intervals say how little a difference of a few points means. Each model has its own similarity scale, so the threshold tables are the point of this page: a model's `min_similarity` is read off where hit@3 and silence cross, and that value is the preset's default (`passageway model list` shows it). 2026-10-29 · passageway 0.2.0 · 100 questions (70 answerable, 30 unanswerable) · Darwin arm64, Python 3.14.3 Each embedder ran with its own fresh index and its own relevance threshold, because similarity scales differ between models. Load is the time to start the model, Index the time to embed all sources. | Embedder | Dimensions | Threshold | Load | Index | Hit@1 | Hit@3 | MRR | Silence | False silence | p50 / p95 | | ---------------------------------------- | ---------- | --------- | ----- | ------ | -------------- | -------------- | ----- | -------------- | -------------- | -------------- | | default: multilingual-e5-small-int8 | 384 | 0.845 | 0.3 s | 21.5 s | 77.1% (66–85%) | 88.6% (79–94%) | 0.826 | 80.0% (63–90%) | 1.4% (0–8%) | 7.7 / 11.4 ms | | e5-base: multilingual-e5-base-int8 | 768 | 0.845 | 0.4 s | 47.3 s | 68.6% (57–78%) | 75.7% (64–84%) | 0.714 | 86.7% (70–95%) | 10.0% (5–19%) | 14.4 / 22.6 ms | | jina-de: jina-embeddings-v2-base-de-int8 | 768 | 0.845 | 0.2 s | 54.4 s | 37.1% (27–49%) | 44.3% (33–56%) | 0.405 | 96.7% (83–99%) | 44.3% (33–56%) | 14.7 / 22.6 ms | In brackets: the 95% interval of a rate, the range that holds the true rate in 95 of 100 repetitions with other questions of the same kind. Overlapping intervals are a warning against over-reading a difference, not a test of it. The tables below show point values only. ### Relevance threshold: default | Threshold | Hit@1 | Hit@3 | MRR | Silence | False silence | p95 | | --------- | ----- | ----- | ----- | ------- | ------------- | ------- | | 0.4 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.6 ms | | 0.45 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.5 ms | | 0.5 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.6 ms | | 0.52 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.4 ms | | 0.54 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.7 ms | | 0.55 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.5 ms | | 0.56 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.5 ms | | 0.58 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.3 ms | | 0.6 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.5 ms | | 0.65 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.7 ms | | 0.7 | 78.6% | 90.0% | 0.840 | 0.0% | 0.0% | 11.6 ms | | 0.75 | 78.6% | 90.0% | 0.840 | 6.7% | 0.0% | 11.4 ms | | 0.8 | 78.6% | 90.0% | 0.840 | 46.7% | 0.0% | 11.4 ms | | 0.81 | 78.6% | 90.0% | 0.840 | 53.3% | 0.0% | 11.4 ms | | 0.82 | 78.6% | 90.0% | 0.840 | 63.3% | 0.0% | 11.4 ms | | 0.83 | 77.1% | 88.6% | 0.829 | 73.3% | 0.0% | 11.4 ms | | 0.84 | 78.6% | 88.6% | 0.833 | 80.0% | 0.0% | 11.4 ms | | 0.845 | 77.1% | 88.6% | 0.826 | 80.0% | 1.4% | 11.3 ms | | 0.85 | 72.9% | 84.3% | 0.783 | 83.3% | 5.7% | 11.2 ms | | 0.86 | 70.0% | 77.1% | 0.736 | 90.0% | 8.6% | 11.0 ms | | 0.87 | 67.1% | 74.3% | 0.707 | 90.0% | 11.4% | 11.2 ms | | 0.88 | 65.7% | 72.9% | 0.693 | 93.3% | 14.3% | 11.2 ms | | 0.9 | 51.4% | 57.1% | 0.543 | 93.3% | 27.1% | 11.3 ms | ### Relevance threshold: e5-base | Threshold | Hit@1 | Hit@3 | MRR | Silence | False silence | p95 | | --------- | ----- | ----- | ----- | ------- | ------------- | ------- | | 0.4 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 21.7 ms | | 0.45 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 22.7 ms | | 0.5 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 22.1 ms | | 0.52 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 22.7 ms | | 0.54 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 21.5 ms | | 0.55 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 22.7 ms | | 0.56 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 22.0 ms | | 0.58 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 22.3 ms | | 0.6 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 22.1 ms | | 0.65 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 23.1 ms | | 0.7 | 75.7% | 91.4% | 0.829 | 0.0% | 0.0% | 23.1 ms | | 0.75 | 75.7% | 91.4% | 0.829 | 13.3% | 0.0% | 22.2 ms | | 0.8 | 75.7% | 90.0% | 0.821 | 56.7% | 1.4% | 22.1 ms | | 0.81 | 75.7% | 91.4% | 0.826 | 70.0% | 1.4% | 22.9 ms | | 0.82 | 77.1% | 91.4% | 0.831 | 73.3% | 1.4% | 22.7 ms | | 0.83 | 72.9% | 85.7% | 0.786 | 80.0% | 4.3% | 22.1 ms | | 0.84 | 68.6% | 77.1% | 0.721 | 83.3% | 10.0% | 21.8 ms | | 0.845 | 68.6% | 75.7% | 0.714 | 86.7% | 10.0% | 22.2 ms | | 0.85 | 67.1% | 74.3% | 0.700 | 90.0% | 12.9% | 22.8 ms | | 0.86 | 65.7% | 72.9% | 0.686 | 90.0% | 17.1% | 22.3 ms | | 0.87 | 61.4% | 67.1% | 0.638 | 90.0% | 22.9% | 21.8 ms | | 0.88 | 51.4% | 57.1% | 0.538 | 90.0% | 31.4% | 22.3 ms | | 0.9 | 42.9% | 48.6% | 0.452 | 90.0% | 41.4% | 21.5 ms | ### Relevance threshold: jina-de | Threshold | Hit@1 | Hit@3 | MRR | Silence | False silence | p95 | | --------- | ----- | ----- | ----- | ------- | ------------- | ------- | | 0.4 | 80.0% | 88.6% | 0.840 | 53.3% | 0.0% | 23.0 ms | | 0.45 | 80.0% | 88.6% | 0.843 | 63.3% | 0.0% | 22.9 ms | | 0.5 | 78.6% | 84.3% | 0.814 | 80.0% | 1.4% | 23.2 ms | | 0.52 | 77.1% | 85.7% | 0.810 | 86.7% | 4.3% | 22.6 ms | | 0.54 | 77.1% | 85.7% | 0.812 | 90.0% | 4.3% | 22.5 ms | | 0.55 | 77.1% | 85.7% | 0.810 | 93.3% | 4.3% | 22.0 ms | | 0.56 | 78.6% | 85.7% | 0.817 | 96.7% | 5.7% | 22.2 ms | | 0.58 | 77.1% | 84.3% | 0.802 | 96.7% | 5.7% | 22.3 ms | | 0.6 | 77.1% | 84.3% | 0.802 | 96.7% | 5.7% | 22.9 ms | | 0.65 | 74.3% | 84.3% | 0.790 | 96.7% | 7.1% | 22.3 ms | | 0.7 | 70.0% | 80.0% | 0.748 | 96.7% | 11.4% | 22.3 ms | | 0.75 | 50.0% | 57.1% | 0.533 | 96.7% | 31.4% | 22.6 ms | | 0.8 | 37.1% | 44.3% | 0.405 | 96.7% | 44.3% | 22.1 ms | | 0.81 | 37.1% | 44.3% | 0.405 | 96.7% | 44.3% | 21.9 ms | | 0.82 | 37.1% | 44.3% | 0.405 | 96.7% | 44.3% | 22.1 ms | | 0.83 | 37.1% | 44.3% | 0.405 | 96.7% | 44.3% | 21.8 ms | | 0.84 | 37.1% | 44.3% | 0.405 | 96.7% | 44.3% | 22.5 ms | | 0.845 | 37.1% | 44.3% | 0.405 | 96.7% | 44.3% | 22.2 ms | | 0.85 | 37.1% | 44.3% | 0.405 | 96.7% | 44.3% | 22.8 ms | | 0.86 | 37.1% | 44.3% | 0.405 | 96.7% | 44.3% | 22.3 ms | | 0.87 | 35.7% | 42.9% | 0.390 | 96.7% | 45.7% | 22.2 ms | | 0.88 | 35.7% | 42.9% | 0.390 | 96.7% | 45.7% | 22.0 ms | | 0.9 | 35.7% | 42.9% | 0.390 | 96.7% | 45.7% | 22.2 ms | ### History | Date | Embedder | Threshold | Questions | Hit@3 | Silence | p95 | | ---------- | ---------------------------------------- | --------- | --------- | ----- | ------- | ------- | | 2026-10-29 | default: multilingual-e5-small-int8 | 0.845 | 100 | 88.6% | 80.0% | 11.4 ms | | 2026-10-29 | e5-base: multilingual-e5-base-int8 | 0.845 | 100 | 75.7% | 86.7% | 22.6 ms | | 2026-10-29 | jina-de: jina-embeddings-v2-base-de-int8 | 0.845 | 100 | 44.3% | 96.7% | 22.6 ms | ## Notes on the comparison (29 October 2026) Written by hand after the first run of `corpus/compare.sh` on a CPU, so it does not change when the block above is rewritten. - **The rule for a preset's gate:** the threshold with the highest sum of hit@3 and silence in its sweep, the lower one on a tie. It gives `multilingual-e5-small` 0.845 (unchanged: 0.84 and 0.845 tie), `multilingual-e5-base` 0.83 and `jina-v2-base-de` 0.56. Each preset carries that value, and `passageway model list` shows it. - **Jina at its gate is the quietest model here:** silence 97% against e5-small's 80%, with hit@3 inside the same interval (86% against 89%) and false silence 6% against 1%. Its similarity scale is nothing like e5's: a match sits around 0.5, an unrelated passage near 0; with e5 the two lie at 0.85 and 0.80. That is why a gate is measured per model and never carried over. - **The default stays `multilingual-e5-small`:** it is the smallest and fastest (indexing the corpus in 21 s against Jina's 54 s, answering in 8 ms against 14 ms), and the Trilium connector's parity was measured with it. For German-heavy sources, or where silence on off-topic questions matters more than speed, `model = "jina-v2-base-de"` is the better choice on these numbers. - **`multilingual-e5-base` does not earn its size** on this corpus: at its gate it finds less than e5-small (hit@3 86% against 89%) and costs twice the indexing time. - **CoreML was measured and turned off by default:** on these int8 models it ran four times slower than the CPU and gave different vectors (far off for Jina), so `device = "auto"` means CPU; `coreml` stays as an opt-in. - The same limits apply as on the [measurements page](https://passageway.marceltov.de/measurements/index.md): one corpus of 60 Wikipedia articles, one machine, and intervals of about ±10 points; the reworded questions are what every model misses most. # Connectors # Confluence connector Index the pages of chosen spaces of a Confluence Data Center. In Claude Code, `/passageway-add-source` from the [plugin](https://passageway.marceltov.de/guides/claude-code/index.md) installs this connector and writes its settings for you. A [passageway](https://github.com/Marceltov/passageway#readme) connector for **Confluence Data Center** (and Server): the current pages of the spaces you name, read through the REST API with GET requests only, using a personal access token. Written from the public REST documentation. ## What it does - **Each page is one item.** Its id is the content id, its version changes with every page version and with its labels, and its link opens the page in Confluence. - **Headings become sections:** `h1`, `h2` and `h3` split a page into passages with their heading trail (`Setup › Linux`); lists, tables and code macros are kept readable; long sections are cut at about 350 tokens. - **Labels become labels** (`space:OPS` plus the page's own), **ancestors become the path** (nearest last, up to four), and every page weighs 1.0. - **Macros:** code and noformat macros keep their text; boxes, expands, sections, columns, excerpts and every other macro with a body keep what they hold; a status macro keeps its title; a table of contents, Jira issues, images, emoticons and other macros without a body are left out. Task lists become lists. Links keep their text, or the linked page's title. Blog posts and attachments are not indexed. - **Edits appear within a minute:** `changed` asks Confluence with CQL for the pages modified since the last poll (fourteen hours early, because CQL reads the time in the server's zone; unchanged versions cost nothing). Deletions, moves out of the spaces and labels that take a page out of scope are settled at the next reconcile. A space that lists no pages is reported as a warning, so a wrong key or a token without permission does not pass in silence. ## Configure Create a personal access token under your profile in Confluence (Data Center and Server 7.9 and later). The connector only ever sends GET requests. ``` [[connector]] name = "wiki" command = ["/path/to/python", "-m", "passageway_connector_confluence"] env = { URL = "https://wiki.example.org", SPACES = "OPS|HR" } secrets = { TOKEN = "env:CONFLUENCE_TOKEN" } ``` | Setting | Default | Meaning | | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `URL` | required | The Confluence site, e.g. `https://wiki.example.org` (with its context path, if any) | | `SPACES` | required | Space keys to index, separated by `\|` | | `TOKEN` | required | A personal access token, as a secret reference such as `env:CONFLUENCE_TOKEN` | | `INCLUDE_LABELS` | | Only pages carrying one of these labels, separated by `\|` | | `EXCLUDE_LABELS` | | Pages carrying one of these labels are left out | | `MAX_PAGES` | `5000` | Upper bound on the spaces together; more fails with a message instead of indexing a subset | | `TIMEOUT` | `30` | Seconds per request | | `PAUSE_SECONDS` | `0` | Pause between requests, for a busy server | | `VERIFY_TLS` | `true` | `false` turns certificate checking off. A CA your operating system trusts already works without it (see [Certificates](https://passageway.marceltov.de/configuration/#certificates)), so prefer adding the CA there | ## Behaviour - **Retries:** 429 and 5xx answers and network errors are retried with a growing wait (1 s, doubling up to 60 s; a longer `Retry-After` is honoured up to 300 s) until a request has waited 15 minutes in total; the error then says how many attempts and how long it waited. Every wait of 10 s or more is logged as it starts (`HTTP 429 … waiting 16 s (15 s of 900 s waited)`), so a sync that waits does not look hung. A 401 says to check the token; any other error fails the call and nothing is stored. - **A throttled or large space** needs more than the core's default `timeout = 300` for the whole `list` or `fetch`: waiting out one rate limit can take minutes. Raise `timeout` in its `[[connector]]` entry (`3600` is a start), or set `PAUSE_SECONDS` so fewer requests are throttled in the first place. - **Cloud:** Atlassian Cloud authenticates with an e-mail address and an API token and serves the API under `/wiki`; it is not supported yet ([ADR 0005](https://passageway.marceltov.de/adr/0005-confluence-data-center-first/)). - **Tested against a fake** of the Data Center API in this repository, not against a live Confluence. ## Test ``` uv run pytest connectors/confluence # offline, against an in-test Confluence ``` # Markdown connector Index a folder of markdown files, with front matter. In Claude Code, `/passageway-add-source` from the [plugin](https://passageway.marceltov.de/guides/claude-code/index.md) installs this connector and writes its settings for you. A [passageway](https://github.com/Marceltov/passageway#readme) connector that turns a folder of markdown files into searchable items: a docs folder, an Obsidian vault, a Jekyll or Hugo site, or the articles of the frozen Wikipedia corpus. ## What it does - **Each markdown file is one item.** Its id is the path relative to the folder (or an `id` in its front matter) and its version is a hash of the file, so an edit re-indexes just that note. - **Front matter gives the metadata:** title, address, date, tags, id and weight (see the table below). - **Headings become sections.** Every heading level starts a section whose heading is the trail of its parents, such as `Setup › Linux`, so a hit says where in the note it is. - **Folders become the breadcrumb**, and **links between notes become related items:** `[text](other.md)` and `[[Other]]`. - **Files that cannot be indexed are skipped, not fatal.** A file that is not UTF-8 text, holds binary data or is too big is skipped with a warning on stderr, and the rest of the folder is indexed. ## Install Add the connector to the environment that runs passageway (see [Install](https://github.com/Marceltov/passageway/blob/main/docs/install.md)): ``` uv tool install \ --with 'passageway-connector-markdown @ git+https://github.com/Marceltov/passageway#subdirectory=connectors/markdown' \ git+https://github.com/Marceltov/passageway ``` ## An example A note `ops/rotating-certificates.md`: ``` --- title: Rotating certificates tags: [ops, security] url: https://wiki.example.org/ops/certs --- # Rotating certificates Rotate the signing certificate every year. ## Steps 1. Create the new certificate. 2. Follow [the deploy guide](deploy.md). ``` becomes this item (shortened): ``` {"id": "ops/rotating-certificates.md", "title": "Rotating certificates", "url": "https://wiki.example.org/ops/certs", "path": ["ops"], "labels": ["ops", "security"], "links": ["ops/deploy.md"], "sections": [{"heading": null, "text": "Rotate the signing certificate every year."}, {"heading": "Steps", "text": "1. Create the new certificate.\n2. Follow [the deploy guide](deploy.md)."}]} ``` ## Configure In `passageway.toml` (use the interpreter that has passageway installed, see [Install](https://github.com/Marceltov/passageway/blob/main/docs/install.md)): ``` [[connector]] name = "notes" command = ["/path/to/python", "-m", "passageway_connector_markdown"] env = { FOLDER = "/Users/you/notes", EXCLUDE = "drafts/**|*.draft.md" } ``` | Setting | Default | Meaning | | ---------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `FOLDER` | required | The folder to index, including its subfolders | | `INCLUDE` | `*.md\|*.markdown` | Glob patterns for the files to index, separated by `\|`. `*` stays within a folder, `**` crosses folders, and a pattern without `/` matches the file name at any depth. Replaces the default | | `EXCLUDE` | | Glob patterns for files to leave out, the same syntax | | `MAX_FILE_BYTES` | `1048576` | Files larger than this are skipped with a warning. The core accepts at most 4 MiB per item line and the output escapes non-ASCII text, which can triple its size, so keep this at or below 1 MiB unless your notes are plain ASCII | Always skipped: hidden files and folders (names starting with `.`, such as `.git` and `.obsidian`) and `node_modules`. Symlinked folders are not followed, and a symlinked file counts only if it points inside `FOLDER`. ## Front matter A file may start with a YAML block between `---` lines. These keys are read; everything else is ignored. | Key | Used for | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `title` | The title. Without it: the first `# heading`, then the file name | | `url` | The address shown with a hit. Without it: the `file://` address of the file | | `updated` | The date shown with a hit. Without it: the file's modification time | | `tags` | Labels. A list, or a comma-separated string | | `id` | A stable id that survives renames and moves. Without it: the relative path. Two files with the same id fail the call and the message names both | | `weight` | A multiplier for ranking, from 0 to 2 (default 1) | All values are read as text, so `no` stays `no` and `2026-10-28` stays a date string. Front matter that is not valid YAML is reported on stderr and the file is indexed without it. ## Behavior - **Sections:** every heading level (`#` to `######`) starts a section whose heading is the trail of its parents. Text before the first heading is its own section. The first `# heading` is the title (when the front matter has no title, or the same one) and is not repeated; a different `# heading` stays a section heading. A `#` inside a fenced code block is not a heading. - **Links:** `[text](other.md#part)` and `[[Other]]` or `[[Other|text]]` become related items when they point at a note in the folder. Wiki links match the file name without case; a name shared by two files links to neither. Links in code are ignored, and the text itself is not changed. - **Version:** a hash of the whole file and of the notes it links to, so any change to the file, front matter included, and a link that starts to resolve (its target was created) re-indexes the note. - **Bad files:** a file that is not UTF-8 text (a BOM is removed), contains binary data or is over `MAX_FILE_BYTES` is skipped with a warning on stderr, not an error. - **`changed`:** notes modified or moved in since the given time, from the later of each file's modification and change time, so edits appear within a minute, also for files copied in with their old dates. - **Cost:** every call reads the whole folder, because ids and links need all the notes. A skipped file is reported on every call. ## Not supported - Headings underlined with `===` or `---` (setext); only `#` headings split. A `#` line inside an HTML comment still counts as a heading. - Glob character classes (`[ab]`) and braces (`{a,b}`); `*.md` is case sensitive, so `NOTE.MD` is not found without `INCLUDE=*.MD`. A pattern with a `/` is anchored at `FOLDER`, so `drafts/**` matches only the top-level `drafts`; use `**/drafts/**` for any depth. - Reference-style links (`[text][ref]`) and brackets inside link text. - File names in a different Unicode form than the link (`Café` typed with a combining accent). ## Test ``` uv sync # installs this connector too uv run pytest connectors/markdown # offline, on temporary folders ``` # Trilium connector Index the notes of a Trilium server, with the LLM-wiki structure of trilium-recall. In Claude Code, `/passageway-add-source` from the [plugin](https://passageway.marceltov.de/guides/claude-code/index.md) installs this connector and writes its settings for you. A [passageway](https://github.com/Marceltov/passageway#readme) connector for [Trilium Notes](https://github.com/TriliumNext/Notes): every text and code note of a Trilium server, read through its ETAPI with GET requests only. It is the trilium-recall sync as a connector, so an LLM wiki kept the trilium-llm-wiki way is understood, and any other Trilium works too. ## What it does - **Each note is one item.** Its id is the note id, its version changes with the content, the title, the attributes or the place in the tree, and its link opens the note in Trilium. - **Headings become sections:** `

` and `

` split a note into passages with their heading trail (`Setup › Linux`); lists, tables and code blocks are kept readable; a code note is split at blank lines. Long sections are cut at about 350 tokens. - **Areas give weight.** The owned `#llmWikiPart` notes mark the areas: `wiki` and `schema` weigh 1.0, `sources` 0.8, the journal (`#calendarRoot`) 0.6, everything else 0.6; `locks`, `templates` and `views` are plumbing and are left out, as are archived notes. - **Wiki labels become labels:** `area:wiki`, `type:`, `topic:`, `status:`. - **Relations and links become related items:** `internalLink`, `relatesTo` and `wikiSource` relations the note owns, and the links in its text, point at other indexed notes; a source note links back to the summaries that cite it. - **The path** is the titles of the ancestors below the area root, nearest last, so a hit says where in the tree the note is. - **Edits to a note's content appear within a minute:** `changed` asks Trilium for the notes modified since the last poll, one small request when nothing changed. Trilium does not count an attribute or a move as a modification, so label changes, moves, renamed ancestors, deletions and archiving are settled at the next reconcile (every 30 minutes by default). - **Cost:** `list` and each `fetch` batch read every note's metadata (one search per area root), because areas, paths and links need the whole tree; a Trilium with thousands of notes makes a full build take minutes. The content of a note is fetched once per version. ## Configure Create an ETAPI token in Trilium under Options › ETAPI. Trilium's tokens can write, so keep it a secret reference; the connector only ever sends GET requests. ``` [[connector]] name = "notes" command = ["/path/to/python", "-m", "passageway_connector_trilium"] env = { URL = "http://trilium:8080", WEB_URL = "https://notes.example.org" } secrets = { TOKEN = "env:TRILIUM_TOKEN" } ``` | Setting | Default | Meaning | | ---------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `URL` | required | The Trilium server as the connector reaches it (`/etapi` is added) | | `TOKEN` | required | An ETAPI token, as a secret reference such as `env:TRILIUM_TOKEN` | | `WEB_URL` | `URL` | The address links are made with, when readers reach Trilium under another name than the connector does | | `TIMEOUT` | `30` | Seconds per request | | `MAX_NOTE_BYTES` | `1048576` | A note whose content is larger is skipped with a warning at fetch (the core accepts 4 MiB per item line); it stays listed, so it is tried again at each reconcile | ## Not carried over from trilium-recall trilium-recall showed a note's summary in place of its source when the summary matched almost as well. The core knows no source, so that rule has no place in it; here a summary and its source are items that link each other, and a hit shows the other as a related item. The connector's parity with trilium-recall is measured on trilium-recall's public fixture, a synthetic wiki with 17 questions served by the in-test ETAPI with the real model: hit@3 0.92 and silence 1.0 against trilium-recall's published 0.83 and 1.0 (`PASSAGEWAY_LIVE=1 uv run pytest connectors/trilium/tests/test_trilium_parity.py -s`). A check against a live Trilium and trilium-recall's private question set is still to be done by its owner. ## Test ``` uv run pytest connectors/trilium # offline, against an in-test ETAPI with the fixture wiki PASSAGEWAY_LIVE=1 uv run pytest connectors/trilium/tests/test_trilium_parity.py # the fixture cases with the real model ``` # Wikipedia connector Index a Wikipedia topic, some articles, or a place. In Claude Code, `/passageway-add-source` from the [plugin](https://passageway.marceltov.de/guides/claude-code/index.md) installs this connector and writes its settings for you. A [passageway](https://github.com/Marceltov/passageway#readme) connector that turns part of Wikipedia (or any MediaWiki site) into searchable items: a **topic** (categories), some **titles**, or a **place**. English and German are both tested; any language code works. Each article is one item. Its id is the page id, its version is the revision id, and its sections follow the article's headings (`History › 19th century`). Boilerplate sections such as References or Einzelnachweise are left out. ## Configure In `passageway.toml`, one `[[connector]]` per language (use the interpreter that has passageway installed, see [Install](https://github.com/Marceltov/passageway/blob/main/docs/install.md)): ``` [[connector]] name = "deep-learning-en" command = ["/path/to/python", "-m", "passageway_connector_wikipedia"] env = { LANGUAGE = "en", CATEGORIES = "Deep learning", CATEGORY_DEPTH = "0" } [[connector]] name = "deep-learning-de" command = ["/path/to/python", "-m", "passageway_connector_wikipedia"] env = { LANGUAGE = "de", CATEGORIES = "Kategorie:Deep Learning" } [[connector]] name = "munich" command = ["/path/to/python", "-m", "passageway_connector_wikipedia"] env = { LANGUAGE = "de", GEO = "48.1372,11.5756,5000" } # articles within 5 km of central Munich ``` | Setting | Default | Meaning | | --------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `LANGUAGE` | `en` | Wikipedia language code | | `CATEGORIES` | | Categories, separated by `\|`. A bare name gets the `Category:` prefix; a name with a prefix (`Kategorie:...`) is used as given | | `CATEGORY_DEPTH` | `1` | How many levels of subcategories to follow (`0` = only the listed categories) | | `TITLES` | | Article titles, separated by `\|`; redirects are followed, missing pages are ignored | | `GEO` | | `lat,lon,radius_in_meters`: articles about places near a point. The radius is limited to 10 to 10000 m by MediaWiki, so this suits a city or neighbourhood, not a region. For a region use `CATEGORIES` | | `MAX_PAGES` | `500` | Upper bound on the scope. A bigger scope fails with a message instead of being cut off | | `SKIP_SECTIONS` | English and German boilerplate | Headings to leave out, separated by `\|`; `none` keeps everything. A skipped heading also skips its subsections | | `API_URL`, `PAGE_URL` | derived from `LANGUAGE` | Point at another MediaWiki site | | `CONTACT` | | Added to the User-Agent; Wikimedia asks automated clients to identify themselves | | `PAUSE_SECONDS` | `0.1` | Pause between requests | At least one of `CATEGORIES`, `TITLES` or `GEO` is required. ## Behavior - **Polite:** sequential requests with a descriptive User-Agent, `maxlag`, and back-off on 429 and 503. - **Redirects:** redirect pages inside a category are left out; `TITLES` follow redirects to the real article. - **Retries:** 429, 502, 503 and 504 answers, `maxlag` and network errors are retried with a growing wait (1 s, doubling up to 60 s; a longer `Retry-After` is honored up to 300 s) until a request has waited 15 minutes in total; the error then says how many attempts and how long it waited. Every wait of 10 s or more is logged as it starts (`HTTP 429 … waiting 16 s (15 s of 900 s waited)`), so a sync that waits does not look hung. Waiting out a long rate limit needs a core `timeout` above its default of 300 s. - **No `changed` mode:** edits appear at the next reconcile (every 30 minutes by default). - **Content license:** Wikipedia text is CC BY-SA 4.0. The link on each item points to the source article; its history page lists the authors. ## Test ``` uv sync # installs this connector too uv run pytest connectors/wikipedia # offline, against an in-test MediaWiki server PASSAGEWAY_LIVE=1 uv run pytest connectors/wikipedia/tests/test_wikipedia_live.py # real Wikipedia ``` # Embedders # OpenAI-compatible embedder Use Ollama, LM Studio, vLLM, OpenAI or Voyage as the embedding model. A [passageway](https://github.com/Marceltov/passageway#readme) embedder for services that speak the OpenAI embeddings API: **Ollama**, **LM Studio**, **vLLM**, **OpenAI** itself, and **Voyage** (whose API differs in one field). It speaks the [embedder protocol](https://passageway.marceltov.de/reference/embedder-protocol/), so passageway runs it as `kind = "command"` and the core does not change. **Where your text goes:** every indexed passage and every question is sent to the service at `BASE_URL`. With a local Ollama or LM Studio that is your own machine; with OpenAI or Voyage it is a third party, under their terms. passageway's default stays local; this embedder is your explicit choice. ## Configure ``` [embedder] kind = "command" command = ["/path/to/python", "-m", "passageway_embedder_openai"] # Ollama: nomic-embed-text wants these prefixes; DIMENSIONS lets passageway start before Ollama does env = { BASE_URL = "http://localhost:11434/v1", MODEL = "nomic-embed-text", DIMENSIONS = "768", QUERY_PREFIX = "search_query: ", PASSAGE_PREFIX = "search_document: " } # min_similarity = ... # measure it for this model: passageway eval --sweep timeout = 60 # the core's limit per request; keep it above TIMEOUT below # OpenAI # env = { MODEL = "text-embedding-3-small" } # secrets = { API_KEY = "env:OPENAI_API_KEY" } # Voyage # env = { PROVIDER = "voyage", MODEL = "voyage-3" } # secrets = { API_KEY = "env:VOYAGE_API_KEY" } ``` | Setting | Default | Meaning | | -------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MODEL` | required | The embedding model the service serves | | `BASE_URL` | `https://api.openai.com/v1`, or Voyage's for `PROVIDER = "voyage"` | The service; `POST {BASE_URL}/embeddings` is called (a trailing `/embeddings` is dropped). No credentials in the URL | | `API_KEY` | | Sent as `Authorization: Bearer`. Required for api.openai.com and api.voyageai.com, usually not for a local server. Put it in `secrets`, never in `env` | | `PROVIDER` | `openai` | `openai` for every OpenAI-compatible service, `voyage` for Voyage, which wants `input_type` (`query` or `document`) with each request | | `QUERY_PREFIX`, `PASSAGE_PREFIX` | | Text put in front of every question or passage; e5-style models served locally want `query:` and `passage:` | | `DIMENSIONS` | | The vector length. Set, it answers `info` without a probe, so passageway can start while the service is still down; it is also sent as `dimensions` (`output_dimension` for Voyage) for models that can shorten their vectors. The first answer is checked against it | | `TIMEOUT` | `50` | Seconds for one request including its retries. Keep the core's `timeout` for this embedder above it, or the core kills the process before this program can report what went wrong | | `NAME` | `:@[:port][#prefixes]` | The embedder's name as the index records it. A prefix changes every vector, so a short hash of the prefixes is part of it; set your own when the model behind a name changes | `min_similarity` belongs to the model: similarity scales differ, so measure it with [`passageway eval --sweep`](https://passageway.marceltov.de/guides/evaluate/) before relying on it. ## Behaviour - **The dimension** is learned at start by embedding one word (`passageway`), unless `DIMENSIONS` is set. Without `DIMENSIONS`, a service that is down when passageway starts keeps passageway in its error state until passageway is restarted; with it, passageway starts and the first `recall` succeeds once the service is up. - **Retries:** 429 and 5xx answers and connection errors are retried with a growing wait (1 s, doubling up to 60 s; a longer `Retry-After` is honoured) for as long as `TIMEOUT` allows: `TIMEOUT` alone bounds a request, retries included, so raise it to wait out a longer rate limit. A request that timed out while being answered is not repeated, because a paid service may already have charged for it. Any other HTTP error, and giving up, is an error reply to the core for that request; the process stays up. - **The key** is never printed: it is masked in every error, also when a gateway quotes the header back. - **Tested against a fake** of the API in this repository, not against any live service; Voyage is written from its public API documentation. Recent Ollama versions serve the OpenAI-compatible `/v1/embeddings`; older ones only `/api/embed`, which this embedder does not speak. - **Batches:** the core sends at most `batch_size` texts per request (32 by default); the services accept far more, so raise it for a cloud model if you like. ## Install ``` uv tool install \ --with 'passageway-embedder-openai @ git+https://github.com/Marceltov/passageway#subdirectory=embedders/openai' \ git+https://github.com/Marceltov/passageway ``` and use that environment's interpreter in `command` (`$(uv tool dir)/passageway/bin/python`), as for a connector. ## Test ``` uv run pytest embedders/openai # offline, against an in-test embeddings API ``` # Contributing # Development ``` git clone https://github.com/Marceltov/passageway cd passageway uv sync # installs the core and every connector package uv run pytest -q # the whole suite, offline ``` ## Layout The repository is a uv workspace: the core package `passageway` at the root and one self-contained package per connector under `connectors/`, each with its own `pyproject.toml`, tests and README. See [ADR 0002](https://passageway.marceltov.de/adr/0002-connectors-as-workspace-packages/index.md). ## Tests - `uv run pytest -q` runs everything offline. Connector tests run the connector as a real subprocess against an in-test server and through `passageway.testing.contract.check_connector`. - `corpus/` holds the frozen sample corpus, `corpus/snapshot.py` (the script that writes it), `corpus/questions.toml` (the 100 questions) and `corpus/measure.sh` (runs them and updates [Measurements](https://passageway.marceltov.de/measurements/index.md), which needs the embedding model); its tests run offline against the Wikipedia connector's fake MediaWiki and check the committed files ([Sample corpus](https://passageway.marceltov.de/reference/corpus/index.md)). - Opt-in live tests hit real services: `PASSAGEWAY_LIVE=1 uv run pytest connectors/wikipedia/tests/test_wikipedia_live.py`. - `tests/test_docs.py` keeps these docs honest: it fails when a setting is added to the code without being documented, and it runs the example connector from the guide. ## Docs ``` uvx --with mkdocs-material==9.7.7 --with mkdocs-llmstxt==0.5.1 --with 'mkdocs<2' mkdocs serve # live preview uvx --with mkdocs-material==9.7.7 --with mkdocs-llmstxt==0.5.1 --with 'mkdocs<2' mkdocs build --strict # what CI runs ``` The Roadmap page is rendered from `docs/roadmap.yml` by a small MkDocs hook (`docs_hooks/roadmap.py`), so adding or finishing a step is one entry in that file; `mkdocs serve` rebuilds the page when the file changes. Its icons are Material Design icons, named in the file. The header bar has two extra links for AI agents (`SKILL.md` and `llms-full.txt`). Material has no setting for that, so `overrides/partials/header.html` is a copy of Material's own header template with one block added. When you change the pinned Material version, copy the new `header.html` from the package and re-add the block; a test fails if the version in the copy and the pinned one differ. ## Changes Every change starts from an issue and goes through a pull request; the CI build must pass, including the strict docs build. The Checks workflow has four jobs, the test suite, the docs build, the plugin (`claude plugin validate .` and the hook's self-test) and the sidecar image, and a `changes` job in front that runs each only when a file it reads changed: a skill does not build the image, a Dockerfile does not build the docs. Run the same gates locally before a push: `uv run pytest -q`, the docs build above, `claude plugin validate .`. Decisions that future contributors will ask about get an ADR in `docs/adr/`. Dependabot keeps the dependencies and the GitHub Actions current (`.github/dependabot.yml`): security updates arrive at once; everything else waits two weeks after a release and comes grouped on Monday mornings, with the `Dependencies:` prefix instead of an issue number. Patch and minor bumps merge by themselves once the required checks are green; majors wait for review. A scheduled audit (`.github/workflows/audit.yml`) checks the locked runtime dependencies for known vulnerabilities every day. ## Releasing A release is a tag. Bump `version` in the root `pyproject.toml`, merge it, then tag the commit: ``` git tag -a v0.2.0 -m "A one-line headline for the release" git push origin v0.2.0 ``` The `Release` workflow checks that the tag equals the version in `pyproject.toml`, runs the tests, and creates the GitHub release. Its notes are the tag message, the pull requests merged since the previous release, and install instructions. It then posts the same notes to a notefeed feed, once per tag, so running the workflow again never posts twice. The feed is configured with two repository secrets, `NOTEFEED_FEED` and `NOTEFEED_FEED_PASSWORD`; without them (a fork) nothing is posted. To publish an existing tag again, run the workflow by hand from the Actions tab and give it the tag. This rewrites the release notes, so edits made by hand on GitHub are lost. The check compares the tag only with the root `pyproject.toml`; connector packages keep their own versions. # Roadmap What is planned and what has shipped, from the [trilium-recall](https://github.com/Marceltov/trilium-recall) engine to a core that knows no source. Next trilium-recall adopts passageway[#45](https://github.com/Marceltov/passageway/issues/45)[trilium-recall PR #11](https://github.com/Marceltov/trilium-recall/pull/11)[V5](https://passageway.marceltov.de/values/#v5-the-index-is-disposable-the-sources-stay-the-truth)[V11](https://passageway.marceltov.de/values/#v11-one-honest-page-beats-three-vague-ones) After parity, trilium-recall points here. The pull request with the migration guide is open there, for the owner's review. Details trilium-recall becomes a thin distribution (a `passageway.toml` with the Trilium connector, its plugin, docs that point here) or is archived with a pointer. It is a breaking change for its users, so it needs a migration guide: - `RECALL_*` becomes `PASSAGEWAY_*`; - the tool's `instance` argument becomes `source`; - configuration moves to `passageway.toml`. 29 Oct 2026 Hook, plugin, sidecar image, quickstart[Claude Code](https://passageway.marceltov.de/guides/claude-code/)[Run it](https://passageway.marceltov.de/deploy/)[PR #52](https://github.com/Marceltov/passageway/pull/52) The prompt hook with its self-test and the setup skill in the plugin, the image with the model baked in, and `passageway init` with a launchd example. 29 Oct 2026 Confluence connector[Connector](https://passageway.marceltov.de/connectors/confluence/)[ADR 0005](https://passageway.marceltov.de/adr/0005-confluence-data-center-first/)[PR #51](https://github.com/Marceltov/passageway/pull/51) The pages of chosen spaces of a Confluence Data Center, with a personal access token, written from the public REST documentation and tested against a fake of it; Cloud later (ADR 0005). 29 Oct 2026 Embedders, the rest[Choosing a model](https://passageway.marceltov.de/models/)[PR #50](https://github.com/Marceltov/passageway/pull/50) An adapter for OpenAI-compatible services and Voyage; three built-in presets with a checksummed download and a measured gate each; CoreML measured and left opt-in; the models compared on the corpus. 29 Oct 2026 Trilium connector[Connector](https://passageway.marceltov.de/connectors/trilium/)[ADR 0004](https://passageway.marceltov.de/adr/0004-no-summary-for-source-substitution/)[PR #49](https://github.com/Marceltov/passageway/pull/49) The trilium-recall sync as a connector. On trilium-recall's public fixture (its synthetic wiki, the real model, an in-test ETAPI) it scores hit@3 0.92 and silence 1.0 against the published 0.83 and 1.0; the check against a live Trilium and the private question set is still the owner's to run. 29 Oct 2026 Any embedding model, and a model comparison[Protocol](https://passageway.marceltov.de/reference/embedder-protocol/)[PR #39](https://github.com/Marceltov/passageway/pull/39) `kind = "command"` runs any program that speaks the embedder protocol, and `passageway eval compare` puts several models side by side. 29 Oct 2026 Questions and first measurements[Measurements](https://passageway.marceltov.de/measurements/)[PR #37](https://github.com/Marceltov/passageway/pull/37) 100 public questions for the sample corpus, and the numbers they give with the multilingual e5-small model. 29 Oct 2026 Evaluation harness[Guide](https://passageway.marceltov.de/guides/evaluate/)[PR #35](https://github.com/Marceltov/passageway/pull/35) `passageway eval` measures retrieval quality on your own questions and builds reports you can publish. 28 Oct 2026 A frozen corpus[Corpus](https://passageway.marceltov.de/reference/corpus/)[PR #32](https://github.com/Marceltov/passageway/pull/32) 30 hand-picked Wikipedia articles on deep learning in English and 30 in German, attributed and licensed CC BY-SA 4.0, so that measurements are reproducible. 28 Oct 2026 Markdown connector[Connector](https://passageway.marceltov.de/connectors/markdown/)[PR #30](https://github.com/Marceltov/passageway/pull/30) Index a folder of markdown files with front matter, written with the SDK. 28 Oct 2026 A connector SDK[Reference](https://passageway.marceltov.de/reference/sdk/)[PR #27](https://github.com/Marceltov/passageway/pull/27) `passageway-sdk` does the protocol plumbing, so that a Python connector is its logic. The Wikipedia connector is its first user. 28 Oct 2026 JSON Lines stays, with a JSON Schema[ADR 0003](https://passageway.marceltov.de/adr/0003-json-lines-over-a-subprocess-stays/)[PR #26](https://github.com/Marceltov/passageway/pull/26) The transport is settled before the protocol is frozen at v1. JSON Lines over a subprocess stays for connectors and embedders, with a published schema. 28 Oct 2026 Agent entry points[PR #14](https://github.com/Marceltov/passageway/pull/14)[PR #16](https://github.com/Marceltov/passageway/pull/16) Plugin skills, a root SKILL.md and llms-full.txt, so that an AI agent can learn passageway and write a connector. 28 Oct 2026 Dependabot and a daily audit[PR #10](https://github.com/Marceltov/passageway/pull/10) Weekly grouped bumps, with auto-merge for patch and minor ones once the required checks are green. 28 Oct 2026 First release[v0.1.0](https://github.com/Marceltov/passageway/releases/tag/v0.1.0)[PR #8](https://github.com/Marceltov/passageway/pull/8) A tag creates the GitHub release and posts its notes to notefeed, once. 28 Oct 2026 Documentation site[PR #6](https://github.com/Marceltov/passageway/pull/6) Built strictly on every pull request and published on its own domain. 28 Oct 2026 Wikipedia connector[PR #4](https://github.com/Marceltov/passageway/pull/4) Index a topic, some articles or a place, in English and German. 28 Oct 2026 A source-agnostic core[PR #2](https://github.com/Marceltov/passageway/pull/2) The connector protocol, a generic index, a sync with a deletion guard, passageway.toml, bearer and OAuth authentication. 28 Oct 2026 Bootstrap[trilium-engine-import](https://github.com/Marceltov/passageway/tree/trilium-engine-import) The trilium-recall engine imported with a fresh history. The last commit with all its Trilium code is a tag. ## Parked Considered and wanted, not needed yet: publishing the SDK and the connectors to PyPI ([#28](https://github.com/Marceltov/passageway/issues/28), waits for a dependency that is not on PyPI); cleaning the formulas in the live Wikipedia connector the way the sample corpus does ([#33](https://github.com/Marceltov/passageway/issues/33)); a reranker, and charts in evaluation reports, until a measurement shows what they would add ([V3](https://passageway.marceltov.de/values/#v3-numbers-are-measured-never-tuned)). ## Decided against Generation or summarisation inside passageway (it returns passages only: [V4](https://passageway.marceltov.de/values/#v4-passages-not-answers)); stdio MCP, because the index and the sync loop must outlive a client session; per-connector access control, because one `recall` searches every source and the callers are one person's or one team's tools: index only what every caller may see; a service installer in v1. ## Keep it true The timeline is rendered from [`docs/roadmap.yml`](https://github.com/Marceltov/passageway/blob/main/docs/roadmap.yml); on GitHub, read that file. Finish a step in the pull request that finishes it: set `status: done`, replace `when` with a `date`, add the link to its pull request, and move it to the top of the shipped entries. Change the file in the pull request that changes the plan. # Decisions # Sources are out-of-process connectors speaking JSON Lines ## Context and Problem Statement passageway must index many kinds of sources (notes, wikis, document stores) without the core knowing any of them, and anyone should be able to add a source in any language. How does the core obtain items from a source? ## Decision Drivers - The core must stay free of source-specific code and dependencies. - Third parties must be able to write a connector without touching the core. - A broken or hanging connector must not take the server, or the other sources, down. - Credentials for one source must not be visible to another source or to the core's own password. ## Considered Options - In-process Python plugins (a connector class loaded by the core). - Out-of-process executables that print JSON Lines, started per call by the core. - A long-running connector service per source, called over HTTP. ## Decision Outcome Chosen option: "out-of-process executables that print JSON Lines" (the transport is weighed against gRPC and others in [ADR 0003](https://passageway.marceltov.de/adr/0003-json-lines-over-a-subprocess-stays/index.md)), because it is the only option that gives language independence, process isolation and a single mechanism for bundled and third-party connectors at the cost of one process start per call. The core runs ` ` for `check`, `list`, `fetch` and `changed`. Each call starts with an empty environment plus the connector's own settings and the secrets it names, reads one JSON object per line, and stores nothing unless the process exits successfully. A timeout kills the connector's whole process group. A reconcile that would delete more than a configured share of a source is refused. ### Consequences - Good, because the core needs no source dependencies and a connector can be written in any language. - Good, because a crash, hang or oversized output fails one call and leaves the index as it was. - Good, because bundled connectors are tested by the same contract suite third parties can use. - Bad, because every call pays a process start, so `changed` polling must be cheap to start. - Bad, because the protocol is now a public interface; changing it later means versioning it (`PASSAGEWAY_PROTOCOL`). ### Confirmation `passageway.testing.contract.check_connector` and `tests/test_protocol.py` exercise the contract against real subprocesses, including limits, timeouts, secret masking and environment isolation. # One repository, each connector a self-contained workspace package ## Context and Problem Statement passageway ships bundled connectors (Wikipedia, Trilium, Confluence, later others) next to the core. Where should they live, and who owns their tests and dependencies? ## Considered Options - A repository per connector. - One repository, one package, connectors as submodules of the core. - One repository, a uv workspace: the core plus a self-contained package per connector. ## Decision Outcome Chosen option: "one repository, a uv workspace", because the protocol is still changing and a change must land in the core, the contract check and every connector as one reviewed unit, while each connector keeps its own `pyproject.toml`, dependencies, tests and README. A connector talks to the core only through the JSON Lines protocol and the shared contract check (`passageway.testing.contract`), so it can be split into its own repository later with `git subtree split` once the protocol is frozen, or when a connector gets its own maintainers or heavy dependencies. Third-party connectors already live outside this repository and follow the same recipe. ### Consequences - Good, because the core stays free of connector dependencies and a connector's tests live and run with it. - Good, because the layout is the template an outside connector author copies. - Bad, because contributors need the workspace installed (`uv sync` does it; the connector is part of the default dev group). - Bad, because one CI job covers all packages for now. # JSON Lines over a subprocess stays, with a JSON Schema beside it ## Context and Problem Statement [ADR 0001](https://passageway.marceltov.de/adr/0001-out-of-process-connectors/index.md) chose out-of-process connectors speaking JSON Lines, but never weighed a transport such as gRPC, and the planned embedder protocol would repeat it. Before the protocol is frozen at v1 and others build on it: is JSON Lines over a subprocess the right transport for connectors and for embedders? ## Decision Drivers - Anyone should be able to write a connector in any language in minutes, and debug it by hand. - A connector must be isolated: its own environment, killable, nothing stored from a failed call. - No ports, certificates or service lifecycle to run next to the core, least of all in the sidecar container. - Other languages should get a machine-readable contract. ## Considered Options 1. Keep JSON Lines over a subprocess and publish a JSON Schema for its messages. 1. Add a long-running mode over stdio with JSON-RPC, for connectors with an expensive start. 1. gRPC: a schema with generated stubs, streaming, health checks and connection reuse. 1. HTTP/REST or MCP as the connector transport. ## Decision Outcome Chosen option: "keep JSON Lines over a subprocess and publish a JSON Schema" (option 1), for connectors and for embedders alike. The cost that would argue against it is small. A start of the Wikipedia connector, with its `httpx` import, takes about 58 ms, and a bare Python start about 11 ms (median of ten runs on a laptop, 2026-10-28), while the poll runs once a minute. gRPC would add `grpcio` to the core and to Python connectors, a code generator per language, a port with TLS and authentication per connector and a lifecycle to manage, and a connector could no longer be tried with `echo`. Options 3 and 4 trade the property this project is built on for typed contracts that a schema gives anyway. What changes with this decision: - [`connector-protocol.schema.json`](https://passageway.marceltov.de/reference/connector-protocol.schema.json) describes every message (`check`, `list`, `fetch` in and out, `changed`). A test keeps it in step with the core's validator on a table of good and bad records, and the fake and Wikipedia connectors are validated against it. - `check` may print `"protocol": "1"`. The core refuses a connector that announces another version, with a message that says so; a `check` without the field is taken to speak v1. The server asks `check` once when a source starts (the full build), so a wrong version keeps the old index serving and shows as that source's error; polls and reconciles do not repeat it. The contract check asks it too. - The embedder protocol, which is planned (see the roadmap), uses the same transport. An embedder is designed as one long-lived process (`{"op":"info"}`, `{"op":"embed",…}`), which is exactly where a per-call start would hurt; it gets its own schema when it is built. ### Consequences - Good, because the contract stays one page and one file, and a connector in any language can validate itself against the schema. - Good, because nothing new has to run or be exposed. - Bad, because there is no streaming, progress or cancellation other than killing the process, and no connection reuse; a connector with a slow start pays it every minute. - Bad, because flow control is replaced by size limits per line and per call. ### More Information If a connector ever needs a long-running mode, JSON-RPC over stdio (as LSP and MCP do) is the upgrade path: the same JSON in one process kept alive, optional, with the per-call mode as the default. It is not built because no connector needs it. # A summary is a linked item, not a stand-in for its source ## Context and Problem Statement trilium-recall showed a note's summary in place of its source note when the summary's own best passage matched almost as well (within 0.05 of cosine similarity), so that a reader got the short page and a pointer to the long one. The Trilium connector carries trilium-recall's sync over to passageway, whose core knows no source ([V1](https://passageway.marceltov.de/values/#v1-the-core-knows-no-source-and-no-model-beyond-its-default)). Where does that rule go? ## Considered Options - A protocol field that marks an item as "stands for" another, and ranking code in the core that substitutes it. - The connector decides at indexing time which of the two to index. - Both are indexed as items that link each other; the core shows the other as a related item of a hit, as it does for every link. ## Decision Outcome Chosen option: "both are items that link each other", because the first option is a field only one connector would use and ranking code that behaves differently for one source's notion of a summary, which V1 rules out, and the second loses the source's detail. The summary carries a `wikiSource` relation to its source, and the connector adds the reverse link to the source, so a hit on either shows the other among its related items. The cost is measured, not assumed: on trilium-recall's public fixture the connector scores hit@3 0.92 and silence 1.0 against trilium-recall's published 0.83 and 1.0. The one miss is the fixture's German summary question, for which nothing passes the gate at all, not even the source, so the old rule (which only swapped a summary in for a source that was already a hit) would not have answered it either; the fixture does not show the rule's loss. If a real question set shows the loss to matter, a generic mechanism (an item's `weight` already exists; a "prefer the shorter of two linked items" rule would be the next candidate) is the way to bring it back, for every source. ### Consequences - Good, because the core stays free of Trilium's notion of a summary, and every connector gets the same link behaviour. - Good, because the measurement that decides whether the rule is missed is public and repeatable. - Bad, because a question answered by a source's long text may show the source rather than its summary, and the summary only as a related item. # The Confluence connector targets Data Center first ## Context and Problem Statement Confluence comes in two kinds that differ in authentication and API paths: Data Center (and Server) authenticates a personal access token with `Authorization: Bearer` under `/rest/api`, Cloud authenticates an e-mail address with an API token under `/wiki/rest/api` (and a newer v2 API). The roadmap left the choice open. Which does the connector support first? ## Considered Options - Data Center first: a personal access token, spaces by key, pages listed with ids and versions, changes by CQL. - Cloud first. - Both at once, behind a `KIND` setting. ## Decision Outcome Chosen option: "Data Center first", because that is what the predecessor's proposal described (a personal access token kept as a secret, spaces by key, a listing of ids and versions, changes since a time) and what the author's own use needs. Cloud is a later addition behind a setting; nothing in the item mapping depends on the kind. ### Consequences - Good, because one authentication scheme and one API shape are tested through an in-test server, and the mapping (sections at `h1` to `h3`, labels, ancestors as the path, every page weighing 1.0) is the same for Cloud later. - Bad, because Atlassian Cloud users have to wait; the README says so. - The connector is written from the public REST documentation and tested against a fake of it, not a live site; the first run against a real Data Center is the owner's. # HTTPS clients trust the operating system's certificate store ## Context and Problem Statement httpx and urllib check certificates against certifi's bundle, which knows the public CAs and nothing else. A Confluence or Trilium behind a company or internal CA, trusted in the macOS Keychain, the Windows store or `/etc/ssl`, failed with `CERTIFICATE_VERIFY_FAILED` while Safari and curl reached it. The ways out were `VERIFY_TLS=false`, which turns checking off, or a hand-exported bundle in `SSL_CERT_FILE`, which goes stale (#66). Where should trust come from, and where should the code that sets it live? ## Considered Options - Every client passes `truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)`, one line in each package, each package depending on `truststore` itself. - One helper in the SDK that every client calls. - `truststore.inject_into_ssl()` once at start-up. ## Decision Outcome Chosen option: "one line in each package". The SDK promises no dependencies, the markdown connector makes no HTTP request, and neither the embedder nor the core depends on the SDK, so a helper there would pull the SDK and `truststore` into places that have no use for them, all to share one line. `inject_into_ssl()` patches `ssl` for the whole process and every library in it, which is more than a client should decide for its neighbours. ### Consequences - Good, because a CA the operating system trusts works everywhere without a setting, and certificate checking stays on. - Good, because `tests/test_trust.py` lists every client, and CLAUDE.md makes a new client join it. - Bad, because the line is repeated in five places; a sixth client has to remember it, which the test and the rule are there for. # Several instances under one user, separated by convention ## Context and Problem Statement Several Claude setups run side by side on one machine (personal, work, a customer), and each may see only some sources. They need their own passageway: their own connectors, index and password, and their own MCP connection. Some setups also need more than one of them (#71). How are instances told apart, and how hard is the line between them? ## Considered Options - One setting, `PASSAGEWAY_INSTANCE=`, that moves the default config and data folders to `passageway-`, under the same operating-system user; each Claude connects to the instances the user chooses. - The same, but each instance as its own operating-system user or container, so that one Claude cannot read another instance's password. - No new setting: the skills set `PASSAGEWAY_CONFIG`, `PASSAGEWAY_DATA_DIR` and `PASSAGEWAY_MODEL_DIR` by hand for each instance. ## Decision Outcome Chosen option: "one setting, under the same user". It keeps sources, indexes, passwords and connections apart, which is what the owner asked for; which Claude connects to which instance is the owner's responsibility, and the documentation says that under one user it is not a security boundary. One setting instead of three keeps the skills and the commands (`passageway init`, `eval`, `model`) short and hard to get half right. The built-in models stay in the shared data folder, so a second instance downloads nothing. A Claude may connect to several instances: one hook file per instance (`passageway.json`, `passageway-.json`) and one MCP server (`passageway`, `passageway-`). The hook asks them all at once within its time limit and joins the passages; one that is down costs only its own. ### Consequences - Good, because nothing changes for a single instance, and adding one is `PASSAGEWAY_INSTANCE=` plus a port. - Bad, because a Claude with a shell can read every instance's password file under the same user. A boundary that must hold needs a user or a container per instance, which the documentation points to. # A failed fetch batch does not keep the other batches out ## Context and Problem Statement The core fetches changed items in batches of 50, and each `fetch` call stores everything or nothing. Until now the sync stopped at the first batch that failed. A page that fails on every run (one Confluence cannot render, or one that stays throttled) then failed its batch every time, and the items listed after it were never fetched: the source stayed partly indexed for good (#72, #78). How should one bad page cost less? ## Considered Options - The core goes on after a failing batch and reports the first error at the end; two failures in a row stop it. - The protocol lets `fetch` deliver the items it got and name the ones it could not, so even the failing batch stores its good items. - A connector skips a page it cannot load and exits 0. ## Decision Outcome Chosen option: "the core goes on after a failing batch", because it needs no protocol change (V9): every connector, bundled or not, benefits as it is, and a call still succeeds completely or stores nothing. Skipping in the connector was ruled out because the sync would report "ok" while a page is missing (V6). A per-item error in the protocol saves at most 49 items for one run, since the failed batch is fetched again at the next sync, and is not worth a protocol version. ### Consequences - Good, because one bad page costs only its own batch, and `/health` still names the failure with a count (`1 of 4 fetch batches failed`). - Good, because two failing batches in a row stop the fetch: a source that is down costs two call timeouts, not one per batch. - Bad, because the good items in a failing batch wait for the next sync; if that matters, a per-item error is the next step and needs a protocol version. - Only connector failures are skipped over. A failure in the index or the embedder still stops the sync at once, because it would repeat on every batch.