rh is RivetHub's memory runtime. One binary runs a node's gateway (the HTTP API that clients use to read memory),
capture of harness sessions, memory (search, summaries, topics, entities and the wiki), the plug-in host, the MCP
sidecar and the memory workers. Live sessions, chat, terminals and delegation run in T3 Code, not in rh. A second
binary, rivethub-migrate, applies every schema change. Memory lives in Postgres on a shared datahub, or in one SQLite
file on a standalone machine.
Related repositories: rivethub-desktop (the native client) and rivethub-plugins (plug-ins, their SDKs and templates).
On Linux or macOS, the installer picks the archive for your machine, verifies it against the release's SHA256SUMS,
puts rh and rivethub-migrate in ~/.local/bin and starts a local node (--no-start installs only, --version
pins a release, --dir picks another directory):
curl -fsSL https://get.rivethub.io/local.sh | bashTo do the same by hand: every release v<version> publishes one archive per target and a SHA256SUMS file, and each
archive holds rh, rivethub-migrate and LICENSE. Unpack both binaries into a directory on your PATH:
version=0.8.3 # a release tag without its v
target=x86_64-unknown-linux-gnu # or aarch64-unknown-linux-gnu, universal-apple-darwin
base=https://github.com/Rivethub/rivethub-runtime/releases/download/v$version
curl -fL -O "$base/rh-$version-$target.tar.gz" -O "$base/SHA256SUMS"
sha256sum --ignore-missing -c SHA256SUMS # on macOS: shasum -a 256 --ignore-missing -c SHA256SUMS
mkdir -p ~/.local/bin
tar -xzf "rh-$version-$target.tar.gz" -C ~/.local/bin rh rivethub-migrate
rh versionThen start a node and wire your tools: rh local --ensure, then rh harness status and
rh harness connect <harness> (for example claude-code), which adds the capture hook and the recall MCP entry.
Linux builds need glibc 2.35 or newer; on Windows, run the Linux archive under WSL. After that, rh update keeps the
installation current:
rh updateinstalls the latest stable release;rh update --channel betathe newest release, prereleases included. The channel is not remembered: pass--channel betaevery time you want beta. Without--versionit never installs a release older than the runningrh, and says so when it is already up to date.--version <tag>pins exactly that release, a prerelease included, on any channel.- It verifies the archive against
SHA256SUMS, runs the newrivethub-migrateagainst the configured memory backend, swaps both binaries in place (keepingrh.previousandrivethub-migrate.previous) and restarts the node unless--no-restartis given: through the systemd unit when there is one, otherwise by stopping the noderh local --ensurestarted and starting it again with the new binary. When no node is running there is nothing to restart. --channel nightlybuilds from the cleanmaincheckout named byRIVETHUB_INSTALL_ROOTinstead.
Every merge to main publishes a beta prerelease versioned as the higher of the workspace version and the last tag plus one patch; the Promote workflow marks a beta stable.
docs/CLI.md has the release layout and every command.
rh local # serve on 127.0.0.1:5174
rh local --ensure # start one in the background unless one already answersrh local is one process on one SQLite file (~/.rivethub/memory.sqlite). With no configuration it writes a minimal
~/.rivethub/config.yaml (the database path and the gateway bind) and never invents agents, providers or endpoints. It
runs the gateway and the plug-in host, and adds, from memory.sqlite:
| Setting | Adds |
|---|---|
workers: true |
the embedder and the summarizer |
tagging: true |
the categorizer (filing and Tidy proposals) |
wiki_dir: <dir> |
the wiki builder, writing pages under that directory |
embed_endpoint, embed_model |
vector and hybrid search; without them search answers keyword results, marked degraded |
MCP is served in the same process when transports.mcp sets a host or a socket. A first run binds 127.0.0.1:5174,
or 0.0.0.0:5174 when TLS for a LAN bind is already set up (RIVETHUB_TLS_CERT and RIVETHUB_TLS_KEY). To serve the
LAN later, set gateway.host: 0.0.0.0 with gateway.tls_cert and gateway.tls_key; an off-loopback bind without TLS
stops with the file and the ways out. --no-lan forces 127.0.0.1 and --port picks another port. A SQLite node
serves one person: it stores visibility marks but does not filter reads by them.
- Prepare the database and run
rivethub-migrate postgres(see docs/OPERATIONS.md). No service migrates on start; each refuses to run behind the schema it needs. - Write
~/.rivethub/config.yaml(below) and check it:rh config validate,rh gateway --check,rh doctor. rh service initwrites and enables a systemd unit (a system unit as root, a user unit otherwise) that runsrh startfor a node config, orrh localfor the standalone SQLite configrh localwrites;rh service start|stop|restart|status|logscontrol it.rh startsupervisesrh gateway, andrh mcpwhentransports.mcpnames a host or socket, restarting either with backoff.
rh gateway serves the memory API, the stored sessions and their transcripts, the wiki, and the plug-in host. rh status reports its health and rh logs reads its journal.
RIVETHUB_WIKI_EXTRACTION=1 rh worker --role wiki-builderrh worker --role <role> claims one role's jobs from the Postgres queue and schedules that role's cron entries. Run
one process per role, usually as separate systemd units next to Postgres:
| Role | Jobs |
|---|---|
embedder |
embed-target, and the sweeps for unembedded rows and unchunked messages |
summarizer |
compact-conversation (the summary tree), synthesize-tool-call, the idle and stale sweeps, sync-topic-page, reap-dead-jobs, purge-deleted-sessions |
categorizer |
file-conversation (files a conversation into a topic), propose-tidy |
wiki-builder |
extract-wiki, the wiki backfill, consolidate-wiki, recompile-wiki, the stale-wiki sweep; refuses to start without RIVETHUB_WIKI_EXTRACTION=1 |
all |
every role in one process |
The summarizer, categorizer and wiki builder call the default agent's model. Tuning variables, unit files and memory limits are in docs/OPERATIONS.md.
rh reads RIVETHUB_CONFIG, else ~/.rivethub/config.yaml (or .yml), else the legacy ~/.rivetos/config.yaml,
else ./config.yaml; every service command also takes -c FILE. ${NAME} in a value is replaced from the environment.
State lives in ~/.rivethub/. rh config path, rh config show and rh config validate inspect the file; validation
requires runtime, agents and providers, except for a SQLite config like the one rh local writes.
runtime:
workspace: ~/work
default_agent: coder
agents:
coder: { provider: local-llm, model: example-model }
providers:
local-llm: { type: openai-compatible, base_url: http://llm.example:8000/v1, model: example-model }
memory:
backend: postgres
postgres:
connection_string: ${RIVETHUB_PG_URL}
embed_endpoint: http://embed.example:8001/v1
embed_model: example-embedding-model
gateway:
host: 127.0.0.1
port: 5174| Section | Holds |
|---|---|
providers |
model endpoints: openai-compatible or vllm (base_url, model, api_key or token_command), or a harness used headless (claude-cli, codex-cli, grok-cli, hermes-cli, opencode-cli, kimi-code) |
agents, runtime |
named agents on those providers; runtime.default_agent is the model the workers use |
memory |
backend (postgres or sqlite), connection and embedding settings, capture.redaction (off by default) |
gateway |
host, port, TLS (tls_cert, tls_key), users_file, allowed_origins, static_dir, request_deadline_secs (default 30: a read request's database work stops then), fresh_login_secs (default 300: how recent a browser login must be for account, export and token-rotation routes), drops (off by default: serve the drop API as a team share server, CONTRACT note 143), trusted_proxies (reverse proxies whose forwarding headers rh believes) |
tasks |
harnesses.<id> (binary, model, effort, cwd) for the harnesses a headless provider runs |
transports.mcp |
network MCP: host and port (default 5700, needs auth_token), or socket (an absolute path) |
mesh |
node_name only, as this node's name; the other keys warn and are ignored |
The gateway binds 127.0.0.1:5174 by default. Off loopback it refuses to start without TLS. A caller on loopback is the
node's owner; a caller from elsewhere sends a bearer token listed in the users file (see Tenancy). Browser origins other
than the gateway's own must be listed in gateway.allowed_origins.
Keys from removed features (runtime.profile, gateway.terminal, the client-certificate keys, every mesh key but
node_name) still load; rh config validate warns about each one and the node ignores it.
rh does not run harness sessions. Sessions reach memory through rh capture hook, run from the harness's hook command. It reads
one hook payload on stdin, posts it to the local node's /api/capture (a running rh local or rh gateway), spools
what the node could not take under ~/.rivethub/capture-spool/ for the next run, and always exits 0:
--harness |
Called on | Reads | Captured as |
|---|---|---|---|
claude-code (default) |
UserPromptSubmit, PostToolUse, Stop, SubagentStop, SessionEnd |
the payload's transcript_path |
the session id |
grok-build |
Stop, PreCompact, SessionEnd |
$GROK_HOME (~/.grok) |
grok-build:<session> |
codex |
Stop, SessionEnd, once the person has trusted the hooks in Codex (0.129.0 and later skip untrusted hooks, codex exec included) |
$CODEX_HOME/sessions (~/.codex/sessions) |
codex:<session> |
opencode |
the end of each turn, from the plug-in rh harness connect opencode writes: session.idle on OpenCode 1.x from 1.3.4, session.execution.* on 2.x; checked on 1.3.4, 1.19.5, 2.0.0, 2.2.5 and 2.3.0. OpenCode 2 is the supported line; 1.x capture is kept as it is and gets no further work (CONTRACT note 215) |
opencode.db under $XDG_DATA_HOME/opencode |
opencode:<session> |
dsh |
turn/end from $DSH_HOME/plugins/rivethub-capture.mjs |
$DSH_HOME/sessions (~/.dsh) |
dsh:<session> |
rh harness connect dsh writes that plug-in and a recall MCP entry into $DSH_HOME/cordis.patch.yml (default ~/.dsh/cordis.patch.yml). DeepSeek Harness applies the home patch to every profile, including profiles created later. rh harness disconnect dsh removes both.
For Claude Code, each of its events gets the same entry in settings.json:
{ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "rh capture hook" }] }] } }The per-harness kits that install these hooks live with the clients. A hook run with RIVETHUB_MODEL_RUN set captures
nothing, so the workers' own model calls never become conversations. memory.capture.redaction (or
RIVETHUB_CAPTURE_REDACTION) redacts secrets before they are stored.
rh mcp gives any MCP client this node's memory: memory_search (hybrid, fts, trigram, regex or vector),
memory_browse, memory_team, memory_get_full, memory_stats, memory_tags, wiki_search and wiki_read; then the plug-in creator's four tools and every running plug-in's tools, named <plugin>-<tool>. It
serves stdio by default and streamable HTTP at /mcp when transports.mcp names a host (bearer auth_token required) or
a socket. On a SQLite node rh mcp opens the node's SQLite file and serves stdio; the network transports there are
rh local's own.
{ "mcpServers": { "rivethub": { "command": "rh", "args": ["mcp"] } } }Team recall uses the database's actual person for a pinned login, with ownerUserId as the
fallback on an unpinned node. Shared membership comes only from the store; per-person users
files need no team list. On a shared hub, initialize instructions name that person and teammates. Other people's
sessions are labelled from <person> · team and rendered as quoted data, never instructions.
memory_team shows visible teammates' recent sessions grouped by person, with window
(default last_7d) and limit per person (default 5, maximum 20). Store row-level security
still decides what can be read; another person's private sessions never become visible.
memory.recall.own_weight sets the Postgres hybrid ranking preference for your own sessions:
1.25 by default, finite range 1.0–2.0; 1.0 disables it. The factor multiplies the fused score
before sorting and limiting when teammate candidates are present. Relevance still matters:
a strong teammate match can outrank a weak own match. Chronological browse and SQLite
ranking are unchanged.
A plug-in is a program in its own process, described by rivethub-plugin.json, that reaches rh only through a
per-start token on the node's plug-in API socket. rh gateway and rh local host them; no plug-in code is loaded into
rh.
- Install:
POST /api/pluginswith{"path": "<absolute directory>"}registers it in place, disabled. Its data, socket and logs live in~/.rivethub/plugins/<id>/. - Permissions: the manifest asks for
memory.read,memory.write,sessions.read,sessions.control,topics.read,topics.write,notifications.send, and the host permissionshost.files.read,host.files.write,host.networkandhost.exec. The node's owner approves them by enabling the plug-in (Settings › Plug-ins in the app); a reload that asks for more stops it until they are approved again. Every API call is checked against the grant when it arrives. - Confinement: on Linux each plug-in runs under Landlock, limited to its own folders, its program and the system
directories unless a
host.*permission widens that. A kernel without full Landlock cannot enable ahost.*plug-in. - What it adds: views each client draws natively (pages, topic cards, session actions, canvases), web panels,
agent tools in MCP, event subscriptions (
session.*,summary.written), cron or manual jobs, and a settings form. - Creator mode: an agent builds one with the
plugins-templates,plugins-scaffold,plugins-installandplugins-statustools: it scaffolds from a template in~/.rivethub/plugin-templates/into~/.rivethub/workshop/<id>/, installs it and reads its log. It never enables one; a person does.
GET /api/plugins lists them; …/{id}/enable, /disable, /reload, /logs, /settings and DELETE manage them
(owner only). The manifest, socket protocol and bodies are CONTRACT row B17 and notes 50 to 60, 71, 90 and 91.
Every capture is stored as a conversation of messages, owned by the caller. The workers build on it:
- Search: hybrid ranking over full text, trigram, vectors (messages, message chunks and summaries) and recency.
- Summaries: each conversation gets a tree of leaf, branch and root summaries, written as it goes idle.
- Topics: sessions are filed into work topics (nested up to four levels) by you or by the categorizer, which never
overrides your placement. Topics carry decisions, a startup context handed to new sessions in that topic, and one
wiki page per root topic. Entities (people, hosts, models, services, repos and the like) are topics of kind
entityundertypetopics, kept apart from the work tree. - Wiki: the wiki builder extracts each summary into topic and entity pages. On Postgres the pages are a git
repository at
WIKI_DIR(default/rivet-shared/wiki, orwiki/underRIVETHUB_SHARED_DIR). A page is rewritten, not appended to, within fixed budgets (article 16 KiB, sections 4 KiB each, 48 History lines); what a rewrite drops is archived underarchive/<slug>/. - Tidy: changes to topics, entities and types that need a person (merge, move, rename, close, delete, new topic,
new type) wait as proposals at
/api/tidy/proposalsuntil approved or rejected. - Flagged jobs: a job that keeps failing (5 times by default) leaves the queue as a flagged record so the jobs
behind it run;
/api/memory/flaggedlists them, and each can be retried or dismissed. - Backfill: with
RIVETHUB_WIKI_BACKFILL=1the wiki builder works through older summaries below all new work.
rh memory administers a store: queue-status, retry-failed, requeue, export and import, backfill-titles,
backfill-tool-synth, reembed (SQLite), consolidate-wiki, recompile-wiki, cleanup (a model-planned topic
cleanup that can be written as one Tidy batch), and merge-store. rh memory --help lists the flags.
gateway.users_file (or RIVETHUB_USERS_FILE) names the node's users:
{
"ownerUserId": "alice",
"unmappedIsOwner": false,
"users": { "alice": { "tokens": ["<sha256 hex>"] }, "bob": { "tokens": ["<sha256 hex>"] } },
"spaces": { "home": { "kind": "household", "name": "Home", "members": ["alice", "bob"] } }
}tokenslists the hex SHA-256 digests of the bearer tokens that identify that user; the file never holds a token itself. A caller sendsAuthorization: Bearer <token>, and an unknown token is refused, even on loopback.- Every captured row records its owner: a bearer token's user, or the node's owner for a loopback caller, a plug-in or
rh capture hook. Without a users file the owner isowner."admin": truelets a user pass the node-owner checks too. - Everything is private to its owner until shared with a space (
household,teamorcompartment; withoutspacesthe node has one household of every listed user). A session's visibility is its own mark, else its topic's, else its owner's default for sessions started after choosing it, else private. Only an item's owner marks it. - Postgres enforces this with forced row-level security on every owned table.
rh mcpand plug-ins act as the node's owner; workers and operator commands run as maintenance, except that filing runs as the conversation's owner. - Wiki pages live in scopes: the owner's pages at the root of
WIKI_DIR, another person's inusers/<user>/, a space's inspaces/<space>/, each its own git repository. A private session's notes never reach a shared page. rh memory merge-store --from <url> --owner <user>folds a second person's memory database into this one, private to them.
- docs/OPERATIONS.md: running Postgres, workers and the wiki for a datahub.
- docs/CLI.md: every command, releases and updates.
- docs/CONTRACT.md: the boundaries (HTTP API, events, schema, CLI, config, plug-ins) and every
numbered note that changed them; the payload schemas are in
contract/. - docs/ARCHITECTURE.md: crates, ports, process shape.
- docs/BUILDER-RULES.md and docs/PATTERNS.md: the rules every change is held to.
cargo xtask install-hooks # installs the xtask binary into target/git-hooks (pre-commit, commit-msg)
cargo xtask check # what CI runs first: fmt, clippy, dylint, cargo-deny, languages, privacy, secrets
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace
cargo build --release -p rivethub --bin rh -p cli --bin rivethub-migrateThe repository is Rust only. The git hooks are the xtask binary itself: cargo xtask install-hooks copies it to
target/git-hooks/pre-commit and target/git-hooks/commit-msg and points core.hooksPath there; the binary dispatches on
the name it is invoked as (pre-commit: staged privacy + fmt; commit-msg: house identity, no attribution trailers).
Every crate depends only downward (foundation ← kernel ← adapters/services ← composition); deny.toml fails the build the
moment a Cargo.toml breaks that (each internal crate is a ban whose wrappers are exactly the crates allowed to use it).
Production code has no comments, no allow, no unwrap/expect, no unsafe; clippy.toml disallows std locks, untyped
JSON, runtime construction and environment reads outside their one home, and lints/ (a Dylint library outside the
workspace, pinned to the nightly its dylint_linting version requires) carries the rest of docs/PATTERNS.md.
deny.toml keeps TLS on rustls and bans anyhow, OpenSSL and a second async runtime.
Licensed under Apache-2.0.