Rivet is a lightweight, self-hosted control layer for AI compute. It gives you one assistant interface and decides whether each request should run on a local model, a remote machine, or a configured cloud provider.
Rivet supports a one-line install on Ubuntu 24.04 LTS:
curl -fsSL https://raw.githubusercontent.com/superC12/Rivet/main/install.sh | sudo bashTo install from a cloned checkout instead:
sudo ./install.sh --localThe installer:
- creates a locked-down
rivetsystem user; - installs Rivet beneath
/opt/rivetin a dedicated virtual environment; - keeps configuration in
/etc/rivet; - keeps conversations in
/var/lib/rivet; - installs and starts
rivet.servicethrough systemd; - binds to
127.0.0.1:8080by default; - verifies
/healthand rolls back a failed update.
Python 3.12 or newer is required. Ubuntu 24.04 LTS includes a compatible Python version. For older Ubuntu releases, use Docker or provide Python 3.12 separately.
rivet status
rivet doctor
rivet logs
rivet restart
rivet updateNormal removal preserves your assistant configuration and conversations:
sudo rivet uninstallPermanent removal requires an explicit purge:
sudo rivet uninstall --purgeEdit /etc/rivet/rivet.env for the bind address, port, GitHub repository, and API keys. Edit /etc/rivet/assistant.yaml and /etc/rivet/rivet.yaml for identity and infrastructure. Restart Rivet after manual changes.
The platform name, assistant identity, infrastructure, and secrets are deliberately separate:
config/assistant.yamlholds the portable assistant name, behavior, and appearance.config/rivet.yamlholds routing, providers, nodes, and actions.
Both are generated on first start from the config/*.yaml.example templates and are not tracked in git. Rivet rewrites them whenever settings are saved, so editing the tracked templates by accident — by finishing onboarding on a development machine, say — would change the defaults every new installation receives. Edit the live files to configure your instance; edit the templates only to change what a fresh install starts with.
.envholds secrets and is never returned by the API.data/rivet.dbholds conversations and execution metadata.
Python 3.12 or newer is recommended.
cd rivet
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -r requirements.txt
uvicorn backend.main:app --host 127.0.0.1 --port 8080Open http://127.0.0.1:8080. During onboarding Rivet checks the configured
Ollama endpoint first, then administrator-provided candidates, Docker's
host.docker.internal, and the common ollama Compose service name. It does
not scan your LAN. Add custom candidates with the comma-separated
RIVET_OLLAMA_ENDPOINTS environment variable.
For OpenRouter, copy .env.example to .env, set OPENROUTER_API_KEY, and load the file into your process environment before starting Rivet. Secrets are intentionally not editable through the browser.
Routing is two separate decisions, kept apart on purpose.
Classification answers what kind of request is this and produces a lane:
| Lane | Meaning |
|---|---|
ACTION |
An instruction that should change something in the world |
LOCAL |
A small model can answer this well |
ESCALATE |
This needs a stronger model |
Selection answers which machine runs it and produces a route — LOCAL, REMOTE, CLOUD, or ACTION — plus the provider, model, and node. Selection is pure and synchronous, so it can be tested against a fixed model list with no classifier and no network involved.
request
↓
classifier ──► lane (backend/routing/classifier.py)
↓
policy ──► allowed tiers (backend/routing/policies.py)
↓
router ──► provider+model (backend/routing/builtin.py)
By default, Auto route uses those built-in rules and makes no extra model call. Optionally, choose Configure Auto Route from the composer route menu—or click the Router node in Compute—to assign any activated model as a compact selection advisor. It chooses only among models already permitted by privacy, route mode, activation state, and drag-and-drop priority, and it can request thinking only when the chosen target reports that capability. Invalid output or an unavailable advisor falls back to the built-in router immediately. Actions and manual model selections never pass through the advisor.
This role is separate from the classifier: the classifier decides the request lane; the optional routing model advises which eligible model should receive it. No routing model is predefined or required.
Three rules are load-bearing:
ACTION is never model-decided. A request reaching the ACTION lane can cause a real side effect, so it is gated on deterministic patterns only, and a question about an action ("how do I create a task?") is never an action. A misfiring classifier should cost a wasted token, not an email nobody meant to send.
Unreadable classification fails upward. If the classifier is unreachable or returns something unparseable, the lane defaults to ESCALATE rather than quietly landing on the cheap option — an unclassified request answered by a 3B model is a confidently wrong answer, while the same request answered by a stronger model is a slightly larger bill. Set router.classifier.fallback_lane: LOCAL to invert that trade.
privacy_mode: local_only is a guarantee, not a preference. Nothing routes around it — not a manual model override, not session affinity, not failing upward, not an external routing engine. It blocks cloud providers, not remote nodes: a machine you own, reached over Tailscale, keeps content inside your own estate. The per-request Local only mode is the stricter one that pins execution to the local machine.
Both constraints hold on the failure path too. When the chosen model dies mid-answer, the fallback is validated against the same allowed tiers as the original choice, so a Local only request cannot quietly reach the cloud while recovering. If no permitted fallback exists, Rivet says the model stopped responding rather than answering from somewhere you excluded.
heuristic (the default) is deterministic pattern matching. It needs nothing installed and costs nothing.
dispatch asks a model you select instead. The dispatcher logic is built in and needs no separate routing service, but Rivet deliberately ships no model weights, predefined model, or model recipe. Choose and configure a classifier model on your own provider, then measure it before trusting it:
router:
classifier:
mode: dispatch
endpoint: http://127.0.0.1:11434
model: your-classifier-modelRivet sends the classifier a bounded prompt at zero temperature and limits its output. The model itself remains entirely the administrator's choice.
The classifier usually runs on the always-on server rather than wherever the config file was last edited, so every value can be overridden by the environment. RIVET_-prefixed names win; the unprefixed spellings are accepted too.
| Variable | Overrides | Default |
|---|---|---|
RIVET_CLASSIFIER_MODE / CLASSIFIER_MODE |
mode |
heuristic |
RIVET_DISPATCH_ENDPOINT / OLLAMA_URL |
endpoint |
http://127.0.0.1:11434 |
RIVET_DISPATCH_MODEL / DISPATCH_MODEL |
model |
empty (required for dispatch) |
RIVET_DISPATCH_TIMEOUT_S / DISPATCH_TIMEOUT_S |
timeout_s |
5.0 |
RIVET_FALLBACK_LANE / FALLBACK_LANE |
fallback_lane |
ESCALATE |
GET /api/classifier reports the configuration and probes the dispatcher for real. It separates the two failures that look identical from inside a request:
{ "status": "model_missing", "model": "your-classifier-model",
"model_installed": false,
"error": "The configured classifier model 'your-classifier-model' is not installed." }That distinction matters because a broken dispatcher never fails loudly. Every request simply fails upward to ESCALATE and gets answered by a larger model, so the first symptom is the bill. /api/status carries the same block, and marks the router degraded when the classifier is not healthy.
POST /api/classify returns the lane without running anything — it picks no provider, wakes no node, runs no action, and stores nothing:
curl -s localhost:8080/api/classify -H 'content-type: application/json' \
-d '{"text":"why is my docker container exiting with 137"}'{"lane": "ESCALATE", "confident": true, "source": "heuristic",
"reason": "Infrastructure specifics are easy to invent",
"latency_ms": 0, "allowed_tiers": ["REMOTE", "CLOUD", "LOCAL"]}allowed_tiers is what the lane may actually use under the current policy and the requested mode, which the lane alone does not tell you. An ACTION lane returns none, because actions go to the gateway rather than to a model.
python eval/run_eval.py # the built-in heuristic
python eval/run_eval.py --mode dispatch # a small local modelThis is the gate. An endpoint that returns a label is not evidence the label is right, and LOCAL vs ESCALATE asks a model to assess its own competence — the thing small models are worst at.
Read the numbers in this order:
- ACTION precision — of the requests that would have gone to n8n, how many were really instructions? First, because it is the only metric whose failure has a side effect in the world.
- ACTION recall — of the real instructions, how many did we catch? A miss here is merely annoying.
- ESCALATE recall — a miss is a confidently wrong answer delivered to you.
- LOCAL precision — the cost side, the OpenRouter bill.
The heuristic scores 100% on the bundled cases, but its patterns were tuned against them, so that number means the suite passes, not 100% accurate on your traffic. The real job of eval/cases.jsonl is regression detection: it tells you what a prompt or pattern change broke. Add cases from your own use — especially ones Rivet got wrong — and the score starts to mean something. --mode dispatch is the honest comparison, since that model has never seen these cases.
Every metric is gated, so the command exits non-zero on any regression rather than only on catastrophic ones. The defaults are floors, not targets; tighten them for your deployment:
python eval/run_eval.py --min-escalate-recall 0.95 --min-accuracy 0.95--min-action-precision defaults to 1.00 and has no slack, because a false ACTION has a side effect in the world.
eval/run_eval.py measures Rivet's own router. Settings → Benchmarks measures the models underneath it, and does it from the dashboard: saved suites you can edit, run, and compare against previous runs.
Two ship as starting points, both ordinary editable rows from the moment they appear:
- Speed & Footprint — how fast it answers, and how much of the machine it takes. Cold-start generation speed, prompt throughput, load time, resident size, and how much of the model sits on the GPU.
- Judgment & Limits — whether it follows your protocol, and admits what it cannot do. Arithmetic, instruction following, tool-call JSON, escalation, and hallucination resistance.
+ New benchmark creates your own, of either kind, with your own prompts and grading rules. Custom benchmarks are stored exactly like the starters, and nothing here is privileged: the two that ship can be renamed, rewritten, or deleted like any other.
Deleting every benchmark hides the Benchmarks tab — an empty measurement panel is just clutter. Settings → Advanced → Benchmarks switches it back on, and restores the two starters if nothing is saved.
Hiding is not deleting. Turning the toggle off puts the panel away and leaves every saved benchmark untouched, so switching it back on returns exactly what you had. Restoring the starters only ever runs when nothing is saved, so it cannot bury your own benchmarks under two you already deleted.
Models are never typed in. The dropdowns are filled from whatever Ollama actually reports on the selected connection, so a suite cannot name a model nobody has, and pulling a new model is enough to make it selectable. A model that was chosen earlier and is no longer detected stays visible, marked, rather than disappearing quietly.
Suites and their run history live in data/rivet.db. Runs stream while they happen, and a run that fails part-way still records what it measured before failing.
| Grader | Passes when |
|---|---|
exact_number |
The expected number appears, ignoring , separators and trailing punctuation |
contains |
The expected text appears, case-insensitively |
exact_lowercase |
The answer starts with the expected word |
exact_match |
The whole answer equals the expected token |
valid_json_tool |
A parseable JSON object naming the expected tool appears |
manual |
Never — it is marked review for a human |
manual is a real answer, not a gap. "Did it admit it doesn't know?" has no honest automatic verdict, and a suite that guessed would quietly turn judgement calls into a score you shouldn't trust. Review results are counted separately and never fold into the pass rate.
These suites replace a pair of shell scripts, and three things from those scripts were left out on purpose.
No nvidia-smi. Rivet routinely routes to compute that is not the machine Rivet runs on. Sampling the local GPU would report the wrong card's memory, and would do it silently. GET /api/ps reports size and size_vram for the model on the machine that actually ran it, so the numbers follow the work — and it needs no GPU tooling installed next to Rivet.
No sudo journalctl -u ollama. The offload line it greps for is derivable: size_vram / size is the fraction resident on the GPU, which is the On GPU column. Reading it from the API costs no privileges, whereas shelling out to sudo from an HTTP request would hand a web endpoint root on a dashboard built around never executing arbitrary commands. The genuine loss is per-layer detail; the ratio answers what that detail was being used for.
No automatic ollama create for context rebuilds. Rebuilding every model at a new num_ctx mutates your model library as a side effect of clicking Run. Build the variant yourself and it appears in the dropdown like any other model:
ollama create mymodel-ctx8192 -f Modelfile.ctx8192Add a provider and node to config/rivet.yaml. A Tailscale hostname or IP works as the provider endpoint:
providers:
desktop-ollama:
type: ollama
node: gaming-pc
endpoint: http://gaming-pc:11434
nodes:
gaming-pc:
type: tailscale
display_name: Gaming PC
hostname: gaming-pc
always_on: false
wake_on_lan:
enabled: true
mac: "AA:BB:CC:DD:EE:FF"
broadcast: "192.168.1.255"Node type drives routing, not node name: type: local is the LOCAL tier, anything else is REMOTE. Renaming a node never silently reclassifies your routes.
Wake-on-LAN only accepts targets explicitly present in this file. Rivet does not expose arbitrary shell execution.
Off by default. Enable it in Settings → Connections, or in config/rivet.yaml:
actions:
n8n:
enabled: true
endpoint: "https://n8n.example/webhook/rivet"
timeout_s: 30Set N8N_ACTION_KEY in .env to have Rivet sign requests with an X-Rivet-Key header. The webhook URL carries its own authorisation, so it is treated as a credential: stored server-side, never returned through the API.
Rivet reports an action as done only when n8n confirms it. To confirm, the workflow must end in a Respond to Webhook node returning a body Rivet recognises:
{ "status": "success", "message": "Task created." }{"success": true}, {"result": "ok"} and similar all work, and a workflow that reports failure is reported as a failure.
A webhook set to respond immediately returns 200 before the workflow runs. That is a receipt, not a confirmation, so Rivet reports it as unconfirmed — the user is told the request was delivered but could not be verified. This is deliberate: a 200 that means nothing is the most common way an automation chain lies to the person using it.
| Status | What the user is told |
|---|---|
executed |
The gateway's own message, e.g. "Task created." |
failed |
"I couldn't complete that action." |
unconfirmed |
"I sent that to your action gateway, but it didn't confirm whether it ran." |
unreachable |
"I couldn't reach your action gateway, so nothing was run." |
rejected |
"Your action gateway refused the request, so nothing was run." |
not_configured |
Points at Settings → Connections. |
docker compose up --buildThe Compose file binds Rivet to 127.0.0.1:8080, persists config and data,
and contains only the Rivet service. Rivet automatically tries
http://host.docker.internal:11434 when its configured local endpoint is not
reachable. Ollama must listen beyond host loopback for a container to reach it;
set OLLAMA_HOST=0.0.0.0:11434 on the Docker host and keep port 11434 protected
by the host firewall.
CI runs on pushes and pull requests. Pushing a version tag such as v2.1.0 runs the full test suite, creates rivet.tar.gz with a SHA-256 checksum, and publishes both files as a GitHub Release. The one-line installer prefers the checksum-verified release archive and falls back to the main branch before the first release exists.
Rivet binds to localhost by default. For access from other devices, prefer Tailscale. If you use a reverse proxy, add authentication and TLS; do not publish an unauthenticated Rivet instance directly to the internet.
Core endpoints include /health, /api/status, /api/models, /api/providers, /api/nodes, /api/routes, /api/conversations, /api/chat, /api/settings, /api/onboarding, and POST /api/nodes/{node}/wake. An unknown /api/... path returns a JSON 404 rather than the frontend.
Chat responses use Server-Sent Events over a streaming POST request. Every SSE payload is JSON-encoded, including plain token strings — a raw string containing a newline would otherwise split into a line with no data: prefix, which the browser drops, silently eating blank lines and code blocks.
Reachability probes and model discovery are cached briefly, so an asleep node costs one connection timeout rather than one per request.
pip install -r requirements-dev.txt
pytestRivet logs route metadata and failures, but not full prompts or secrets. The UI escapes model output before applying its small safe Markdown subset; arbitrary HTML is never rendered.