Goal-based, adaptive browser testing you can watch run live. Give it a URL and a plain-English goal; an AI agent drives a real Chromium browser like a user, streams the session live, decides pass/fail, and produces a shareable PDF report.
Status: actively developed. Runs end-to-end and is deployed. See Roadmap for what's intentionally left for later.
- Goal-based testing — no selectors or scripts. Describe the goal in English; the agent figures out the steps and adapts if the UI changes.
- Watch it live — a real-time CDP screencast of the browser streams to the UI over a WebSocket while the test runs.
- Pass/fail verdict — browser-use's built-in judge decides whether the goal was actually met, with a written rationale.
- PDF report — a polished one-page report (verdict, stats, summary, session recording link) is generated automatically when a run finishes.
- Trigger from anywhere — token-authed REST API to start runs and fetch results from your own tools or pipelines.
| Layer | Tech |
|---|---|
| Frontend | React + Vite (live viewer) |
| API | Express — REST + WebSocket relay |
| Agent | Python + browser-use driving Playwright Chromium |
| Report | HTML → PDF rendered by the same Chromium (embedded Bricolage Grotesque + IBM Plex Mono) |
| Model | OpenAI (default gpt-4.1) |
| Packaging | Single Docker image — ghcr.io/rszhd/qassist, docker compose up |
┌──────────────┐ POST /api/runs ┌──────────────────────────────┐
│ React viewer │ ──────────────────▶ │ Express (server/) │
│ (frontend/) │ ◀── WS /ws ──────── │ • spawns the Python agent │
└──────────────┘ frames + events │ • relays NDJSON → WebSocket │
│ • renders PDF on completion │
└───────────────┬──────────────┘
spawn (1 per run) │ stdout: NDJSON
▼
┌──────────────────────────────┐
│ agent/run_agent.py │
│ • browser-use + Chromium │
│ • CDP screencast → "frame" │
│ • per-step → "step" events │
│ • final verdict → "done" │
└──────────────────────────────┘
Express spawns agent/run_agent.py as a child process per run. The script prints
newline-delimited JSON to stdout — frame events (JPEG screencast, ~6 fps) plus
step/done events — which Express relays over the WebSocket. The screencast is
viewer-gated: Express tells the agent over stdin when the first viewer attaches
and the last one leaves, and frames are only captured in between, so unwatched
runs (e.g. CI-triggered) skip the encode cost entirely. On completion the server
calls agent/make_report.py to render the PDF. The worker holds no durable
state between runs.
agent/ Python agent + report renderer
run_agent.py runs one browser-use test, emits NDJSON to stdout
make_report.py renders a run's JSON into a PDF (via Chromium)
fonts/ embedded woff2 fonts (self-contained PDFs)
requirements.txt
server/
src/server.js Express REST API + WebSocket relay + run registry
frontend/ React + Vite UI — Run view (live viewer) and Projects
Dockerfile multi-stage: builds frontend, bundles Node + Python + Chromium
docker-compose.yml
.env.example
Docker is the only thing you need installed — Node, Python and Chromium all live inside the image.
The fastest path: pull a published, tested image. Two files and one command, and you never see the source.
curl -O https://raw.githubusercontent.com/rszhd/qassist/main/docker-compose.release.yml
curl -o .env https://raw.githubusercontent.com/rszhd/qassist/main/.env.example
# generate the one secret .env needs, then start it:
sed -i "s/^KEY_ENCRYPTION_SECRET=$/KEY_ENCRYPTION_SECRET=$(openssl rand -hex 32)/" .env
docker compose -f docker-compose.release.yml up -dImages are published to
ghcr.io/rszhd/qassist
on every version tag — :1.2.3 exactly, :1.2 to float on patches, and
:latest. Pin the exact version; the file above does. Upgrading is editing
that tag and re-running the command, and the schema migrates itself at boot.
Clone the repo, then:
cp .env.example .env # then generate its one required secret:
sed -i "s/^KEY_ENCRYPTION_SECRET=$/KEY_ENCRYPTION_SECRET=$(openssl rand -hex 32)/" .env
docker compose up --buildThe first build takes a few minutes (it downloads Chromium). Either way, open
http://localhost:8080, enter a URL + goal, and watch it run. Finished runs
expose a Download PDF report button.
Two things worth knowing on a fresh clone:
- Your OpenAI key goes in the app, not in
.env. The agent is bring-your-own-key: open Settings → OpenAI key and paste yours — it is stored encrypted (that is whatKEY_ENCRYPTION_SECRETis for) and every run you start is funded by it. Until then, starting a run answers503pointing you at Settings. The server holds no key of its own, so an instance you share can never spend your tokens on someone else's runs (US-039). WORKER_API_TOKENis optional for local use. Leave it unset and the API needs no token, so there's nothing to paste into the UI. The server logs a warning at startup because this leaves the port open to your network — set a token before exposing it beyond localhost.
Everything else, including the Postgres control plane, is wired up by
docker compose; there is nothing else to configure.
Set in .env (see .env.example):
| Variable | Default | Purpose |
|---|---|---|
WORKER_API_TOKEN |
— | Bearer token required on every API/WS call |
BROWSER_USE_MODEL |
gpt-4.1 |
OpenAI model |
MAX_CONCURRENT_SESSIONS |
4 |
Concurrent browser cap — the real throttle. Rule: floor((RAM_GB − 1.5) / 1). Runs over the cap wait in an in-memory FIFO and are told their position live; the queue is not durable, so a restart marks everything still waiting error |
MAX_STEPS |
60 |
Safety ceiling on agent steps per run |
MAX_RUN_MEMORY_MB |
1600 |
Per-run process-tree RSS cap; over it the run is killed and marked failed. Summed RSS double-counts Chromium's shared pages — a recording run measures ~1177 MB here but only ~660 MB PSS (US-024) |
PORT |
8080 |
Express listen port |
QA_RECORD |
1 |
Record every session to runs/<runId>/recording.mp4. 0 disables it — frame capture is then skipped entirely while nobody is watching the run |
ARTIFACT_RETENTION_DAYS |
7 |
How long runs/<runId>/ (report PDF + mp4 recording) is kept. Swept at startup and every 6 h; the history row and its verdict are kept forever regardless. 0 = never prune |
PUBLIC_BASE_URL |
— | Public address of this instance (https://qa.example.com). Only used to make the PDF report's "View recording" link resolvable; the recording is served either way |
KEY_ENCRYPTION_SECRET |
— | Required. Encrypts stored OpenAI keys at rest. Generate once (openssl rand -hex 32) and keep it — losing it makes every stored key undecryptable |
DATABASE_URL |
— | Required — Postgres control plane (saved tests, run history, and the users row a stored key lives on). Set for you in both paths — docker compose points it at its own db service, npm run dev at the same container on localhost:5433. Without it the server refuses to boot |
RESEND_API_KEY |
— | Resend key for result email. Unset (or MAIL_FROM unset) = notifications off: prefs still save, nothing sends. /api/health reports this as mail |
MAIL_FROM |
— | Sender address, on a domain verified with Resend (QAssist <qa@example.com>) |
NOTIFY_EMAILS |
— | Comma-separated fallback recipients, used when a project names none |
NOTIFY_MODE |
failure |
Default for tests in no project — one of failure, always, never. failure covers anything that is not a pass, including a run that ended unjudged. Projects carry their own mode |
NOTIFY_SECRET |
WORKER_API_TOKEN |
Signs unsubscribe links. Falls back to a per-boot random value if the token is blank too, which invalidates links already mailed |
OPERATOR_EMAIL |
operator@qassist.local |
Seeds the single account row, and is the last-resort recipient after NOTIFY_EMAILS. The default is not a deliverable address |
STRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRETSTRIPE_PRICE_ID |
— | Subscription billing (see Billing). All blank = billing off, every run free — the self-host default. Also needs PUBLIC_BASE_URL, the control plane and AUTH_ENABLED; missing any one leaves the instance free. /api/health reports this as billing |
BILLING_EXEMPT_EMAILS |
OPERATOR_EMAIL |
Comma-separated accounts that run without subscribing |
Per-run artifacts (screenshots, recording.mp4, report_data.json,
report.pdf) are written to
runs/<runId>/ and persisted via the ./runs volume. Durable metadata —
saved tests, suites, run verdicts — lives in Postgres (pgdata volume);
schema and rationale in db/README.md.
The two have different lifetimes on purpose: a history row is a few hundred
bytes and is kept forever, while the directory beside it is tens of MB and is
deleted after ARTIFACT_RETENTION_DAYS. A pruned run keeps its verdict,
timings and step count, and simply stops offering the report and recording.
A handful of further variables — APP_HOST, ACME_EMAIL, QASSIST_IMAGE,
RUNS_DIR, ROBOTS_TAG — are read only by the production overlay and are
documented in DEPLOY.md. A plain docker compose up ignores
them entirely.
# start a run
curl -X POST http://<host>:8080/api/runs \
-H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
-d '{"goal":"Search for a laptop and add the first result to cart","start_url":"https://example.com"}'
# -> {"runId":"...","status":"running"}
# poll status + result (the history row's columns, plus runId/result/status
# for polling; the run's own page is http://<host>:8080/runs/<runId>)
curl http://<host>:8080/api/runs/<runId> -H "Authorization: Bearer $WORKER_API_TOKEN"
# download the PDF report (202 while generating, 200 when ready)
curl -L http://<host>:8080/api/runs/<runId>/report.pdf \
-H "Authorization: Bearer $WORKER_API_TOKEN" -o report.pdf
# download the session recording (mp4; 404 if the run wasn't recorded).
# Supports range requests, and — alone among the endpoints — ?token=<token>
# instead of the header, so a <video> element can stream it directly.
curl -L http://<host>:8080/api/runs/<runId>/recording \
-H "Authorization: Bearer $WORKER_API_TOKEN" -o recording.mp4
# health
curl http://<host>:8080/api/health
# live feed: ws://<host>:8080/ws?runId=<runId>&token=<WORKER_API_TOKEN>Needs the Postgres control plane (DATABASE_URL); without it these answer 503
and ad-hoc runs above still work.
A saved test is the reusable unit. Grouping is optional: a test can sit in a
project, and within it in at most one module (auth, payment, …).
A suite is the cross-cutting alternative — an arbitrary many-to-many
selection inside one project, so the same test can be in smoke and
nightly. Projects, modules and suites are all runnable in one call.
# save a test, then run it (start_url is overridable per run — point CI at a
# fresh preview deploy without editing the test)
curl -X POST http://<host>:8080/api/tests \
-H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"login smoke","goal":"log in and see the dashboard","start_url":"https://example.com"}'
curl -X POST http://<host>:8080/api/tests/<testId>/run \
-H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
-d '{"start_url":"https://preview.example.com","trigger":"ci"}'
# organize: a project, a module in it, then file the test under the module
# (project_id is derived from the module — you never set both)
curl -X POST http://<host>:8080/api/projects \
-H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Checkout"}' # -> {"id":"...","slug":"checkout",...}
curl -X POST http://<host>:8080/api/projects/checkout/modules \
-H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"Auth"}' # -> {"id":"...","slug":"auth",...}
curl -X PUT http://<host>:8080/api/tests/<testId> \
-H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
-d '{"module_id":"<moduleId>"}'
# run a whole module or project. Paths take a slug or a uuid, so CI configs
# don't have to carry ids; one run is started per member test.
curl -X POST http://<host>:8080/api/projects/checkout/modules/auth/run \
-H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
-d '{"trigger":"ci"}'
# -> {"moduleId":"...","runs":[{"runId":"...","testId":"...","status":"queued"}, ...]}
# list/filter: ?project_id=<id>, ?module_id=<id>, or project_id=none (Ungrouped)
curl "http://<host>:8080/api/tests?project_id=<projectId>" \
-H "Authorization: Bearer $WORKER_API_TOKEN"Suites work the same way but belong to a project, and their members must too:
curl -X POST http://<host>:8080/api/suites \
-H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"smoke","project_id":"<projectId>","test_ids":["<id>","<id>"]}'
curl -X POST http://<host>:8080/api/suites/<suiteId>/run \
-H "Authorization: Bearer $WORKER_API_TOKEN"Deleting a module or project never deletes tests — they fall back to Ungrouped. Deleting a project does take its suites with it.
POST /api/schedules runs any of those four things on a repeating slot. The
schedule names exactly one target — test_id, module_id, suite_id or
project_id — and fires the same way the matching /run endpoint does: one
run per member test, queued behind MAX_CONCURRENT_SESSIONS like any other.
# every 6 hours: slots are anchored to local midnight, so this fires at
# 00:15 / 06:15 / 12:15 / 18:15 in the schedule's own timezone
curl -X POST http://<host>:8080/api/schedules \
-H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
-d '{"suite_id":"<suiteId>","kind":"hourly","interval_hours":6,"minute":15,
"tz":"Europe/Berlin"}'
# -> {"id":"...","kind":"hourly","interval_hours":6,"enabled":true,
# "next_run_at":"2026-07-23T04:15:00.000Z", ...}
# nightly, and weekly on a Tuesday
-d '{"test_id":"<testId>","kind":"daily","hour":2,"minute":30,"tz":"Europe/Berlin"}'
-d '{"project_id":"<id>","kind":"weekly","weekday":2,"hour":9,"tz":"Europe/Berlin"}'
# list (filter by any target column), pause, re-time, remove
curl "http://<host>:8080/api/schedules?suite_id=<suiteId>" -H "Authorization: Bearer $WORKER_API_TOKEN"
curl -X PUT http://<host>:8080/api/schedules/<id> -H "Authorization: Bearer $WORKER_API_TOKEN" \
-H "Content-Type: application/json" -d '{"enabled":false}'
curl -X DELETE http://<host>:8080/api/schedules/<id> -H "Authorization: Bearer $WORKER_API_TOKEN"kind is hourly (with interval_hours ∈ 1, 2, 3, 4, 6, 8, 12), daily
(hour/minute) or weekly (plus weekday, 0 = Sunday). tz is an IANA
name and defaults to the server's; times mean wall-clock time there, so a
daily slot keeps its hour across a DST change. Every write recomputes
next_run_at, which is what the scheduler claims each minute.
A slot fires once even if the server was down for several — missed slots are
not replayed. A test with a run still queued or running is skipped for
that slot while its siblings in the same suite go ahead. Deleting the target
deletes its schedules.
A pipeline triggers a module or a suite — the set of tests that covers a
change — passing the fresh preview URL as start_url, then polls each run and
fails the job unless every one comes back passed. That's curl plus a poll
loop, no Action and no plugin: docs/ci.md has the script and
ready-made GitHub Actions and GitLab CI jobs.
GET /api/runs lists finished and in-flight runs newest first, from the same
control plane (503 without DATABASE_URL). Every row carries the test's name
and grouping, so a history table renders from one request.
# filters combine: test_id, project_id, module_id, status (comma-separated),
# since/until (ISO timestamps on created_at), limit (≤200) and offset
curl "http://<host>:8080/api/runs?test_id=<testId>&status=failed,error&limit=20" \
-H "Authorization: Bearer $WORKER_API_TOKEN"
# -> {"runs":[{"id":"...","status":"failed","test_name":"login smoke",
# "success":false,"created_at":"...","has_recording":true, ...}],
# "total":37,"limit":20,"offset":0}total is the unpaginated count, for paging. A run's project is its test's,
reached by join — so a run whose test was later deleted keeps its history row
(goal and start_url were copied at enqueue time) but matches no project
filter. Once retention prunes runs/<id>/, artifacts_deleted_at is set and
the row reports no recording or report while the verdict survives.
Off unless RESEND_API_KEY and MAIL_FROM are both set — GET /api/health
answers mail so you can tell "not configured" from "nothing failed yet".
Prefs live on the project, so one recipient list covers every test in it:
# who hears about this project's runs, and when.
# notify: failure (default) | always | never — "failure" is anything that is
# not a pass, so an errored or unjudged run mails too.
curl -X PUT http://<host>:8080/api/projects/checkout \
-H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
-d '{"notify":"failure","notify_emails":["qa@example.com","lead@example.com"]}'Prefs are settable on PUT only, never on create. An empty notify_emails
falls through to NOTIFY_EMAILS, then to OPERATOR_EMAIL. Each finished run
decides for itself, one mail per recipient with the PDF attached; a run
started ad-hoc from the Run view never mails, having no test and no project.
Every mail carries a signed unsubscribe link — the one route in the app that takes no bearer token, because the person clicking it was mailed a report and does not have the instance's token. Suppression is by address and instance- wide, so being added to a second project cannot quietly re-subscribe someone:
# who has opted out, and putting one back
curl http://<host>:8080/api/notifications/suppressions \
-H "Authorization: Bearer $WORKER_API_TOKEN"
curl -X DELETE http://<host>:8080/api/notifications/suppressions/qa@example.com \
-H "Authorization: Bearer $WORKER_API_TOKEN"Off unless you turn it on, and self-hosting is always free. With
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and STRIPE_PRICE_ID unset there
is no billing UI, no gating and no /api/billing — the instance behaves as if
this feature did not ship. GET /api/health answers billing either way.
Turning it on gates starting a run and nothing else: an account without an
active subscription gets 402 from every run trigger, and its schedules stop
firing (they are never deleted, and resume on resubscribe). Reading stays open
— history, run detail, steps, reports and recordings all keep working, so
cancelling is never a data-loss event. LLM tokens remain BYOK on every tier: a
subscription pays for hosting, not for the model.
It also requires AUTH_ENABLED and PUBLIC_BASE_URL. Billing charges users,
so an instance with no real users — single-token or open — is never gated, and
Stripe needs somewhere to send the customer back to.
active and trialing may run. past_due keeps running until
current_period_end, because Stripe retries a declined card for around two
weeks and cutting off a paying customer on the first failed retry is the worse
bug. canceled, unpaid, incomplete and "never subscribed" are refused.
Only one plan exists: one recurring Stripe price, taken through Checkout, with
changes and cancellation handled by the Stripe Customer Portal. There is no
payment UI of our own, and no stripe dependency — the integration is three
form-encoded POSTs and one HMAC.
Testing it locally (Stripe test mode — nothing here touches live money):
# 1. a test-mode price to sell, and the endpoint's signing secret
stripe login
stripe listen --forward-to localhost:8080/api/billing/webhook # prints whsec_…
# 2. .env: the test keys, plus billing's other preconditions
# STRIPE_SECRET_KEY=sk_test_… STRIPE_WEBHOOK_SECRET=whsec_…
# STRIPE_PRICE_ID=price_… PUBLIC_BASE_URL=http://localhost:8080
# AUTH_ENABLED=1 SESSION_SECRET=… DATABASE_URL=…
# 3. sign in, hit Subscribe in Settings, pay with Stripe's test card
# 4242 4242 4242 4242, any future expiry, any CVC.
# 4. drive the rest of the lifecycle without waiting for a renewal
stripe trigger customer.subscription.deleted # → runs start returning 402stripe listen is not optional for local work: the webhook is what records the
subscription, and it authenticates by signature over the exact bytes Stripe
sent — so it cannot be faked with a hand-rolled curl.
This section is for working on QAssist. To just run it, see Run it above — that path needs Docker only.
Developing needs Node 22+ and Python 3.11+ on the host, plus Docker: the dev server starts the Postgres control plane as a container for you (see below), so Docker has to be running even when you aren't using the full stack.
One-time setup:
cp .env.example .env # set WORKER_API_TOKEN and KEY_ENCRYPTION_SECRET
# agent venv (browser-use + Playwright Chromium); needs python3-venv or uv
cd agent && uv venv .venv && uv pip install -r requirements.txt --python .venv/bin/python \
&& .venv/bin/playwright install chromium
# without uv: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt && ...
cd ../server && npm install
cd ../frontend && npm installThen run both dev servers, each with hot reload and logs in the foreground:
# terminal 1 — API on :8081 (node --watch; loads ../.env, uses agent/.venv;
# auto-starts the compose Postgres on 127.0.0.1:5433 and connects to it)
cd server && npm run dev
# terminal 2 — frontend on :5173 (Vite HMR; proxies /api and /ws to :8081)
cd frontend && npm run devOpen http://localhost:5173 and paste your WORKER_API_TOKEN. Dev defaults to
port 8081 so it can't collide with the Docker stack on 8080; override with
PORT=<p> npm run dev (server) and API_PORT=<p> npm run dev (frontend),
keeping the two in sync.
npm run dev runs docker compose up -d --wait db first, so Postgres is
already healthy by the time the server boots; pending migrations in
db/migrations/ are then applied automatically. You don't set DATABASE_URL
— the dev script defaults it to the container on 127.0.0.1:5433. The
control plane is required (US-039): if Postgres isn't reachable the server
refuses to boot and says so, rather than serving a half-app.
- Port 5433 already taken? It's mapped that way to avoid colliding with a
local Postgres on 5432. To use a different database entirely, pass the URL
in the shell —
DATABASE_URL=postgres://… npm run dev— not via.env, since the script's default is applied after.envis read. - Reset the data:
docker compose down -vdrops thepgdatavolume; the nextnpm run devrecreates the schema from scratch. npm testneeds none of this — the control-plane tests run the real migrations against an in-memory Postgres (pg-mem).
Runs as a single container (docker compose up -d) on any Linux host with
Docker (4 vCPU / 8 GB is comfortable for ~4 concurrent sessions). The app
listens on port 8080.
To put it on a public hostname over HTTPS, layer the production overlay on the same base file — it adds a Traefik reverse proxy with automatic Let's Encrypt certificates and stops publishing 8080 on the host:
docker network create qassist-edge
docker compose -p qassist-proxy -f docker-compose.proxy.yml up -d
docker compose -p qassist -f docker-compose.yml -f docker-compose.prod.yml up -dThe runbook — DNS, the .env values the overlay reads, verifying the
WebSocket, deploying a new tag, and the certificate store — is
DEPLOY.md.
Planned work lives in backlog/ — one file per user
story, organized by sprint folder (sprint/current/, sprint/next/,
unscheduled/, released/), with status, dependencies, and acceptance
criteria. Finished
stories move into sprint/<name>/done/, so the sprint folder itself always lists
exactly the work that is left.
Current sprint — the self-host release (in
backlog/sprint/current/). Shipped and in the app today:
- Control plane (Postgres) — saved tests & suites (US-009), projects & modules (US-023), run history (US-011), scheduled runs (US-010), failure email notifications (US-012), and a page per run (US-030).
- Session recording — an MP4 per run (US-006).
- Registration-flow verification — email-confirmation signups (US-013).
- Subscription billing for a hosted tier — Stripe Checkout and the Customer
Portal, entirely env-gated
(US-022); see
Billing. Self-hosting stays free: with
STRIPE_*unset there is no billing UI and no gating.
Left before the release is cut:
- Public HTTPS — Caddy on 443 terminating TLS; unblocks CI/CD (US-007).
- CI/CD trigger — GitHub/GitLab run a module or a suite via a documented curl step (US-008, needs US-009); a reusable Action and a GitHub App are a later story (US-029).
- A licensed, public repo — AGPL-3.0
(US-031) and a CI
pipeline that publishes a versioned image per tag, so self-hosting is a pull
rather than a build
(US-032).
Next sprint (in
backlog/sprint/next/): a report with per-step screenshots and the session recording (US-020).
Later (backlog/unscheduled/): SMS/social
registration tiers, PR status checks, scaling to ~100 concurrent sessions
(US-015),
and a possible desktop app.
- The worker is stateless per run — durable state belongs in the control plane above.
- How the open-source repo relates to the paid hosted tier (and the future
private cloud repo):
docs/repo-model.md. - Secure it before exposing publicly: always behind HTTPS, always with the token.
- Some sites (Reddit, Cloudflare-heavy pages) block datacenter IPs and will fail from a server — expected, not a bug.
Patches welcome. CONTRIBUTING.md covers getting the stack
running, which test suite to run for what, the house style, and the one
procedural ask — a DCO Signed-off-by line, which git commit -s adds for you.
AGPL-3.0-only. In plain terms:
- Self-hosting is free, for anything, forever — personal or commercial, no seat count, no feature gate, no key to buy. Running QAssist is not what the licence asks anything about. Model tokens are bring-your-own on every tier, so the only bill is the one you already have with your LLM provider.
- Modify it and run your version freely. The obligation attaches to distribution — and, because this is the AGPL rather than the GPL, to offering your modified version to others as a network service. Do that, and those users are entitled to your source under the same licence.
- That is the whole reason for the A: it keeps a competitor from taking this code, running it as a closed hosted product, and giving nothing back — while leaving every actual self-hoster completely unrestricted.
Contributions stay under the same licence and you keep your copyright — DCO, not a CLA.
A paid hosted tier at qassist.run is planned, and it
runs this codebase — not a fork of it, and not a more capable private
sibling. Even the Stripe billing is here, env-gated: with STRIPE_* unset,
which is the self-host default, there is no billing UI and no gating at all.
Payment there covers hosting; it buys no feature you don't have here.
A private repo exists for the commercial layer around that deployment, and the
boundary is a deliberate constraint rather than a place features go to
disappear: the default is public, and only what is meaningless without our
billing or our infrastructure may live on the private side. Dependencies run
one way — that repo consumes this one's published image and its token-authed
API, and this repo never learns it exists. The rules, and the routing test
applied to every new feature, are in
docs/repo-model.md.