Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.
Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:
gen_cpp.py -> core/generated/velox_proto.{hpp,cpp}
gen_ts.py -> extension/src/shared/protocol/
gen_openrpc.py -> contracts/openrpc.json
gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp
Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.
59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.
run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.
core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
contracts/ — the wire contract
This directory is the interface between every lane. Owner: agent PROTO. Nobody else commits here. Everybody else generates from here.
Status: v1.0.0 — FROZEN (2026-09-09)
The surface below is complete and generated from: 38 methods, 9 events, 25 named types, 59 fixtures. See
docs/adr/0005-protocol-1.0.0-freeze.mdfor the versioning rule.What each lane can rely on, starting now:
You need It is here C++ types, parsing, dispatch core/generated/velox_proto.{hpp,cpp}(targetlibveloxproto)TypeScript types, typed client, runtime validators extension/src/shared/protocol/The API document to read contracts/openrpc.jsonA daemon to build against today tools/mockd— both transports, 4 Hz progress, unhappy-path flagsProof you have not drifted ./tests/conformance/run.shChanging this is a PR to
contracts/alone. Optional field or new method → minor. Rename, remove or retype → major, plus an ADR. File a request; do not add a field locally.
contracts/
├── VERSION # protocol semver — frozen at 1.0.0
├── openrpc.json # human-readable API doc (generated from schema/)
├── schema/
│ ├── envelope.schema.json # JSON-RPC 2.0 envelope + our error codes
│ ├── types/ # Task, Segment, Category, Queue, Settings, CaptureRules…
│ ├── methods/ # one file per method: params + result
│ └── events/ # one file per server→client notification
├── fixtures/ # golden request/response pairs, replayed by conformance
└── codegen/
├── schema_ir.py # the one loader/IR all generators share
├── gen_cpp.py # → core/generated/ (structs + to_json + parse + dispatch)
├── gen_ts.py # → extension/src/shared/protocol/ (types, client, validators)
├── gen_openrpc.py # → contracts/openrpc.json
└── gen_cpp_conformance.py # → tests/conformance/cpp/fixture_dispatcher.hpp
Each subdirectory has its own README: codegen/ documents the supported JSON Schema
subset, fixtures/ documents the fixture shape and the placeholder rules.
Rules
- Generated code is committed. No lane may be blocked because it can't run Python.
- Hand-editing generated files is a merge blocker. Fix the schema and regenerate.
- Every method needs at least one fixture — a success case and, where meaningful, an error case. A method with no fixture is not done.
- Versioning: adding an optional field or a new method → minor bump. Removing,
renaming, retyping, or changing a default → major bump and a written migration note
in
docs/adr/.session.hellorejects a major mismatch with error-32001and a message the GUI renders as "Velox needs updating". - Changes arrive as a PR to
contracts/alone, containing: schema edit + fixtures + regenerated code +VERSIONbump. Lanes rebase onto it. This is the only synchronization point in the whole project — keep it cheap and frequent rather than big and rare.
Per-method annotations
Every method schema carries these, and both generators emit them as data the code can act on rather than as prose a reader has to honour:
| Key | Meaning |
|---|---|
x-privileged |
refused over the WebSocket transport with -32003 |
x-transports |
which listeners serve it (uds, ws) |
x-deadlineMs |
how long a client waits before giving up |
x-errors |
the error codes this method is documented to return |
x-wsRestrictions |
extra limits when the call arrives from the extension |
19 of the 38 methods are privileged: everything that reconfigures the daemon, destroys user data, or names an arbitrary destination path. The extension may request a download; it may not choose where the bytes land.
Transport framing
| Client | Transport | Framing |
|---|---|---|
| GUI, CLI | $XDG_RUNTIME_DIR/velox/velox.sock |
newline-delimited JSON (NDJSON) |
| nmhost ← Firefox | stdio | 4-byte little-endian length prefix (Firefox's format) |
| nmhost → daemon | same Unix socket | NDJSON |
| Extension (fallback) | ws://127.0.0.1:520xx |
one JSON message per WS text frame |
All four carry the same JSON-RPC 2.0 payloads. The framing differences stop at the
transport layer; no method behaves differently depending on how it arrived — except that
methods marked "privileged": true in the schema are refused over the WebSocket transport.
Method surface (v1.0.0 target — expand only via PR)
Session
| Method | Params → Result |
|---|---|
session.hello |
{clientType, clientName, protocolVersion, token?} → {daemonVersion, protocolVersion, capabilities[], sessionId} |
session.pair |
{clientName, extensionId} → {token, expiresAt} (WS only; triggers user prompt) |
session.subscribe |
{events[]} → {ok} |
Downloads
| Method | Params → Result |
|---|---|
download.probe |
{url, headers?, cookies?, referrer?, userAgent?} → {filename, sizeBytes?, mime, resumable, effectiveUrl, suggestedCategoryId} |
download.add |
{url, headers?, cookies?, referrer?, userAgent?, filename?, saveDir?, categoryId?, segments?, bufferBytes?, startMode:"now"|"later"|"queue", queueId?, description?, checksum?} → {taskId, state} |
download.addBatch |
{items[], defaults} → {taskIds[]} |
download.list |
{filter?, sort?, offset?, limit?} → {total, items: TaskSummary[]} |
download.get |
{taskId} → TaskDetail (includes segments[]) |
download.start | .pause | .resume | .cancel |
{taskIds[]} → {updated[]} |
download.remove |
{taskIds[], deleteFile:bool} → {removed[]} |
download.update |
{taskId, patch:{filename?, saveDir?, categoryId?, queueId?, description?, segments?, bufferBytes?}} → TaskSummary |
download.refreshUrl |
{taskId, url, headers?} → {ok} (IDM's "Refresh Download Address") |
Organisation
category.list · category.upsert · category.remove · queue.list · queue.upsert ·
queue.start · queue.stop · queue.reorder · rules.list · rules.upsert ·
schedule.get · schedule.set
Settings & limits
settings.get {keys?} · settings.set {values} · limiter.get · limiter.set {globalBps?, enabled}
Browser integration
| Method | Notes |
|---|---|
capture.offer |
{url, method, headers, cookies, contentType?, contentLength?, contentDisposition?, tabUrl, filename?} → {action:"take"|"ignore", taskId?, reason?} — must answer within 750 ms; the extension gives up and lets Firefox handle it otherwise |
capture.getRules |
Extension mirrors the daemon's monitored types so the two never disagree |
media.listVariants |
{manifestUrl, headers} → {variants:[{id,resolution,bitrate,codec,sizeEstimate}]} |
media.addVariant |
{manifestUrl, variantId, ...addParams} → {taskId} |
Grabber
grabber.start {startUrl, depth, includePatterns[], excludePatterns[], fileTypes[]} →
{jobId}; grabber.status {jobId}; grabber.harvest {jobId, select[]} → {taskIds[]}
Events (server → client notifications)
| Event | Payload |
|---|---|
event.task.added / .removed |
{taskId, summary?} |
event.task.state |
{taskId, state, error?} |
event.task.progress |
Batched array, emitted at ≤4 Hz: [{taskId, downloaded, speedBps, etaSec, segments:[{i,completed,speedBps}]}] |
event.speed.global |
{downBps, activeCount} |
event.auth.required |
{taskId, host, realm, scheme} |
event.notify |
{level, title, body, taskId?} |
event.settings.changed |
{keys[]} |
event.grabber.progress |
{jobId, found, crawled, done} |
Error codes
| Code | Meaning |
|---|---|
-32600/-32601/-32602/-32603 |
Standard JSON-RPC |
-32001 |
Protocol major version mismatch |
-32002 |
Not paired / invalid token |
-32003 |
Method not permitted on this transport |
-32010 |
Task not found |
-32011 |
Invalid destination path (outside allowed roots, or not writable) |
-32012 |
Disk full |
-32013 |
Probe failed (with data.httpStatus) |
-32014 |
Rate limited (pairing brute-force lockout) |