Configuration
Rift reads rift.toml from the workspace root. The generated
rift.schema.json
defines the file and rejects any key it doesn't know, so a typo fails at load rather than being
silently ignored. Edits take effect for later requests once validation passes; while the file is
invalid, requests fail with configuration_invalid until you fix it.
An editor that supports TOML schema directives can load the generated schema:
#:schema https://volar.sh/rift/rift.schema.jsonTreat rift.toml as executable workspace configuration: a selected hook runs with the server's
OS permissions after an applied change, and an LSP process starts with those permissions on its
first request. Inspect the file before running Rift in a cloned repository.
Each section states its defaults below its example. Durations take ms, s, m, h, or d;
byte sizes take b, kb, mb, gb, or tb - binary units, so 1kb is 1024 bytes.
Server
Since v0.0.8server bounds the server's own blocking work - the filesystem scans and parses behind reads,
changes, and index rebuilds:
[server]
num_workers = 4
worker_queue_timeout = "30s"
idle_timeout = "30m"
readiness_timeout = "30s"num_workerscaps the workers running blocking operations at once, 1 to 64worker_queue_timeoutbounds one request's wait for a free worker, 1ms to 1h - when it expires, the request fails withtemporarily_unavailableand the client can retry without stacking another waiteridle_timeoutstops the server once no request remains active and that span has passed after the last response completed, 1s to 1d; the nextrift mcporrift server startbrings it backreadiness_timeoutbounds one request's whole wait for the workspace's index and its LSP processes to prove they're ready to answer, 1s to 1h - index validation starts the clock, and once the request has resolved which LSP processes it needs, waiting on them spends whatever time is leftportpins the exact loopback port, 1024 or above; omitted, the server picks the first free port fromport_range-port_range = { min = 12000, max = 13000 }is the default - and the two keys exclude each other
The worker pool exists before the first request, so the server sizes it once at startup: a change to this table applies on the next start, while every other table applies to later requests as soon as the file is accepted.
Logs
Since v0.0.22logs bounds what the server records about itself. Diagnostics admitted by capture enter a
bounded queue and land in the workspace database, where the rift://logs resource reads them
back for an agent and rift server logs reads the same records for an operator - so a refusal an
agent meets can be explained without an operator reading the server's terminal:
[logs]
retention_records = 50000
page_records = 500
capture = "rift=info,rift_mcp=info,rift_server=info"retention_recordscaps the records the store keeps, 100 to 1000000; a write past the cap drops the oldest records firstpage_recordscaps what onerift://logsread returns, 1 to 5000captureselects which targets are recorded, in theRUST_LOGspellingtracingtakes -rift_mcp=debugrecords the index lane's own rebuild decisions. A target this filter excludes never reaches the store, whatever the terminal shows
The server reads this table at startup, so a change applies on the next start. The records
outlive the process that wrote them: they live beside the search index in .rift/db, and a
workspace whose index will not settle still answers rift://logs.
Source
Since v0.0.6source controls which files below the workspace root the index and reads consider visible.
include and exclude take exact project-relative paths or globs, matched the same way
search's paths selector matches them:
[source]
include = []
exclude = []
respect_gitignore = trueEmpty include includes every file exclude and .gitignore leave standing; a match in exclude
drops a file even when include also matches it. respect_gitignore, on by default, applies the
workspace's own .gitignore files, root and nested; the server never reads global or
parent-directory ignore sources.
.git, .rift, and target stay invisible whatever this table says: no include, exclude,
or .gitignore entry can make the server serve them.
source shapes what the index walks: a file it excludes stops reaching search and
get_symbol results. A single search request can still
reach one such file on demand, through the request-side force_include override on search's
paths selector.
Languages and LSP
Since v0.0.25An exact language entry connects visible paths to shipped syntax, execution permission, and an
optional LSP process. Its key is a language name, or name:dialect for an exact dialect:
[languages.rust]
enabled = true
include = ["**/*.rs"]
exclude = ["vendor/**"]
execution = false
lsp = "rust"
[lsp.rust]
command = ["rustup", "run", "1.98", "rust-analyzer"]
environment = {}
startup_timeout = "30s"
request_timeout = "1m"
output_limit = "4kb"
[lsp.rust.retry]
attempts = 8
delay = "250ms"
delay_limit = "2s"
[lsp.rust.restart]
attempts = 3
window = "5m"Every shipped syntax provider contributes its file patterns. An absent include keeps those
patterns; a present list replaces them, and an empty list matches no file. exclude removes
matches from the effective list. enabled defaults to true, exclude defaults empty,
execution defaults to false, and lsp is absent by default. A bare language and each of its
dialect entries are independent. enabled = false turns off syntax, LSP service, and execution
for paths the entry matches.
A language no shipped syntax provider knows must carry a nonempty include list. It can still
select an LSP process and execution permission. One visible path may match one exact language; a
current path two entries claim refuses the workspace candidate, naming the path and both
identities, and the previously published revision keeps serving.
A visible file no language claims remains available to generic reads, and joins lexical search as
plain text when [search.text].include selects it. So does a file whose entry is disabled, or
whose language ships no grammar: what those entries drop is syntax analysis, not the file.
The lsp key is what turns the engine-served tools on:
rename_symbol and
move_file refuse unsupported for a language whose entry
selects no process. Reads and the direct change tools answer either way.
lsp accepts an inline process table or the name of one shared top-level process. The inline
table collapses to one key when the program takes no arguments:
[languages.rust]
lsp.command = "rust-analyzer"A named process may serve several exact identities, including dialects:
[languages.typescript]
lsp = "typescript"
[languages."typescript:tsx"]
lsp = "typescript"
[lsp.typescript]
command = ["node_modules/.bin/typescript-language-server", "--stdio"]
[lsp.typescript.initialization_options.tsserver]
useSyntaxServer = "never"An entry may select the embedded engine instead of a spawned command. The build links the engine in, so nothing is installed or started beside the server:
[languages.python.lsp]
embedded = "ty"embedded names an engine this build carries; ty serves Python. Exactly one of command
and embedded is present, and an embedded engine takes no environment and no
initialization_options: both configure a spawned process, so a table naming them beside
embedded is refused rather than silently ignored.
command accepts a program string without whitespace or a nonempty list holding the program and
its literal arguments. The list holds one program and at most 64 arguments; each argument holds
at most 4096 bytes. Rift starts the command directly without a shell. Absolute programs are
refused. A bare name resolves through the server's PATH, with environment applied on top.
initialization_options is absent by default and must be a JSON object when present;
environment defaults empty and holds at most 64 entries.
startup_timeout defaults to "30s" and takes "1s" to "10m". request_timeout defaults
to "1m" and takes "1s" to "10m". output_limit defaults to "4kb" and takes "1kb" to
"8mb"; Rift still reports the full size, so you can tell a truncated log from a short one.
retry bounds repeated requests while an answer stays unsettled. attempts includes the first
request; waits start at delay, double, and stop at delay_limit. restart bounds replacement
processes after a process ends or stops answering. A replacement older than window stops
counting. The server starts a process on its first request and keeps it across later requests.
Configuration reload reuses unchanged process definitions and ends replaced processes.
retry.attempts defaults to 8 and takes 1 to 64. retry.delay defaults to "250ms" and
takes "1ms" to "1m"; retry.delay_limit defaults to "2s" and takes "1ms" to "10m".
restart.attempts defaults to 3 and takes 0 to 16; restart.window defaults to "5m" and
takes "1s" to "1d".
A named process that no language selects is accepted but never started. A language that names a
missing process is refused during configuration validation. The top-level table holds at most 16
processes. The workspace holds at most 64 language entries. Each language include and exclude
list holds at most 64 patterns.
A language server may decline a request it's already serving, report that the file moved under it, or answer before it has loaded the declaration. Every such refusal gets the configured retry schedule. If every attempt refuses, Rift returns the language server's latest words.
A capability the server never advertised is not a refusal and returns at once.
A server still loading your project answers anyway, and answers thinly: rust-analyzer reports no
diagnostics at all for a file it hasn't analyzed, and refuses a rename with No references found at position for a declaration it hasn't indexed. Neither is its verdict.
LSP's $/progress traffic tells Rift when work remains outstanding, so an answer that arrives
during that work is provisional. Progress doesn't bind completed work to one semantic request, so
an empty answer or a refusal stays bounded by the retry schedule after progress ends.
Before the first announcement arrives there's nothing to read, and one shape of thin answer says
exactly what a settled server says: a move_file answered with no edit, and a diagnostics pull
answered with no finding, are also what a loaded server answers for a move with no import to
rewrite and for a file that's clean. Every empty move or reference answer is therefore asked
through the retry schedule while one document remains open. A classified workspace change
invalidates readiness from earlier work.
A move that spends the schedule with no proposal applies with a
rift.move.references_not_updated warning, while an unconfirmed empty reference check refuses
unless forced. A nonempty full diagnostic report can settle after progress ends. An empty full
report needs equal evidence at the final attempt; a report still changing at that bound becomes
rift.engine.unready, not an empty finding list.
A workspace/diagnostic/refresh request from the language server invalidates an earlier report
before that comparison.
The waits grow from delay and stop growing at delay_limit, which keeps the resends off a
process that is busy indexing. At the defaults a request waits at most 9.75s across its eight
asks, which covers a rust-analyzer cold start on a small project.
What the caller sees when the attempts run out depends on what was being asked. A rename asks
before anything is written, so the call fails temporarily_unavailable with the tree untouched
when analysis remains outstanding. Move and reference checks carry their stated warning or
refusal when final emptiness remains unconfirmed.
A diagnostics pull runs after the change has already applied, so it can't fail the call: the
summary carries rift.engine.unready when the process never settled, or rift.engine.failed
when it failed outright, rather than an empty finding list that would tell the caller the changed
file is clean.
restart bounds what Rift does when the process itself fails. One that ended, that couldn't be
started, or that stopped answering is replaced automatically, and the replacement serves the
request. Each replacement spends one of restart.attempts, and a replacement older than
restart.window stops counting, so a process that dies once a week never runs out.
One that crash-loops does: once the budget is spent, requests for its languages fail
temporarily_unavailable until the window passes, instead of starting a process that dies again.
The first process a workspace starts is the start, not a replacement, and a refused command
program, empty or an absolute path, is reported at once rather than started again.
A symbol address spells its language as an identity segment, and a declaration in a .tsx file
is addressed under the dialect: rift://symbol/typescript:tsx/view.tsx/Banner. The server looks
the entry up by that exact identity, so the bare language and the dialect are two independent
entries, each selecting its own process, and both may select the same named one. Without the
dialect entry the same rename refuses unsupported.
A minimal entry names its process inline. This one names tombi, a TOML formatter, linter, and language server:
[languages.toml.lsp]
command = ["tombi", "lsp"]lsp is the subcommand that starts tombi's language server over stdio.
A language server that ships as a package runs from workspace-installed bytes. Pin both packages
in package.json, install from its lockfile, and point command at the local executable, the way
[lsp.typescript] above does. This avoids paying package runner resolution at startup.
typescript-language-server refuses its initialize request unless a typescript package resolves
from the workspace root, answering Could not find a valid TypeScript installation. Install that
package in the workspace, or name an existing one with the tsserver.path initialization option;
without either, every rename and move for the language fails on the language server's own words.
The index still walks what source leaves visible, so the installed package directory needs an
exclude:
[source]
exclude = ["node_modules/**"]Without it the walk reaches typescript's own sources and the first change fails
limit_exceeded on node_modules/typescript/lib/_tsc.js, which is past the index's per-file
byte bound. Any workspace with an installed package directory needs the same entry.
tsserver.useSyntaxServer = "never" keeps the process to one semantic server. Under the default
auto it also runs a syntax-only server, and that one can answer the first rename after startup
from the open file alone: the observed cold answer renamed the declaration and left both
importers standing. With one semantic server the rename waits for the loaded project and reaches
every file.
Execution
Since v0.0.25languages.<identity>.execution records whether the planned
execute tool may run a block of agent-written code in the
targeted tree's execution copy. Execution stays off until its exact language entry enables it:
[execution]
max_code = "16kb"
max_timeout = "30s"
max_output = "8kb"
max_concurrent = 2
[languages.python]
include = ["**/*.py"]
execution = trueThe execution keys are shown at their defaults. languages.<identity>.execution defaults to
false.
max_codebounds one submitted block, 1b to 32kbmax_timeoutbounds one evaluation, 1ms to 1d - the evaluation only: refreshing the execution copy is extra, and a refresh that outruns the same bound fails the callmax_outputbounds each captured stream separately, up to 16kb, so a program that floods stdout gets a prefix and a byte count rather than a hung requestmax_concurrentcaps evaluations running at once across the whole workspace, 1 to 64
The schema states each range as rift:range on the key, and the server enforces it at load -
schema validation alone can't compare "16kb" against a ceiling.
The language entry also owns file selection, so the execution permission applies to the same exact identity and paths that syntax and LSP service use.
Providers
Since v0.0.7 · Rust, JavaScript, TypeScript, Markdown, JSON, YAML, TOML, PythonThe built-in providers run without configuration; the providers table exists to bound or
disable them:
[providers.history]
enabled = true
max_revisions = 500enabled = false turns the history provider off: the server refuses a revision-addressed read -
rev on get_symbol, search, or nodes - with
capability_unavailable. A workspace without version control cannot serve those reads whatever
this table says.
max_revisions bounds the timeline walk from the served revision, 1 to 100000, because the
walk's cost scales with how far back it reaches. The history provider computes a
SymbolHistory timeline inside that depth; a
revision beyond it contributes no version.
[providers.binding] bounds the name binding provider, which resolves references from syntax
facts inside the server, with no language engine involved. The keys are shown at their
defaults:
[providers.binding]
enabled = true
max_unit_scopes = 4096
max_unit_definitions = 16384
max_unit_references = 65536
max_unit_links = 4096
max_graph_nodes = 2000000
max_graph_links = 500000
max_reference_work = 4096
max_path_depth = 64
max_reference_targets = 64
max_publication_work = 50000000enabled = false turns the provider off: reads answer from syntax facts alone.
The provider assembles every file's facts into one graph and resolves each reference on a bounded work queue; each bound caps one phase of that work:
max_unit_scopesbounds the scopes one file may contribute, 1 to 40960max_unit_definitionsbounds the definitions one file may contribute, 1 to 163840max_unit_referencesbounds the references one file may contribute, 1 to 655360max_unit_linksbounds the imports, member links, and module declarations one file may contribute together, 1 to 40960max_graph_nodesbounds the scopes, definitions, and references one build's graph holds, 1 to 20000000max_graph_linksbounds that graph's links, 1 to 5000000max_reference_workbounds the work items one reference may enqueue while resolving, 1 to 409600max_path_depthbounds the steps one work item may accumulate, 1 to 640max_reference_targetsbounds the definitions one reference may resolve to, 1 to 256max_publication_workbounds the work items one build may enqueue across every reference, 1 to 5000000000
The index's publication acceptance follows max_graph_nodes too: raising it also raises the
bound on what one build can publish beside syntax.
An exhausted bound never fails a build: the revision publishes with the syntax facts alone, and the breach lands in the server log naming the bound.
Provider configuration does not set merge order or freshness. A provider rebuilding after a file
change can serve its previous immutable revision; the result then carries a stale_index entry
in warnings naming the two tree revision digests.
Search
Since v0.0.9search runs on a lexical index. [search.text] selects the
plain-text files that join it through include and bounds their chunks. pool_slots bounds
pooled connections shared by search and stored logs; writes wait for one process-wide turn.
A visible file joins the lexical index through its effective language entry, or through
[search.text].include when no language claims it. Narrow that list where the workspace holds
large unclaimed files search should not carry:
[search.text]
include = ["**/*.md", "**/*.mdx", "**/*.txt"]
max_chunk = "1mb"
[search]
pool_slots = 4
busy_timeout = "5s"includeselects text files from the visible paths no language entry claimed, at most 64 patterns. It defaults to["**"], so every such path joins the lexical text index; an explicit empty list selects no text filemax_chunkbounds how large a selected text file may grow before it's split into several indexed chunks, 1kb to 16mbpool_slotsbounds SQLite connections the workspace database may hold open at once, 1 to 16busy_timeoutbounds how long a connection waits for a database lock held by another process before failing withSQLITE_BUSY, 100ms to 30s
The two rankings are combined by reciprocal-rank fusion: a result scores as the weighted sum
of 1 / (k + rank) over the rankings that returned it. [search.lexical] and
[search.semantic] carry the weights, fusion_k carries the k, and [search.semantic]
names the model the second ranking runs on:
[search]
fusion_k = 60
[search.lexical]
weight = 0.7
[search.semantic]
weight = 0.3
source = "hf"
model = "minishlab/potion-retrieval-32M"
download_timeout = "5m"
download_attempts = 3
batch_declarations = 32
max_tokens = 256
candidates = 200
candidates_per_file = 3
max_vectors = 200000weightis each ranking's share of a fused score, 0.0 to 1.0; the two must sum to 1, so each reads as a share of one wholefusion_ksets how sharply a top rank counts, 1 to 1000; a larger value flattens the contribution curve and lets agreement between the two rankings outweigh one ranking's top position[search.semantic].sourcenames where the weights come from:hfdownloads them from a Hugging Face repository and caches them where every other Hugging Face client on the machine caches them,directoryreads a directory the workspace already holds and downloads nothingmodelnames which weights, in the form the semantic tier'ssourcesets: underhfa repository identifier such asminishlab/potion-retrieval-32M, optionally carrying a revision after@; underdirectorya workspace-relative directory. The server stores vectors per model - two models' vectors aren't comparable - so changing the value embeds the workspace again- The model's own
config.jsondecides how the server reads it. Amodel_typeofmodel2vecnames a static model, which embeds a declaration by averaging one row per token; anything else is read as a BERT checkpoint, which runs a forward pass per batch. The shipped default is static, because a forward pass over every declaration in a workspace costs minutes on a laptop CPU and a row gather costs seconds download_timeoutanddownload_attemptsbound one model acquisition, 10s to 1h and 1 to 10; a spent budget leaves search lexical for the life of the serverbatch_declarationsandmax_tokensbound one embedding pass, 1 to 256 and 32 to 512. A BERT checkpoint's attention memory grows with the square of the token window, so a raised window wants a lowered batch; a static model reads one row per token, so its cost grows with the window rather than with its squarecandidatesbounds how many declarations the semantic ranking returns before the two rankings are fused, 1 to 1000, andcandidates_per_filebounds any one file's share of themcandidates_per_filebounds how many declarations one file contributes to that list, 1 to 64; the rest of the file's declarations are dropped, so one file whose declarations all rank well can't fill the list on its own and leave every other file unreachablemax_vectorsbounds how many vectors the workspace holds, 1000 to 1000000; each vector costs the model's dimension in single-precision floats
disabled keeps the workspace lexical without emptying the rest of the table. Set it where
the workspace must not fetch model weights:
[search.semantic]
disabled = trueHooks
Since v0.0.5hooks adds transforms and validations, run in configured order after direct edits land.
A hook is selected from the initial changed paths through include and exclude. Rift selects
the complete list before any hook runs, so one transform cannot make a later hook join the run.
command accepts a program string without whitespace or a nonempty list holding one program and
at most 64 literal arguments, each at most 4096 bytes. Rift starts it directly without a shell:
[[hooks]]
id = "tests"
kind = "test"
command = ["cargo", "test"]
changed_paths = "none"
writes = "none"
timeout = "120s"
output_limit = "4kb"
failure_severity = "error"
determinism = "deterministic"
include = ["crates/**"]
exclude = ["crates/generated/**"]The keys that shape how the command runs:
id- unique within the list, labels the resultcommand- refuses an absolute program; a bare name resolves through thePATHof the environment the command runs with - the server's, withenvironmentapplied on top - and a relative one belowworking_directoryworking_directory- relative to the workspace root; an omitted value selects the rootchanged_paths-"append"adds the changed project paths aftercommandin byte order, for a linter that takes files;"none"leaves the command aloneinclude- selects a hook when an initial changed path matches; empty selects every changeexclude- removes paths from that selectionwrites-"none"makes a validation that may retain no source write;"changed_paths"makes a transform that may change only paths changed before hooks ran;"workspace"lets a transform change any visible source filefailure_severity-"warning"or"error"on the diagnostic a hook failure contributes
A hook runs in the tree it checks, so it executes the agent's change each time one applies -
cargo test compiles the very change it's checking.
id, kind, command, and determinism are required. changed_paths and writes default to
"none"; failure_severity defaults to "error"; working_directory, environment,
guarantees, include, and exclude default empty; and the timeout and output keys use the
shown defaults. The list holds at most 32 hooks. environment holds at most 64 entries,
guarantees holds at most 16 entries, and each include and exclude list holds at most 64
patterns.
Two keys carry bounds: timeout, when Rift kills the process, takes "1ms" to "1h", and
output_limit, how much of each stream Rift keeps, takes "256b" to "4kb". Rift still
reports the full size, so you can tell a truncated log from a short one.
guarantees declares what a validation pass establishes, which turns a passing run into
GuaranteeEvidence on the change. Each entry
names the property (kind), what the check covers (scope), and the claim in words
(detail); the evidence on the change carries them alongside the hook's id. determinism
says whether the same tree and environment are expected to give the same answer twice.
Every transform must appear before every validation. Transform hooks cannot declare guarantees.
A passing transform keeps writes inside its declared scope, and those writes become part of the
change's final files and id. Rift restores transform writes when the command fails,
times out, writes outside scope, or changes source permissions. If transforms undo every direct
edit, result is unchanged.
Validation hooks read final transformed bytes. Rift restores any source bytes or permissions they change and reports that as failure. A failed validation does not refuse or roll back an applied change; it contributes configured warning or error. This is same flow as editor save: formatting updates source, then checks report on saved result.
A hook that exits nonzero, overruns timeout, or fails to launch contributes a finding under the
code rift.hook.failed, naming what ended the run and quoting each non-empty stream with its size.
A command starts from the environment the server inherited, with environment values added on
top. Store secrets outside the committed rift.toml and inject them into the server environment.
Every configured hook inherits that environment and can read those secrets.
This formatter runs first and may update any visible source path:
[[hooks]]
id = "format"
kind = "format"
command = ["cargo", "fmt", "--all"]
changed_paths = "none"
writes = "workspace"
timeout = "120s"
output_limit = "4kb"
failure_severity = "warning"
determinism = "deterministic"