Reference
This page is the exhaustive one. It describes the stack as it runs today, which is the Compose
deployment of the published images. Commands that belong to the q15 binary are marked not built
and paired with what to type instead.
Run it today, end to end
Section titled “Run it today, end to end”This is the sequence that produces a running stack on the published images, in order, with what you should see at each step. It is longer than the four commands on Install q15 because it is the path that exists.
-
Get the deployment directory.
Today that means a checkout, because the Compose file and the secret templates live in the repository. When the
q15binary ships, this step is the installer instead, and it disappears.Terminal window git clone --depth 1 https://github.com/q15co/q15.gitcd q15 -
Seed the configuration and secret files.
Terminal window make compose-secrets-initcp deploy/compose/release.env.example deploy/compose/release.envYou should now see nine new files: eight under
deploy/compose/secrets/andauth/auth.jsonbeside them. Each printed line is eitherwroteorkept;keptmeans the file was already there, because nothing is overwritten. -
Fill in what your deployment needs.
At minimum, a provider block with a key your provider accepts, and the matching file in
secrets/with one value in it and mode 600. Models and Credentials cover those two, and the shipped config already points the embedding backend at the localq15-teicontainer. -
Decide the origin before you enrol.
release.env, or the environment Q15_WEB_ORIGIN=https://chat.example.netThe passkey you create in step 6 is bound to this hostname. Changing it later fails startup, so this is the one value you cannot repair afterwards.
-
Start the stack.
Terminal window podman compose --env-file deploy/compose/release.env \-f deploy/compose/docker-compose.image-first.yml up -d --wait--waitreturns only when every health check passes, so the command ending is the answer.You should now see six containers running, with only the web tier publishing a port. If one is missing or restarting,
podman compose … logs -f <name>names the input it could not read, and Troubleshooting has the short list. -
Enrol your authenticator.
Terminal window podman compose --env-file deploy/compose/release.env \-f deploy/compose/docker-compose.image-first.yml exec q15-web \/usr/local/bin/q15-web auth enroll 'Laptop'The command prints a blob and waits. Open the app, paste the blob, create the credential, and paste the one-line response back inside the five-minute window. See Chat for the constraints on acceptable authenticators.
-
Reach it. The web tier binds loopback, so use an SSH forward, or put a TLS terminator in front and set the origin to that hostname. A worked reverse-proxy example is in development.
-
Back it up before you need it.
/memoryand/workspaceare the two that cannot be recreated. Backups has the list and the order.
The stack
Section titled “The stack”| Stack | File | Images | Use it for |
|---|---|---|---|
| Deployment | deploy/compose/docker-compose.image-first.yml |
Published ghcr.io/q15co/q15-* tags |
A server that should stay running. |
| Development | docker-compose.yml (repository root) |
Built locally from source | Working on q15 itself. Project q15-local. |
Both are plain Compose files and the images are ordinary OCI images, so podman (the default) and
Docker run the same commands. The examples here use podman compose; on Docker, use docker compose
and change nothing else.
make compose-secrets-initcp deploy/compose/release.env.example deploy/compose/release.env# edit release.env, agent-config.yaml, proxy-policy.yaml and secrets/podman compose --env-file deploy/compose/release.env \ -f deploy/compose/docker-compose.image-first.yml up -d --wait--wait returns only when every health check passes. Nothing is built on your host: the deployment
file uses image: only.
Paths are relative to deploy/compose/, which is where the Compose file expects them.
| File | Mounted at | Purpose |
|---|---|---|
release.env |
— | Q15_IMAGE_TAG and embedding-backend overrides. Passed with --env-file. |
agent-config.yaml |
/etc/q15/agent/config.yaml |
Providers, tools, Telegram, the bridge socket. |
proxy-policy.yaml |
/etc/q15/proxy/policy.yaml |
Egress passthrough and credential injection rules. |
auth/ |
/etc/q15/auth |
OpenAI OAuth credential state. Mount the directory, not the file. |
secrets/<name> |
/run/q15-secrets/<name> |
One file per credential, each paired with a *_FILE environment variable. |
make compose-secrets-init copies the tracked *.example templates to those names and never
overwrites a file that already exists.
Volumes
Section titled “Volumes”| Volume | Mount | Contents |
|---|---|---|
q15_workspace |
/workspace |
The durable project tree, working files, embedding source registry. |
q15_memory |
/memory |
Identity, semantic knowledge, working state, turn history. |
q15_skills |
/skills |
Installed skills. @builtin/ ships with the image. |
q15_media |
/media |
The media store: attachments and generated media. |
q15_agent_state |
/var/lib/q15/agent |
Scheduled-job definitions and run records. |
q15_proxy_state |
/var/lib/q15/proxy |
Proxy-owned state, including the interception CA. |
q15_web_state |
/var/lib/q15-web |
Web credential store and admin socket. Seeded UID 65532, GID 1000, mode 0700. |
q15_bridge |
/run/q15 |
The bridge socket. Mounted in the agent and the web tier only, read-only on web. |
q15_qdrant_storage |
/qdrant/storage |
Embedding collections. |
q15_exec_nix_store |
/nix |
The executor’s package store, kept warm across restarts. |
q15_tei_hf_cache |
/data |
Embedding model weights. |
| Service | Inside the stack | Published |
|---|---|---|
q15-web |
0.0.0.0:8080 (the deployment file sets it) |
127.0.0.1:8080 |
q15-agent |
bridge socket at /run/q15/bridge.sock |
— |
q15-exec |
:50051 (gRPC) |
— |
q15-proxy |
:18080 (HTTP proxy), :50052 (admin gRPC) |
— |
q15-qdrant |
:6334 |
— |
q15-tei |
:80 |
— |
Only the web tier publishes anything, and only on loopback. The port is pinned by the contract test, not by a variable:
make compose-checkpodman-compose -f docker-compose.yml configpodman-compose -f deploy/compose/docker-compose.image-first.yml configmake test runs the same check, so a change that moves the port or the mount set fails the gate.
Routes
Section titled “Routes”| Route | What it does |
|---|---|
GET /ws |
The browser socket: streaming replies, tool activity, history paging, media. |
GET /api/turns |
Transcript history, newest first, paged with after_seq and limit. |
POST /api/media |
Uploads a sealed batch of attachments. One proof-authorized request per send. |
GET /api/media/{hash} |
Serves a stored attachment back, streamed in 32 KiB encrypted chunks. |
GET / and assets |
The app itself, served from the binary. Hashed files only; no directory listings. |
GET /healthz |
The only unauthenticated endpoint. Used by the container health check. |
There is no password, no bearer token, no query token and no proxy-identity fallback. Unauthenticated
requests to anything but /healthz answer 401, and an empty credential store starts locked.
Content sealing
Section titled “Content sealing”Every frame that carries content is sealed between the browser and the agent: chat bodies, reasoning, tool arguments and results, live deltas, snapshots and replayed history. Unknown frame types are sealed by default, so a new frame type fails closed rather than leaking. Only a small control set (ids, counters, timestamps, error codes) travels in plaintext.
The keys are derived per connection and bound to the session: an ephemeral ECDH exchange between the browser and the agent, then HKDF-SHA-256 key derivation, then AES-256-GCM per direction, with the chunk index bound into each message. The web tier relays opaque bytes; it holds no key and parses no chat content.
Attachments ride the same envelope. The composer holds files in memory until you send; one proof-authorized upload carries a sealed batch, capped at 16 files and 8 MiB of decoded file bytes per send. The agent sniffs the type, stores the file under a content hash, and serves it back through an authenticated descriptor carrying filename, type and size.
Configuration
Section titled “Configuration”agent-config.yaml
Section titled “agent-config.yaml”providers: - name: ollama-cloud type: ollama base_url: https://ollama.com key_env: OLLAMA_API_KEY discovery: models_dev: true
agent: name: Q15 memory_recent_turns: 6 bridge: listen_target: unix:///run/q15/bridge.sock tools: web_search: brave_api_key_env: BRAVE_API_KEY embeddings: qdrant_url_env: Q15_QDRANT_URL provider: openai base_url_env: Q15_EMBEDDINGS_BASE_URL model: qwen3-embedding-0.6b dimensions: 1024 batch_size: 128 schedule: max_jobs: 64 max_run_turns: 16 telegram: token_env: Q15_TELEGRAM_TOKEN allowed_user_ids_env: Q15_TELEGRAM_ALLOWED_USER_IDS| Key | Type | Default | Notes |
|---|---|---|---|
providers[].name |
string | required | The label used in model refs. |
providers[].type |
enum | required | ollama, openai-compatible, openai-codex. |
providers[].base_url |
string | provider default | For example https://ollama.com, or your own server. |
providers[].key_env |
string | — | The environment variable holding the key. *_FILE also works. |
providers[].discovery.models_dev |
bool | false | Enrich discovered models with cost, context and benchmark metadata. |
providers[].discovery.include/exclude |
[]string | — | Glob filters over discovered model ids. |
agent.name |
string | required | The agent’s own name, written into its identity files. The planned installer derives the unit and volume names from it; the shipped Compose stack uses fixed names. |
agent.memory_recent_turns |
int | 6 | How many recent turns are replayed verbatim. |
agent.bridge.listen_target |
string | unix:///run/q15/bridge.sock |
Unix only. TCP is refused: this is the identity surface. |
agent.tools.web_search.brave_api_key_env |
string | — | Omit the block to run without web search. |
agent.tools.embeddings.* |
— | see Memory | provider is openai (local TEI) or gemini; omit the block to disable. |
agent.tools.schedule.max_jobs |
int | 64 | Bounded at 1000. |
agent.tools.schedule.max_run_turns |
int | 16 | Bounded at 128. |
agent.telegram.token_env |
string | — | Omit to run without Telegram. |
agent.telegram.allowed_user_ids_env |
string | — | Required if Telegram is configured. An empty list is refused. |
There is no model list in this file. Providers publish rosters, and the current model is runtime state. See Models.
proxy-policy.yaml
Section titled “proxy-policy.yaml”proxy: no_proxy: - localhost - 127.0.0.1 - ::1 - q15-proxy - q15-exec set_lowercase_proxy_env: true secrets: - github_token rules: - name: github-api match_hosts: - api.github.com env: - name: GH_TOKEN secret: github_token rules: - github-api in: - header| Key | Purpose |
|---|---|
proxy.no_proxy |
Hosts that bypass the proxy entirely. |
proxy.set_lowercase_proxy_env |
Also export the proxy variables in lowercase. |
proxy.secrets |
The secret aliases this policy may use. |
proxy.rules[].match_hosts |
Hostnames the rule applies to. |
proxy.rules[].match_path_prefixes |
Optional path restriction inside a host. |
proxy.rules[].set_header |
Headers to set, with {{ secret.alias }} placeholders. |
proxy.rules[].set_basic_auth |
username plus a secret, injected as an Authorization: Basic header. |
proxy.rules[].replace_placeholder |
Literal placeholders replaced in a query, header or body. |
proxy.env[] |
An environment variable the executor receives, backed by a secret, gated by rules. |
proxy.env[].in |
Where the value may appear: header, env, and so on. |
Full detail, including the interception model and response scrubbing, is in Credentials.
Environment
Section titled “Environment”| Variable | Default | Purpose |
|---|---|---|
Q15_IMAGE_TAG |
stable |
The release tag applied to all four q15 images. |
Q15_WEB_ORIGIN |
required | Exact public HTTPS origin, no trailing slash. Bound to passkeys. |
Q15_WEB_LISTEN |
127.0.0.1:8080 |
Listen address inside the container. The deployment file sets 0.0.0.0:8080 so the published port works. |
Q15_WEB_BRIDGE |
unix:///run/q15/bridge.sock |
The agent bridge the web tier dials. |
Q15_WEB_STATE_DIR |
/var/lib/q15-web |
Credential and session state. |
Q15_WEB_TLS_CERT, Q15_WEB_TLS_KEY |
unset | Optional direct TLS; both or neither. |
Q15_WEB_DIR |
unset | Serve a development bundle from a directory instead of the embedded one. |
TEI_IMAGE |
ghcr.io/huggingface/text-embeddings-inference:turing-1.9 |
The embedding server image. |
TEI_MODEL |
Qwen/Qwen3-Embedding-0.6B |
The model the container serves. |
TEI_SERVED_MODEL_NAME |
qwen3-embedding-0.6b |
Must match model in agent-config.yaml. |
TEI_MAX_CLIENT_BATCH_SIZE |
128 | Must be at least the config’s batch_size. |
The secret contract
Section titled “The secret contract”Every binary reads a secret from NAME, and failing that from the file named by NAME_FILE. The file’s
contents are trimmed, and an empty file is an error rather than an empty secret. That one rule is how
secrets/github_token reaches a container as GITHUB_TOKEN_FILE=/run/q15-secrets/github_token.
The tracked templates are:
secrets/brave_api_key secrets/ollama_api_keysecrets/gemini_api_key secrets/q15_telegram_allowed_user_idssecrets/github_token secrets/q15_telegram_tokensecrets/moonshot_api_key secrets/zai_api_keyauth/auth.json.exampleThey are templates: make compose-secrets-init copies each to its real name, and the real files are
git-ignored. See Credentials for what each one is used for.
Commands
Section titled “Commands”| Command | Status | Today |
|---|---|---|
q15 init |
not built | Write agent-config.yaml, proxy-policy.yaml and secrets/ by hand. |
q15 up |
not built | podman compose … up -d --wait |
q15 status |
not built | podman compose … ps |
q15 logs -f <service> |
not built | podman compose … logs -f q15-agent |
q15 doctor |
not built | Container health checks plus the log lines that name the failing input. |
q15 secret set |
not built | Write the file into secrets/ and recreate the container that reads it. |
q15-web auth enroll|list|revoke DEVICE_ID |
built | podman compose exec q15-web /usr/local/bin/q15-web auth … |
q15-auth login|status|logout |
built | Run on the host against the auth/ directory. |
Releases
Section titled “Releases”A release is one set of four images, always published together:
| Tag | Meaning |
|---|---|
stable |
A moving tag. It advances on all four packages only after the same release is verified on all four. |
YYYY.MM.DD.<run-number> |
One immutable release, for example 2026.10.10.84. The same tag always selects one compatible set of four. |
Published tags are on the q15 packages page, and the images pull without registry authentication. Pin an immutable tag if you want control over when your stack moves. See Updating for what does not roll back.
Development stack
Section titled “Development stack”make project-setup # the pinned toolchain, under ./.toolsmake compose-up # builds from the working tree and starts with --waitmake compose-psmake compose-logs SERVICE=q15-agentmake compose-downmake verify # lint, tests, contract checksmake build # agent, auth, exec, proxy and web binaries into ./binmake ui-dev # the browser client against a local APImake ui-test # browser unit testsThis stack builds from source and follows the moving :main tag. Use it to work on q15; use the
deployment stack to run it.