Who this is for: whoever sets an instance up and whoever has to explain what
it is currently doing. You need: an instance root with a revetly.toml and
a .env — revetly init writes both. Not here: what a connector or an
adapter does with its own table
(writing-a-connector.md,
writing-a-write-adapter.md), and how several
projects inherit these keys (multi-project.md).
Two halves: the generated table below is the reference — every option, the
plain name a person reads on the page beside the real name this document uses,
its type, what it falls back to when nothing declares it, the values it accepts,
and what it is for, short sentence and long. Every one of them is also settable
from a page on this machine — revetly config, which renders its controls from
the same declaration this table is generated from and writes these two files
rather than a store of its own. Under it, the sections, with an example
each is the same surface as something to
copy, and two complete files puts it together — those two
are hand-written prose, so read the table above as the contract and them as
illustration.
If you have never seen this file, do not start here. Open revetly config
first. It asks for the one or two answers this instance cannot run without and
holds everything else behind a disclosure until you go looking, which is a
different document from this one and deliberately so. The page these are set
from is what it shows and why it shows so little
of what is below — including the three facts it can tell you that this table
cannot: a folder this engine cannot reach from where it runs, what the first
pass will cost before it runs, and that a repository is read forward from the
moment it is connected, so its past is a separately priced backfill rather than
something a first run quietly includes.
The page is organised by four subjects now, not three: this instance, the projects in it, everything the instance itself holds and does not delegate to any project, and the whole declaration underneath all of that. Each level shows less than the one below it, and nothing is reachable only past the last one — a setting no card or section claims is still in All settings.
This instance is two lines and nothing else. Neo4j: connected (or not
reachable, or not tested) opens the three storage fields behind Change;
LLM and embedding: {provider} — or, where the two differ, LLM: {llm} · Embedding: {embedding} — opens both provider selectors and both API keys
behind its own Change. Neither line paints a field until Change is
pressed, and the card carries nothing beyond these two lines: the
machine-shaped settings that used to sit in the instance card's own
Advanced — which container Revetly runs in, which one restarts it, where
that reaches Docker, the two ports — moved to Ports, updates and
containers, one of the seven sections a level down, and the instance card
has no More and no Advanced control left to hold them in.
Projects is one card per project, holding seven bands in this order:
Namespace and identity, Sources, Records folders, Approved item store, Item
types and parents, Imports and exports, Overrides. A band draws its own name
only where it holds three rows or more in the state being painted; Sources
and Overrides are the two exceptions and always draw theirs, because each is
a gesture and a count with nothing else to sit under. A source is a line
or a card. Where its only setting is one already-spoken-for line —
Documents: docs, adr for the docs connector, which has exactly one field —
it is a line carrying Change, Test connection and Remove source; where
it carries rows of its own, the commits connector's repository floor among
them, it stays a card, nested exactly as it always did. Sources is the
editor of [connectors] enabled, and there is no text box over that list
anywhere on the page: Add source offers the external connectors this
engine registers that the project has not already enabled, Remove source
takes one off, and Revetly's own records are neither offered nor removable —
they are the Records folders line above the sources, never one of them. On a
multi-project instance Add source asks which connection for a connector
that declares connection keys: the instance's, which writes this project's
scope keys and not one connection key, or this project's own, which paints
the connection fields too with the instance's values as their grey. The
source's line then says which of the two it runs on, and Test connection
carries the project it is about so the probe is answered about that project's
chain.
Overrides lists what a project restates of the instance's own settings,
each as here <value> · instance <value> with Remove override, and reads
No overrides where it restates nothing. Add override sits behind the
Advanced settings switch and offers exactly five sections: Extraction and
spend, Sensitivity and withholding, Watch and retries, Git and records, and
Document folders. It never offers LLM and embedding — the loader refuses
[projects.<key>.llm] and [projects.<key>.embedding] outright — never
Authentication and approval or Ports, updates and containers, which are read
once for the whole instance rather than per project, and never a component
table: a project that already restates [connectors.<name>] for one of its
sources keeps rendering it where it always has, on that source's own card,
with a sentence naming that a restated table replaces the instance's rather
than merging into it — its connection keys apart, which a project stating none
of its own inherits — and this page does not mint one blind. Changing a
restated value is removing it and adding it again, or reaching for All
settings; the band is a reading, not a box. A shared namespace
([shared.<key>], a third namespace beside the projects that several of them
resolve into) is not a project and draws no project card — where one is
declared it is its own named line at the Projects level, <key> · read by <exports>, with Change opening its two rows.
Revetly's own records are not a source. The backlog it writes, the
discussions it captures and the delivery records it stamps — pm,
conversations and deliveries — are the Records folders line on the
project card, and they are never offered among the sources to add:
connecting an instance to itself is not something to offer. Which sources
those are is one declared boolean on the connector's own SourceDeclaration
(own_records, false unless a connector says otherwise) and never a list of
three names inside the page. file_backed cannot draw that line and never
could — docs is file-backed and reads somebody else's documentation, and it
stays somebody else's the day it comes out of a drive service rather than
a folder.
Instance settings is the level this story adds. It draws up to seven
sections, in this fixed order: LLM and embedding, Extraction and spend,
Sensitivity and withholding, Watch and retries, Git and records,
Authentication and approval, Ports, updates and containers — plus two more that
draw only where the file declares two or more projects: Connections, between
Git and records and Authentication and approval, and Project defaults at the
end. Connections is one line per external tool the instance holds a
[connectors.<name>] table for, reading who it connects as and where
(Todoist: token set · api.todoist.com), each with Test connection and a
Change opening that connector's connection keys and only those, plus Add
connection offering the registered external connectors that declare connection
keys and have no table yet. An instance-level table's SCOPE keys are
not drawn there — they are the single-project shape left behind, they reach only
the projects that declare no table of their own, and revetly doctor fails
each of those projects. Each heading carries the count of rows the section is showing in
its current state; a section with nothing to show in normal mode — Ports,
updates and containers has no basic-tier row at all — is not drawn until
the Advanced settings switch is on, and there is no placeholder standing in
for it while it is absent. Rows follow the order the engine declares for
their section, never registry order, and a dependent row is indented under
the row it depends on and drawn immediately after it — which is what keeps
First retry delay beside Read pass retries rather than forty rows away in
whatever order the file happens to walk. One search box at this level
matches a label or a summary, opens every section that matched, and dims the
rest.
All settings keeps the role it has always had: generated by walking the
engine's own registries rather than by collecting what a card or a section
drew, so a row nothing above claims is still here, grouped by the three
subjects with a project's own restated rows under that project. It gained
two filters, Changed from default and Required, beside its own search.
It remains the one place a real name — an environment variable, a TOML
section and key — appears at all; every level above names a setting only by
its label.
Everything above follows from five rules, and nothing on the page departs from them:
Test and
Change; its inputs stay behind Change, and first paint shows lines,
never fields. Once both halves are answered, the two provider selectors
fold into their line the same way. A line whose last verdict was a pass is
green and carries the checker's own sentence — key accepted, Revetly can
read this folder — as a line under the reading; with no verdict on file it
reads exactly as it always did.basic-tier rows only, its heading carries the
count, and a section with nothing to show in normal mode is absent
entirely until the switch is on — never a heading with nothing under it,
because a heading saying there is nothing here is noise on a page whose
whole job is removing noise.advanced-tier row in
place, under a thin rule, inside whichever section or card its declared
topic names, and draws the sections normal mode leaves out entirely. No
card carries an Advanced control of its own. Show N more stays per
card and reveals only that card's own basic rows.Save, with no reload.Advanced settings switchOne control, in the sticky masthead, and there is no second one anywhere —
no per-card Advanced button exists any more. It is remembered per
browser and written to no file: turning it on changes nothing Revetly
will read at its next start, and somebody opening the same page in their own
browser sees it exactly as they last left it, which may not be as this one
leaves it. Its state paints before the first pixel, so there is no flash of
the wrong count, and flipping it does not reload the page — every advanced
row already rendered is simply shown or hidden in place, and every section's
heading swaps between its basic count and its basic-plus-advanced count with
it.
/start has retiredThere is no first-run route any more. /start answers a redirect to the
front door, at the step that replaced its first one — a redirect and never a
404, because that address was printed here and somebody has it in a bookmark.
What it was actually for is one gesture, and that is what it is now: Add your
first project, entered from the projects list, five steps in the same order —
name it, say where it lives, connect the sources it reads, price the first pass,
run it. It is a query on the front door rather than a surface of its own, and it
can be abandoned at any step without writing anything.
The acts are unchanged. Pricing reads the sources already in hand and calls no provider; running refuses without the quote it showed you, and runs under this instance's ingest lock; the price is broken down per connector and names the models no rate table could price rather than reporting them free. A folder the running engine cannot open is still refused before it is written — now in a sentence naming who can grant that folder, rather than in the compose fragment they would have to type.
Adding the first project edits the project revetly init wrote. It never
adds a second one beside it. A folder of its own is the one setting the bare
[project] table cannot hold — root is declared only under
[projects.<key>] — so a first project that lives anywhere other than the
instance root is declared under its own table and the bare one is emptied in the
same write.
Adding a later project migrates [project] into [projects.<key>] in the
same save, under the name that project already answers to, with the root it
already read and the two identity keys that would otherwise be inherited by both
projects at once. That migration is not tidiness. The moment any [projects.*]
table exists the bare [project] table stops being a project, so a second
project declared beside an un-migrated first one orphans everything already read
under the first one's namespace — no refusal, no warning, and nothing to notice
until a search comes back empty. Two projects that would carry the same
namespace are refused before either is written, and the save that makes a second
project declares [instance] default_project where nothing has.
One name is asked for. The namespace a project's artifacts carry is derived from it by the loader's own rule, shown as its own line rather than hidden, and separately editable under Advanced — a project has two names, and the moment those two silently differ nobody can answer where a saved artifact came from.
Four facts, stated on the option itself, answer four different questions.
tier — required, basic or advanced — decides grouping and nothing
else: which of the page's disclosures a row sits behind. topic decides
which section or band it sits in — one of the seven instance sections,
project-defaults, or one of the project card's own bands — while subject
still says whose the setting is: instance, project or source. A row on a
project card whose topic names an instance section is there because that
project restates it, and it draws in Overrides rather than in one of the six
bands, which is the whole rule Overrides is read by, rather than a list
anybody keeps in step. order decides the position within wherever topic
placed a row — required rows first, then a topic's basic rows, then its
advanced ones, with a dependent immediately after the row it depends on.
shown_when names the one other option, and the condition on its value, a
dependent row exists under. Every declared condition reduces to one of five
predicates: the parent is answered (records remote and branch, only once
a records clone is declared); the parent's value is one of a stated few
(the verifier table, only under the method it names); the parent differs
from another named option (the embedding endpoint and its siblings, only
where the embedding provider is not the LLM provider); another section is
declared at all (the updater rows, only where [updater] exists); and
the file declares at least a stated number of projects (Project defaults,
and the default-project selector inside it, only at two or more). A row
states at most one of the five, and it is re-evaluated in the browser on
every change to its parent, which is what makes the fourth reveal rule true
before Save rather than after a reload.
tier is deliberately not the required flag beside it in the same record:
that one is the loader's, and means a value must be there. A declaration
stating both and making them disagree hides a field that cannot be left
empty, and is refused by name.
Whether a field is blocking is still asked at render time, of the
provider or connector that owns it, and never read off tier — the same
field is optional on one provider and refused-without on another, and no
static flag says both. Measured against the providers this engine ships:
openai blocks on its key alone. All three model fields are optional,
because it pins all three.openrouter blocks on the LLM model and the embedding model, and not on
the high-volume model — nothing refuses on [llm] small_model, and the
client is built with small_model or model.cloudflare blocks on its token, on the account id its address carries,
and on the LLM model. The embedding model is not asked for at all,
because its embedding model is pinned.anthropic blocks on its key and on [embedding] provider, because it
embeds nothing itself. Its [llm] workspace is marked required by
no declaration anywhere, and becomes required the moment Anthropic refuses
a key for want of it — a fact a vendor stated about one key, just now,
which nothing on disk could have carried.A field can become needed because a vendor said so. That is the second of
the two sources, and it runs. It was built, tested and never fired for one
release — the argument that carries a probe's verdict had no caller anywhere in
the engine — so a page repaired against an earlier version of this document ran
the loader-derived half alone. Pressing Test asks the provider or
the connector, and what comes back is kept: the last verdict per probe,
in .revetly/config-probes.json beside the staged record, written by the probe
route and read when the page is next drawn. A verdict marks the settings its
own repair sentence NAMES — [llm] workspace, [connectors.commits] repos, an
environment variable — and only where the engine or a component actually
declares that setting; a sentence naming nothing marks nothing, and the page
invents no verdict of its own. A marked field is drawn in the open with the
author's sentence under it in red, whatever tier it carries.
How many calls a Test makes. One on the provider card, usually: the free
model lookup, which is a GET at the declared endpoint under the declared key
and carries no body. Two where the declared model does not resolve and the
provider is one whose probe lists — openai-compatible, openrouter and
cloudflare, the three that pin no model — because a 404 on the model route
says nothing on an endpoint that may not serve that route, so the catalogue is
asked to settle it. That second call is a model listing, and it is the one
revetly doctor prints as egress (model listing) and context_health carries
under egress.listing; providers.md is where both are
explained. So a press on one of those three providers, on an instance whose
declared model does not resolve, reaches the vendor twice. Neither call is
billed, and neither sends a body of anybody's work.
When a verdict stops being true. Each entry carries a digest of the values
it was about — the declaration the probe read and the variables that
declaration names, secrets included, which is why it is a digest and not a
listing: nothing readable as a credential is written to the file. Replace the
key, move the endpoint, change the model, swap the provider, and the digest
disagrees: the verdict is ignored where it is read and dropped the next time
anything is written. A pass replaces the failure before it under the same
handle. It survives a reload and a restart — which is the one place it
deliberately differs from the staged record beside it, where a row is pending
until a restart and a restart therefore finishes it; a vendor's refusal of a
key is as true after a restart as before it. Nothing re-probes when the page is
drawn: a Test is still something a person presses, and the first paint fetches
nothing.
Test connection on a source probes that source. Its declared folders, the
settings of its own nobody read, and its own doctor_checks — and nothing
else. It used to run the instance-wide sweep, so pressing it on a folder of
documents answered about a tracker's token, a git remote and the write adapter,
and reported 16 source check(s). No check on this page answers with a count
now: a toast carries what was found in the words of whoever found it, or one
sentence saying nothing needs anybody. The findings land on that source's own
line — green with the checker's own sentence, or red with the fix, which marks
the setting it names the same way a provider's does. The instance-wide sweep
stays where Test what this can read uses it, in the Add-a-project step, and
two checks stay on it alone: the git credentials, because the repositories they
are about always include the instance's own root and its records repo, and the
write adapter, which is where Revetly writes rather than what it reads.
The same rule read the other way is Todoist, which declares neither
project nor projects as required, refuses the two of them declared
together, and reads every board in the account when neither is named. So the
page must not mark either required. What a connector requires it declares;
what it needs beyond that only its own check knows, and a check is what
Test is for.
Of the hundred and fifteen options the engine declares, the ones with subject = instance split 7 required / 11 basic / 64 advanced, a project's own 23
split 3 / 12 / 8, and a source's 10 split 2 / 3 / 5. The levels moved
three rows when they were drawn: [docs] roots to required, and
[egress] withhold_above and [egress] library_telemetry to basic. In
normal mode, on a connected single-project instance, Instance settings draws
at most 15 rows across at most 6 headings; the switch adds the rest, each
under a heading that says what it changes.
summary stays the one sentence a setting is introduced by, with detail —
most of what that sentence used to carry — behind a per-field toggle; the
table below carries both joined, because this document is read by somebody
repairing rather than by somebody setting up. A closed set's values still
render as the word the option declares for them rather than as the token —
the Accepts column below carries both, because this is where the two
vocabularies meet, and the loader still validates the token underneath. The
standard both are written to, the fifteen-word limit a summary is warned
against, and the glossary every label and word draws from, is its own page:
writing.md.
No file name, no environment variable, no configuration section and no shell
command appears on a card. Every field is introduced by its label, the plain
name a person reads, which is almost never the key.
All settings is the one place a real name appears. It stays reachable at
the foot of the page, opened by Show rather than by the Advanced settings
switch, still generated by walking the engine's own registries rather than by
collecting what the cards drew — so an option no card claims is still there —
and it is grouped by the three subjects, with a project's own restated rows
under that project rather than as a fourth group. Each row carries its plain
label and, beneath it, the name this document uses; the search matches
either, or the summary, for somebody who knows what a setting does and
neither of its names.
Where a value came from is stated only when it would surprise — six cases now, and every one of them is a case where changing something here would do nothing, would do more than the one card it sits on suggests, or has already happened:
[served], which binds an address, and [auth], which mounts the verifier
every caller is checked against. On an instance whose supervisor runs the
endpoint, that child comes back on the new declaration by itself; on one
where nothing here can restart anything and a compose file declares this
engine, the banner prints the command that does, spelled for whichever
compose file this stack lives in. On a host install there is no stack to name
and the banner says only that the change is read at the next start.revetly.toml and the .env beside it again at the start of a pass, of a
served request and of an unattended watch tick, so every setting but the two
above is in force where it is used with nothing restarted.The rest of the time the page says nothing, because where a value came from is not interesting when changing it works.
Unset and defaulted are told apart by the control, never by a label. Grey writing inside a box is the default you get by leaving the field alone, and it is never a format hint. A red, empty box is something only you can answer, with nothing sensible to stand in. A model or an address takes its grey from the provider actually in force rather than from the option's own default, which is empty on every one of them.
Seven of the settings a card asks for name a folder on the machine the engine runs on: a project's folder, the repositories it reads, the folders its prose lives in, where new items, conversation notes and delivery records are filed, and the records store. Every one of them is browsed rather than typed. The engine serves that listing itself, at one route with one verb, and it lists directory names and nothing else — it creates no folder, renames nothing, deletes nothing, moves nothing and opens no file, not even to decide whether to show one. Anything listed is something this process can already read, which is the whole point: a browser's own folder chooser withholds the real path from a page, and where the engine runs boxed off it would happily offer a folder the engine was never given, save it, and leave the mistake to be found a pass later. Browsing starts where the field points and may go up and out of it; what it may not leave is what the running engine was actually given — the disk on a host install, and the mounts plus the instance root under the container transport.
At least five more settings point at this machine and are reachable only under
All settings: [records] conversation and [records] delivery, [engine] source, REVETLY_DOCKER_SOCKET, and REVETLY_ROOT, which is read-only. They
use the same picker where they are opened.
Three settings keep the typed field, deliberately. [llm] module,
[embedding] module and a component's own module each name a Python file the
loader imports and executes. Browsing to an arbitrary .py the engine will then
run is a code-execution gesture on a settings page, added for the one audience
that will never need it — whoever writes a provider or a connector is editing
revetly.toml already and knows where they put the file. Those three stay as
text, under Advanced.
Nothing is written until Save, and there is one Save for the whole page however many cards were touched: every change at once, or none of them. A save the loader would refuse writes nothing, says why in the words the page uses, and marks the field it is about — the same check Revetly makes when it starts, made before the file is touched rather than after.
Most of what this page writes is read when Revetly starts rather than when it is saved, and a save that lands in that class raises a banner. The banner has two shapes, and which one you get is decided by what this instance is running and by nothing anybody sets on this page:
The banner's other action is Put it back, which saves the previous value again. It is an ordinary save and not an undo — nothing already read is unread by it — and there is no restore route here, nor one implied.
The page is the answer to your instance will not start, go and fix it, so it opens on the instance it exists to repair. A declaration Revetly refuses — a model an endpoint pins none of and nobody named, a place holder nothing fills, a searching half never declared, a value outside the set its setting accepts — is a banner at the top of the page carrying the refusal in the same words Revetly will print the next time it starts, and naming the setting where it named one. It is not a page that will not open.
What it can still do there, it does. Every setting is under All settings and every one of them is editable, the ones nothing can run without are marked, and the page still knows where it listens and who may reach it, because those are values the file states rather than values Revetly works out from it. Where the file does not say, the page falls back to the same default it always would and tells you which — a page listening somewhere other than where you expect it is not something to discover.
What it cannot do, it says it cannot do rather than offering a control that could only fail. Projects are not drawn: each of them is one project as Revetly loads it, and this is precisely what Revetly will not do — every setting they hold is still under All settings, and they come back the moment the file loads. Nothing offers Test, because there is nothing resolved to check with. A field that lists what your provider actually runs says it cannot ask yet. There is no Add project, because adding one prices and runs a pass. And there is no restart, because restarting into a declaration Revetly will not start on takes the instance down and brings nothing back.
The way out is one save. Saving goes through exactly the check Revetly makes
when it starts, so a save that does not fix the refusal writes nothing at
all — you get the next problem, in the same words, with nothing
half-written behind you. A save that does fix it is written, and the page picks
itself up on the spot: the banner clears, the projects come back, and nothing
was restarted. revetly doctor names this page as the other way out of a
declaration it will not accept.
It behaves the same whether the file was already broken when the page opened or
broke underneath it while the page was open — one page, one banner, and the
order stops mattering. The one case still out of scope is an instance with no
revetly.toml at all; that is what revetly init is for.
revetly config prints one address, and that address reaches the page. It
carries the page's own key, you open it as it is, and there is nothing to look up and
nothing to paste in beside it. Under the container transport that is the whole
of it — the command runs inside the engine and the browser is on your machine,
so it says plainly that opening the address is your move. On a host install
where there is a browser to reach, revetly config --open opens it for you as
well as printing it; it is off unless you ask, because a command reaching out to
drive your desktop is not something to do on somebody's behalf, and the address
on the terminal already opens it.
The key does not stay in the address. The answer to that one request signs the browser in, and the page takes the key back out of the address bar in place before anything is painted — no reload, no second request, and no entry left in history to go back to. What is left is the plain address, and it is worth bookmarking: it still opens the page. The key is never written into a link, a form or anything else on the page, and no request the page makes carries it.
Being signed in lasts a month. Come back inside it, in the same browser, and
the bookmark opens straight into the page. Come back after it, or in a different
browser, and you get a short page saying to run revetly config again and open
what it prints — the same one gesture, and never a file to go and read.
The key stays. Reaching the port and being allowed to change what this
instance runs on are two different questions, and the key is what separates
them. [config] expose = "loopback" asks for the page to be published on this
host, which is not the same as only you: a shared machine, a devcontainer, an
SSH tunnel somebody else opened and any other process running as any user on
that host all reach a loopback port — and this page rewrites the file Revetly
starts on and can ask for a restart. There is deliberately no declaration
that turns the key off. One was considered and not built: [config] expose is a
claim about what the compose file publishes rather than a fact about the socket
(the page binds every interface inside its container, by design), it resolves to
loopback when the file says nothing at all, and an override compose file or a
plain docker run -p moves the real mapping somewhere this engine cannot see
from inside the container it runs in. A key that could be declared away on the
strength of a claim nothing can check is not a key.
What [config] expose is, and what it is not. It is a declaration that a
compose file is meant to carry out, written into the ports: line by revetly init. It is not a property of the socket: the page binds every interface inside
its container whatever the line says, because a process bound to 127.0.0.1
inside its own network namespace is one no published port can reach. And it is
not something the engine enforces or can confirm. docker compose merges a
docker-compose.override.yml without being asked, and -f, COMPOSE_FILE and
a plain docker run -p each move the mapping without any file in the instance
root changing, so what a container can read is what somebody wrote down rather
than what the daemon is doing.
So the engine says which of the two it has. Where it measured the binding it
says what it observed and where from: the address this process bound, on a host
install where nothing publishes a port in front of it, or the Docker daemon's
own answer where somebody mounted the socket into the engine (the compose file
this engine writes deliberately does not, so that is normally "no socket here",
said in those words). Everywhere else the reach reads this host only, declared and not confirmed and the sentence under it names the file that asks for it and
every way the mapping moves without that file. Where two files in the root
publish the page's port on two addresses, the config page line warns and names
both, because which one is in force depends on an invocation the engine never
saw.
A word outside loopback and all is refused by the loader rather than read as
the safe one, which is how this engine treats every value outside a closed set.
An absent [config] table and an empty value are both the default, and both are
reported as the default rather than as something somebody wrote.
revetly doctor's config page line says the same three facts this command
does — the port, who can reach it and on what evidence, and that a key is in
force. Both read them from one place, so the two cannot drift into two claims
about one page.
Three facts about how the page runs that are worth knowing before the first
edit. It is a process inside the engine container, started and restarted by
revetly supervise, the image's command, so a restart of the engine takes the
page down with it and the new container brings it back, whether the restart was
made from the page or by hand. An engine service that declares a command of its
own starts no supervisor, and nothing starts the page there.
The page shows and tests the values the engine will start on next: what the
.env declares outranks what the process happens to hold, so a key you just
fixed is the key Test judges — and a value the compose file pins as a literal
on the engine service is shown as such and is not editable here, because an
edit to the .env under that name would never take effect. And restarting is
the update endpoint's authority: the page asks for that endpoint's token at the
moment of the restart and never carries it itself; revetly config prints the
token beside the page's own address where the instance runs an endpoint.
The engine ships as one data-free container image that must boot unchanged
as both a Tier 1 instance (user runs it on their machine) and a Tier 2
instance (Revetly runs it per-tenant on infra). Every runtime input is read
strictly from the config surface below — env vars, injected secrets, and the
instance revetly.toml/.env — with no hardcoded or host-derived fallback.
Missing or malformed required config fails fast with a named fix; it never
silently falls back to a host default.
Every option below is generated from revetly.configspec, which is what the
engine itself reads: the type, the default, the values it accepts, the plain
name a person is shown, where the page puts it and both halves of the sentence
are one declaration, and the loader builds a project's configuration from that
same declaration — so this table, the page at revetly config and the value the
engine runs on cannot say three different answers for one key. Name is what
the page calls it and Option is what this document calls it — the two names
All settings prints one above the other, and the search there matches either.
Regenerate the block with revetly config --write-reference.
| Option | Name | Section | Type | Default | Accepts | Shown when | What it is |
|---|---|---|---|---|---|---|---|
NEO4J_URI |
Neo4j address | Storage | str | required — no fallback | The address the graph is read and written at. There is no local fallback: unset, Revetly refuses to start rather than reaching for a host default and writing somewhere nobody meant. | ||
NEO4J_USER |
Neo4j user | Storage | str | required — no fallback | The user name Revetly connects to the graph as. | ||
NEO4J_PASSWORD |
Neo4j password | Storage | str | required — no fallback | The password for that graph user. No fallback. Revetly refuses to start rather than guessing one. | ||
OPENAI_API_KEY |
OpenAI API key | LLM and embedding | str | none | The API key that pays for OpenAI LLM and embedding calls. OpenAI reads and searches on this one key, and it is what an instance that has named no other provider runs on. The page and the doctor say whether it is needed from the providers actually in force, never from this line alone. | ||
ANTHROPIC_API_KEY |
Anthropic API key | LLM and embedding | str | none | The API key that pays for Anthropic LLM calls. Anthropic serves no embeddings, so an instance running extraction on it needs a second provider and a second key beside this one. | ||
REVETLY_LLM_MODEL |
LLM model override | LLM and embedding | str | gpt-4.1-mini |
An LLM model set in the environment, outranking the model set here. Unset, this page's answer decides, and then Revetly's own pin for the provider in force rather than whatever ambient default the client library carries. | ||
REVETLY_LLM_CONCURRENCY |
LLM calls in flight | LLM and embedding | int (calls in flight) | 4 |
How many LLM calls run at once; four by default. Raising it does not make a long read pass shorter — it is one episode's calls, not the pass's. Match it to what the provider allows at once. | ||
REVETLY_LLM_MAX_ATTEMPTS |
LLM retry attempts | LLM and embedding | int (attempts) | 5 |
How many attempts a throttled LLM call gets before the read pass fails. | ||
REVETLY_GIT_TOKEN |
Default git token | Git and records | str | none | The fallback token for any git credential naming no variable of its own. A token typed into the configuration file is refused by name, because that file is committed. | ||
REVETLY_GROUP_ID |
Namespace override | Namespace and identity | str | none | A project namespace set in the environment, refused above one project. It outranks the namespace declared for the project, and is refused outright where more than one project is declared — one pinned namespace cannot answer for two. | ||
REVETLY_CAPTURE_INGEST |
Extraction timing override | Watch and retries | str | none | background (Run in background), sync (Wait for extraction) |
Whether a save waits for extraction, set in the environment. Run in background returns as soon as the files are written and extracts after; wait for extraction holds the call open until extraction has landed. Unset, the instance's own extraction timing answers. | |
REVETLY_REVIEWER |
Terminal approver name | Authentication and approval | str | none | The name recorded as approver when a review is answered at a terminal. On an endpoint people sign in to, the signed-in caller wins and this is not consulted at all. | ||
REVETLY_AUTOCOMMIT |
Commit on save | Git and records | bool | true |
Whether Revetly commits the files a save writes. Off opts out of the commit alone: the files are written either way, and committing them is left to whoever runs the instance. | ||
REVETLY_REVIEW_PAGE_LINES |
Review lines per page | Authentication and approval | int (lines) | 20 |
How many lines of a draft are shown at a time; twenty by default. | ||
REVETLY_ENGINE_CONTAINER |
Engine container | Ports, updates and containers | str | none | [updater] is declared |
The Docker container restarted when an update or a change is applied. | |
REVETLY_UPDATER_CONTAINER |
Updater container | Ports, updates and containers | str | none | [updater] is declared |
The Docker container holding the service that restarts the engine. | |
REVETLY_DOCKER_SOCKET |
Docker socket | Ports, updates and containers | str | /var/run/docker.sock |
[updater] is declared |
The socket path the updater reaches Docker at. | |
REVETLY_ROOT |
Instance root | Ports, updates and containers | str | none | The folder this instance's settings are read from. Otherwise discovered by walking up from the working directory for a configuration file. | ||
REVETLY_IMAGE |
Engine image | Ports, updates and containers | str | none | The build this instance is pinned to. |
revetly.toml| Option | Name | Section | Type | Default | Accepts | Shown when | What it is |
|---|---|---|---|---|---|---|---|
[auth] mode |
Authentication mode | Authentication and approval | str | oidc |
oidc (Sign-in at an issuer), static (A token issued here) |
Which credential a served endpoint requires. Sign-in at an issuer is what every declaration written before this key existed meant. The whole section is read on the served HTTP route only. | |
[auth] issuer |
Issuer | Authentication and approval | str | empty | [auth] mode is oidc |
The authorization server this instance trusts to sign people in. Published, so a client discovers where to authenticate rather than being told out of band. Required under sign-in mode. | |
[auth] resource |
Resource identifier | Authentication and approval | str | empty | The name a client asks for a token for. Declared, never derived from the bind address. Required in both modes. | ||
[auth] audience |
Token audience | Authentication and approval | str | empty | What a token must name, where that differs from the resource identifier. For an issuer that writes something other than the resource identifier. Empty means the resource identifier. | ||
[auth] groups_claim |
Groups claim | Authentication and approval | str | groups |
Which token claim carries the groups a caller is in; groups by default. It matters only where grants are declared. | ||
[auth] verifier |
Token verification method | Authentication and approval | str | empty | the token verifiers this instance registers — introspection (Ask the issuer), jwt (Check the signature), static (Match a local token) |
[auth] mode is oidc |
What turns a presented token into a caller. Check the signature verifies the token's own signature against the issuer's key set; ask the issuer calls its introspection endpoint on every request; match a local token compares it against a token issued here. Which one is named selects which settings table below is read. |
[auth] scopes |
Required scopes | Authentication and approval | list of str | empty | Which scopes a token must carry, where any are required. | ||
[auth] registration_hosts |
Client registration hosts | Authentication and approval | list of str | empty | Where a self-registering client's return addresses may be. Empty means no hosted client can register itself. | ||
[auth] approver_may_draft |
Self-approval | Authentication and approval | bool | false |
Whether whoever raised a draft may also approve it. Off by default: the gate exists so a second pair of eyes commits the record. | ||
[auth] review_page_client |
Review page client id | Authentication and approval | str | revetly-review |
The public client id the review page signs people in with. A public client (PKCE, no secret) at the declared issuer. | ||
[backend] pbi |
Approved item store | Approved item store | str | repo |
the write adapters this instance registers — azure-devops (Azure DevOps board), github (GitHub board), repo (This project's folder), todoist (Todoist board) |
Where an approved draft is written; naming nothing writes it nowhere. A folder in this project, or straight back onto the board this project reads. Naming nothing at all is a declaration rather than an omission: approved drafts are then written to no store. | |
[backend] id_family |
Backlog id family | Approved item store | str | empty | One id family for the whole backlog, so every type numbers together. Empty leaves the store whatever scheme it already had. Declared alongside a prefix per type is refused. | ||
[backend] id_prefixes |
Id prefix per type | Approved item store | table | empty | What new ids start with, one prefix per item type. One prefix per type, so adding a type does not renumber the backlog onto one family. | ||
[backend] tracker_note |
Tracker delivery note | Approved item store | bool | true |
Whether shipping posts a delivery note on the tracker item. A ticket in somebody's board has nowhere to put the delivery record, so after a ship the engine renders the record into a comment and the write adapter posts it, with a pointer line in the item body. Off writes nothing to the item and behaves exactly as it did before, and the local record is written either way. | ||
[backfill] ceiling_episodes |
Backfill episode ceiling | Extraction and spend | int (episodes) | 0 |
How many episodes one backfill may send before it stops. It stops at a window boundary rather than mid-window. Zero is undeclared, and a backfill refuses to run under it. | ||
[backfill] ceiling_spend |
Backfill spend ceiling | Extraction and spend | float (USD) | 0.0 |
The same ceiling in USD; zero is undeclared, not unlimited. | ||
[backlog] types |
Item types | Item types and parents | list of str | epic, feature, story |
The item types this project has; epic, feature and story by default. What a store can actually create is the store's answer, not this one's. | ||
[backlog] default |
Default item type | Item types and parents | str | story |
Which type a draft naming none becomes, from the declared types. | ||
[backlog] parents |
Parent type map | Item types and parents | table | {'epic': (), 'feature': ('epic',), 'story': ('feature', 'epic')} |
Which item types may sit under which. The whole hierarchy comes from here, and a declared map replaces the default outright. | ||
[backlog] parent_required |
Types requiring a parent | Item types and parents | list of str | feature |
Which item types must name a parent rather than may. | ||
[capture] ingest |
Extraction timing | Watch and retries | str | background |
background (Run in background), sync (Wait for extraction) |
Whether a save returns at once or waits for extraction to land. Run in background is right unless the answer is needed in the same breath; wait for extraction holds the call open until extraction has landed. | |
[capture] recover_attempts |
Read pass retries | Watch and retries | int (attempts) | 2 |
How many attempts a failed read pass makes at itself; two by default. | ||
[capture] recover_delay |
First retry delay | Watch and retries | float (seconds) | 60.0 |
[capture] recover_attempts is set |
Seconds before the first retry; the wait doubles after each one. | |
[capture] episode_window |
Context episodes per call | Extraction and spend | int (episodes) | 0 |
How many preceding episodes ride along on every extraction call. Zero sends none. Documents carrying explicit ids need none; chat transcripts need a few, because who they is only said further up. | ||
[config] port |
Configuration page port | Ports, updates and containers | int | 9714 |
The port this page listens on; nothing listens until the page is started. Unlike the update service, declaring the section switches nothing on. | ||
[config] expose |
Configuration page reach | Ports, updates and containers | str | loopback |
loopback (This machine only), all (Every attached network) |
Whether this page answers beyond this machine; a claim, not a bind. The page binds every interface inside its container whatever this line says, so it is a label on the declaration rather than something it enforces: a compose ports line is what carries it out, and the engine cannot confirm what is published. | |
[connectors] enabled |
Enabled sources | Sources | list of str | pm, docs, conversations, deliveries |
the connectors this instance registers — azure-devops (Azure DevOps), commits (Git commits), conversations (Conversation notes), deliveries (Delivery records), docs (Documents), github (GitHub), pm (Backlog items), todoist (Todoist) |
Which sources this project reads; an empty list reads nothing, deliberately. An empty list is a declaration and not an oversight — nothing is read, and Revetly guesses nothing. | |
[docs] roots |
Document folders | Sources | list of str | docs |
Folders inside this project whose prose is read into the graph. Every file under one of them is a spending decision: the prose in it goes through extraction on the next read pass. | ||
[egress] withhold_artifacts |
Withheld artifacts | Sensitivity and withholding | list of str | empty | Artifact ids whose bodies are withheld from every provider call. They are still read, still committed and still in the ledger — it is the body that stays here, not the record of it. | ||
[egress] withhold_sources |
Withheld sources | Sensitivity and withholding | list of str | empty | Whole sources whose bodies are withheld from every provider call. | ||
[egress] withhold_kinds |
Withheld artifact kinds | Sensitivity and withholding | list of str | empty | Artifact kinds withheld from every provider call, optionally narrowed to one source. An entry is a kind, or a source and a kind together where only the one source's are meant. | ||
[egress] withhold_above |
Highest classification sent | Sensitivity and withholding | str | empty | the classifications this engine knows — confidential (Confidential), internal (Internal), public (Public), restricted (Restricted) |
Anything a source classifies above this level is withheld. Public is publishable as it stands; internal is inside the organisation's own walls; confidential is somebody else's private data; restricted is credentials and regulated data. Naming the most restricted classification withholds nothing, and empty withholds nothing at all. It depends on the source declaring a classification in the first place. | |
[egress] library_telemetry |
Graph library telemetry | Sensitivity and withholding | bool | false |
Whether the graph library may send usage data to its vendor. graphiti-core posts an anonymous id, its version and the clients it was handed to its vendor's endpoint on every graph construction. Off by default: extraction is meant to be the only sink outside the perimeter, and a process-level variable outranks this line. | ||
[embedding] provider |
Embedding provider | LLM and embedding | str | empty | the providers this engine registers — anthropic (Anthropic), cloudflare (Cloudflare), none (No provider (lite instance)), openai (OpenAI), openai-compatible (Any OpenAI-compatible endpoint), openrouter (OpenRouter) |
Which provider makes text searchable; empty follows the LLM provider. Empty follows the LLM provider, where that provider can also embed; required where it cannot — Anthropic serves no embeddings. A different vendor from the one that runs extraction is normal here. | |
[embedding] endpoint |
Embedding endpoint | LLM and embedding | str | empty | [embedding] provider differs from [llm] provider |
A different base URL for embedding calls; empty follows the LLM endpoint. Empty follows the LLM endpoint on the same provider, or the provider's own address. | |
[embedding] key_env |
Embedding key variable | LLM and embedding | str | empty | [embedding] provider differs from [llm] provider |
Names the environment variable holding the embedding API key. Empty follows the LLM key variable on the same provider, or the provider's own. | |
[embedding] account |
Embedding account id | LLM and embedding | str | empty | [embedding] provider is cloudflare |
The account a per-account embedding endpoint belongs to. Empty follows the LLM account id on the same provider. | |
[embedding] model |
Embedding model | LLM and embedding | str | empty | The model that turns text into vectors. Changing it on a corpus that exists changes the vector space: everything has to be read again and paid for again, which is a corpus revision and never a silent switch. Empty is the provider's pin (text-embedding-3-small on OpenAI); an OpenAI-compatible provider must declare one. |
||
[embedding] module |
Embedding provider module | LLM and embedding | str | empty | A file in this instance holding the embedding provider. As the LLM provider module does for the extraction half. | ||
[embedding] headers |
Embedding header variables | LLM and embedding | table | empty | [embedding] provider differs from [llm] provider |
Extra headers on every embedding call, each naming a variable. Empty follows the LLM header variables on the same provider — one gateway, one set of headers — and the same header names are refused. | |
[embedding] params |
Embedding request fields | LLM and embedding | table | empty | [embedding] provider differs from [llm] provider |
Extra JSON fields merged into every embedding request. Empty follows the LLM request fields on the same provider. | |
[engine] source |
Engine source folder | Ports, updates and containers | str | empty | Where the engine code this build is compared against lives. Relative to the project root. Read by the doctor chain and by nothing else. | ||
[extraction] statements |
Statement extraction | Extraction and spend | bool | true |
Whether decisions, assumptions and constraints are extracted too. Better answers, bigger bill. Off, Revetly still reads everything — it just will not say why something was decided. A smaller corpus, not a broken one, and an indexed artifact makes no extraction call either way. | ||
[extraction] tiers |
Extraction tier per kind | Extraction and spend | table | {'commit': 'index', 'delivery-record': 'index', 'delivery-note': 'index', 'revision': 'index'} |
entities (Extract with the LLM), index (Index without extraction), statements (Statements only) |
Which artifact kinds go through the LLM and which are only indexed. Per kind, merged over the engine's own defaults. Extract with the LLM sends the kind through the model; index without extraction keeps the whole body searchable and every declared and explicit link, and makes no extraction call at all. Statements only indexes the kind the same way and then makes one LLM call per artifact for its decisions, assumptions and constraints, where the kind carries them. Commits, delivery records and revisions are indexed unless a line here says otherwise. | |
[extraction] concepts |
Concept naming | Extraction and spend | str | declared |
declared (Product concepts only), open (Anything it finds) |
Whether the extractor names product concepts only, or anything it finds. Product concepts only tells the extractor to name product concepts and nothing else — never code identifiers, paths, shas, versions or git identities — and withdraws the ones that slip through afterwards. Anything it finds lets it name whatever it likes, which is what every pass did before this line existed. | |
[extraction] aliases |
Concept aliases | Extraction and spend | table | empty | Other spellings of one concept, folded onto a single node. Canonical name to its other spellings, so five ways of writing the product's name are one entity: the extractor is told, and the pass folds what the corpus already holds onto one node at no provider cost. | ||
[extraction] retractions |
Retracted edges and concepts | Extraction and spend | table | empty | Which inferred links and concepts this instance takes back. Two lists, each row carrying its reason: edges by source name, relation and target name, concepts by name. A pass at the tail of every read invalidates the edges and takes the concepts off the shelf, at no provider cost, so a wrong inference stays taken back through every re-read. Nothing is erased and nothing a source stated is touched — removing a row here does not restore what it retracted. | ||
[git] fetch |
Git fetch | Git and records | bool | true |
Whether a repository is fetched before it is read. Off pins reading to whatever the checkout holds, which is what a deliberately offline machine wants. | ||
[git] push |
Git push | Git and records | bool | true |
[git] records_clone is set |
Whether the records Revetly writes are pushed to their remote. | |
[git] records_clone |
Records clone | Git and records | str | empty | A checkout Revetly owns and is the only writer of. Empty reads and writes the records store where it already sits, which means sharing a working tree with whoever else is in it. Declared for the instance, it is relative to the instance and every project writes into it, each in its own folder. | ||
[git] records_remote |
Records remote | Git and records | str | origin |
[git] records_clone is set |
The remote that clone is fetched from and pushed to; origin by default. | |
[git] records_ref |
Records branch | Git and records | str | empty | [git] records_clone is set |
Which branch of that clone; empty keeps the branch it is on. | |
[git] credentials |
Git credentials | Git and records | table | empty | Named ways into private repositories, each naming the variable holding its token. Each entry names the environment variable its token is read from. A token value here is refused: the configuration file is committed, so the name of the variable goes in the file and the token itself never does. Pushing needs an entry declared writable. | ||
[instance] default_project |
Default project | Project defaults | str | empty | 2 or more projects are declared | Which project answers when a question names none. | |
[llm] provider |
LLM provider | LLM and embedding | str | openai |
the providers this engine registers — anthropic (Anthropic), cloudflare (Cloudflare), none (No provider (lite instance)), openai (OpenAI), openai-compatible (Any OpenAI-compatible endpoint), openrouter (OpenRouter) |
Which provider runs extraction on the sources this instance reads. OpenAI, an OpenAI-compatible endpoint (Azure, vLLM, Ollama, Gemini's compatible endpoint), Anthropic, OpenRouter, Cloudflare, or one installed as a revetly.providers entry point. Instance-wide — a project table re-declaring it is refused. |
|
[llm] endpoint |
LLM endpoint | LLM and embedding | str | empty | A different base URL to call; empty uses the provider's own. https://api.openai.com/v1, https://api.anthropic.com; an OpenAI-compatible provider has none of its own and must declare one. The SDK's own base-URL variable found in the environment with a different value stops the engine before a client is built. |
||
[llm] region |
LLM region label | LLM and embedding | str | empty | Where that endpoint is, as the operator states it; never validated. eu, us-east — printed by revetly doctor and context_health and never validated: the engine cannot know, so this is the operator's statement rather than a fact it checked. |
||
[llm] key_env |
LLM key variable | LLM and embedding | str | empty | Names the environment variable holding the API key, never the key itself. Empty is the provider's own: OPENAI_API_KEY, ANTHROPIC_API_KEY. |
||
[llm] workspace |
LLM workspace | LLM and embedding | str | empty | [llm] provider is anthropic |
The provider workspace this key belongs to; an Anthropic console key needs one. Where the provider asks for one: Anthropic sends it as the anthropic-workspace-id header, which an identity-linked console key needs and a workspace-bound key does not — the provider refuses the former without it, and revetly doctor names this line as the fix. Providers with no use for it ignore it. |
|
[llm] account |
LLM account id | LLM and embedding | str | empty | [llm] provider is cloudflare |
The account a per-account endpoint belongs to; Cloudflare needs one. Cloudflare addresses one account per URL and its default endpoint carries {account} until this fills it (wrangler whoami prints it). An endpoint still carrying the placeholder at load is refused rather than sent. |
|
[llm] model |
LLM model | LLM and embedding | str | empty | The model that does extraction, and where quality shows up. Empty is the engine's pin for the provider in force (gpt-4.1-mini, claude-sonnet-5); an OpenAI-compatible provider has none and must declare one. An LLM model set in the environment outranks this line. |
||
[llm] small_model |
LLM high-volume model | LLM and embedding | str | empty | The cheaper model for repetitive extraction work; most calls, most of the bill. Matching up names, filling in dates, deciding two records name one artifact — the dull work, and by far the most calls, so going cheaper here moves the bill more than going cheaper on the LLM model. Empty is the provider's pin (gpt-4.1-nano on OpenAI); a provider whose client sends every call to one model ignores it. |
||
[llm] module |
LLM provider module | LLM and embedding | str | empty | A file in this instance holding a provider Revetly does not ship. A Python file whose provider is a revetly Provider — the third door, beside the built-ins and an installed entry point. It matters only where the provider name is unregistered. |
||
[llm] headers |
LLM header variables | LLM and embedding | table | empty | Extra headers on every LLM call, each naming the variable holding its value. Declared as header = "VARIABLE" — the NAME of the environment variable holding the value, never the value. For a gateway that wants a second credential beside the key (cf-aig-authorization) or a name for itself (cf-aig-gateway-id, HTTP-Referer). Authorization and the key headers are refused: the engine sets those from the key variable. |
||
[llm] params |
LLM request fields | LLM and embedding | table | empty | Extra JSON fields merged into every LLM request body. OpenRouter's provider = { data_collection = "deny", zdr = true }, say. The engine states what it sent and cannot verify the endpoint honoured it. Fields the client writes itself (model, messages, input, response_format, stream) are refused. |
||
[pricing] models |
Model rates | Extraction and spend | table | empty | What this account pays per million tokens, by model name. Matched by prefix, longest first. Empty uses the engine's own dated figures, one sheet per provider, and says which sheet it read. | ||
[project] group_id |
Project namespace | Namespace and identity | str | required — no fallback | The namespace every artifact this project saves carries. Typed by a human and never derived from a directory name. Renaming it moves nothing already saved — Revetly will not do that for you, and two projects that would carry the same namespace are refused. | ||
[project] source_system |
System id | Namespace and identity | str | repo |
Names this instance as a system, so ids from two systems cannot collide. Artifacts saved here keep plain ids; everything read from anywhere else is marked with where it came from, so two systems using the same id cannot be confused for one another. | ||
[project] source_instance |
Instance id | Namespace and identity | str | empty | Which instance of that system this is, where the system id alone is short. Empty takes the project namespace. | ||
[records] conversation |
Records conversation folder | Records folders | str | empty | The conversation notes folder when Revetly owns a separate records store. For an instance whose backlog lives in a store it only reads. Empty falls through to the project folder set above. | ||
[records] delivery |
Records delivery folder | Records folders | str | empty | The delivery records folder when Revetly owns a separate records store. For an instance whose backlog lives in a store it only reads. Empty falls through to the project folder set above. | ||
[routing] pbi |
Backlog folder | Records folders | str | sources/backlog |
The folder new backlog items are written to, relative to the project folder. Where an approved draft, a captured conversation or a delivery record is filed, relative to the project folder. | ||
[routing] conversation |
Conversation notes folder | Records folders | str | sources/conversations |
The folder conversation notes are written to, relative to the project folder. Where an approved draft, a captured conversation or a delivery record is filed, relative to the project folder. | ||
[routing] delivery |
Delivery records folder | Records folders | str | sources/deliveries |
The folder delivery records are written to, relative to the project folder. Where an approved draft, a captured conversation or a delivery record is filed, relative to the project folder. | ||
[search] fuzzy |
Fuzzy term matching | Search and ranking | bool | true |
Whether a long search term also matches indexed words one or two letters away. On, a term of five to eight characters also matches indexed words one letter away, and a longer one words two letters away. The comparison runs against the index's stemmed words (mooring is stored as moor), so a typo in a word the stemmer shortens is still not found: ingestino does not reach ingestion. What it changes is ranking: a term the stemmer leaves whole counts twice and also matches its near-spellings (store matches story). Off, every term matches only as the index spells it. Measured, off ranked real agent queries slightly better and model-written queries slightly worse, so the default stays on. A term of four characters or fewer, a bare number and an id this engine recognises are matched exactly either way, and a binding topic scope never uses it. |
||
[search] recency_half_life_days |
Recency half-life | Search and ranking | int (days) | 180 |
How many days until a result counts half as much for being recent. Ranking only, never filtering: nothing is ever hidden for being old. A result whose source was last changed one half-life ago is worth half a fresh one at the same relevance, two half-lives ago a quarter, and so on. Raise it where the corpus is a slow-moving record; lower it where last week is what matters. Measured on the source's own clock, not on when Revetly read it. Zero is refused — it is a division nobody meant rather than a way of saying no decay. | ||
[search] provenance_weights |
Provenance weights | Search and ranking | table | {'source-of-record': 1.0, 'human-authored': 0.9, 'mirror': 0.6, 'ai-derived': 0.5} |
What a claim is worth in the ranking, by where it came from. One weight per provenance a connector may declare, each above zero and at most one, merged over the engine's own figures — saying what a mirror is worth says nothing about what a decision note is. This is the whole reason provenance ranks at all: a mirror and the record it copies say the same words, and one of them is the authority. A weight for a provenance no connector may declare is refused naming it. | ||
[sensitivity] patterns |
Sensitivity patterns | Sensitivity and withholding | table | empty | Extra patterns the capture-time scan looks for, as name and expression. | ||
[sensitivity] disabled |
Disabled patterns | Sensitivity and withholding | list of str | empty | Which of the engine's own sensitivity patterns this instance does not run. | ||
[served] public_url |
Public address | Authentication and approval | str | empty | The address people reach this instance at, where that differs from the bind. Consulted before the resource identifier. | ||
[served] allowed_origins |
Allowed browser origins | Authentication and approval | list of str | empty | Which browser origins may call this instance's served endpoint. Undeclared emits no CORS header at all. | ||
[update] source |
Update source | Ports, updates and containers | str | empty | Where this instance's releases are published. Empty uses the address baked into the image. | ||
[updater] port |
Updater port | Ports, updates and containers | int | 9713 |
[updater] is declared |
The port the update service listens on. Declaring this section is what turns that endpoint on, because what it holds is a Docker socket. | |
[updater] expose |
Updater reach | Ports, updates and containers | str | loopback |
loopback (This machine only), all (Every attached network) |
[updater] is declared |
Whether the update service answers beyond this machine. This machine only binds the loopback address; every attached network binds them all. |
[updater] swap_timeout |
Restart timeout | Ports, updates and containers | float (seconds) | 120.0 |
[updater] is declared |
Seconds a container swap gets before it reads as stalled. | |
[watch] enabled |
Watch loop | Watch and retries | bool | false |
Whether Revetly runs unattended read passes on this instance. Off, the served engine starts no loop of its own, and revetly watch still runs a pass when somebody types it. On, a pass runs on the watch interval unattended. Always off on a fresh instance. |
||
[watch] interval |
Watch interval | Watch and retries | float (seconds) | 300.0 |
Seconds between unattended polls for new work. Counting from the end of the last pass. Zero is watch reading declared off: the loop runs at no time, and revetly watch refuses. The switch above turns the served engine's own loop on and off. |
||
[watch] full_interval |
Full read interval | Watch and retries | float (seconds) | 21600.0 |
Seconds between full read passes, whatever the poll said. | ||
[watch] backoff_max |
Maximum backoff | Watch and retries | float (seconds) | 3600.0 |
The longest a failing source waits before it is tried again, in seconds. |
Declared under [projects.<key>], beside the [project] keys above.
| Option | Name | Section | Type | Default | Accepts | Shown when | What it is |
|---|---|---|---|---|---|---|---|
root |
Project folder | Namespace and identity | str | empty | The folder this project's sources are in, relative to the instance root. It has to be a folder the running engine can actually open — a boxed-off engine was never given most of the machine. | ||
imports |
Imported projects | Imports and exports | list of str | empty | Which projects or shared namespaces this project may read. Closed by default: content crosses only where an exporter and an importer agree, so ticking one direction writes both halves of it. | ||
exports |
Exported projects | Imports and exports | list of str | empty | Which projects may read this project; both sides must agree. | ||
isolated |
Isolation | Imports and exports | bool | false |
Nothing leaves this project and nothing reaches it. Another project naming it is a load error rather than a line quietly ignored. |
The keys inside these belong to a component — a connector, a write adapter, a token verifier — or name a project rather than a setting. The engine declares nothing about them and rules on nothing inside them, which is why they are not in the generated table above.
Which half of the surface is which. Everything in the generated table is the
engine's: it declares those options, checks them, and refuses a value outside
the set it accepts. Everything in the table below is a component's, and the
engine's position on it has not changed — it carries the table to whoever owns
it and rules on no key inside it. What changed is who else may
speak: a connector, a write adapter or a token verifier can now declare what it
reads out of its own table, in the same configspec.Option vocabulary the
engine's own options use, and where one has:
revetly config renders that table with the typed controls the engine's own
options get, inside the connection card for the source that reads it, and
grouped by the same three tiers — because SettingsDeclaration reuses
configspec.Option, a component's own settings carry tier, subject and
label in the same vocabulary and there is no second one to learn;revetly check reports the table against the declaration — what is set, what
required key is missing, what value falls outside the accepted set — before a
pass spends anything;revetly doctor names a component whose required setting is unset, in the
same wording the page shows;Declaring is optional and stays optional, and so are the three the page groups
by: a component that states no tier, subject or label is rendered under
Advanced, filed against the source it reads, and named by its own key. Only
the engine's own options must state all three, and a test fails on one that does
not. A component that declares nothing at all has its table carried
through verbatim, byte-identical on save, exactly as before — and is
reported unchecked, never as passed. revetly check --contract
publishes what each connector this engine ships declares, table by table; what
an author writes is in writing-a-connector.md §4b and
writing-a-write-adapter.md §2.
Two sub-tables under those sections are the engine's vocabulary rather than
a component's, and are read by the loader rather than carried to anybody:
[auth.grants] and [auth.tokens] (below), and [backend.id_prefixes]. A
component of one of those names would be the collision that reservation exists
to make visible.
| Input | Source | Required | Notes |
|---|---|---|---|
[backend.<name>] |
revetly.toml |
no | One write adapter's own settings, carried verbatim to that adapter and read nowhere else — the write-side twin of [connectors.<name>]. module and factory are read by the loader; everything else is the adapter's. A credential value here is refused at load (the file is committed): name the variable that holds it (token_env = "ACME_TOKEN"). A key the adapter never reads is reported by revetly doctor as declared-but-never-read. |
[auth.<name>] |
revetly.toml |
no | One token verifier's own settings, carried verbatim and read nowhere else — the same shape as [connectors.<name>] and [backend.<name>]. A credential value here is refused at load (the file is committed): name the variable that holds it (client_secret_env = "REVETLY_AUTH_CLIENT_SECRET"). The verifier this engine ships with is introspection (RFC 7662): endpoint is required, client_id and client_secret_env are its credential, timeout (default 10s) bounds the call every request waits on. It asks the issuer itself on every request rather than checking a signature locally and rather than caching — a cache here is a window in which a token withdrawn five minutes ago still reads the corpus. A token whose aud names another resource is refused (the confused deputy RFC 8707 exists to stop); an issuer that returns no audience at all is said once and its tokens stand, since refusing them would refuse every deployment behind an issuer that does not implement it. An introspection endpoint that cannot be reached answers nobody — "cannot say who is asking" and "nobody" are the same answer at a door. The second verifier is jwt: it fetches the issuer's published keys — at jwks_uri if declared, otherwise from whichever of /.well-known/openid-configuration or /.well-known/oauth-authorization-server the issuer answers — and checks signature, iss, exp and aud locally. algorithms narrows what it accepts and may name asymmetric algorithms only, since an HMAC token verified against a key from the issuer's public key set is the classic forgery; key_ttl (default 600s) is how long a fetched set stands before the next call refreshes it, leeway (default 60s) is the clock skew tolerated on exp, timeout (default 10s) bounds a fetch. A token whose aud is not this resource is refused, and so is one carrying no aud at all — stricter than introspection, because here the audience is in the token and one without it was not minted for this resource. No refusal echoes the token. Keys are cached: a rotation is picked up on the next unknown kid (throttled, so a stranger guessing key ids cannot make this engine hammer its issuer), an issuer that goes down does not invalidate what it already published, and one that was never reachable verifies nobody. The third is static, which reads no settings table of its own: a token's settings are the token, declared under [auth.tokens.<name>]. It is selected by declaring one rather than by being named, checked before the issuer's verifier because it costs a hash rather than a signature, and it is the only one that authenticates a machine. |
[auth.grants.<claim value>] |
revetly.toml |
no | What a caller carrying that claim value may reach: role, the lens, and projects, the namespaces. Exactly those two keys; anything else is refused by name. The permitted set for a call is the intersection of this and the project's own imports/exports — a grant can only narrow, and config keeps its veto. A grant naming a project this instance does not declare, or a declared project no grant reaches, is a revetly doctor finding rather than a load error: neither authenticates anybody, and stopping ingest over one is the wrong blast radius. |
[auth.tokens.<name>] |
revetly.toml |
no | A bearer token this instance declares itself, by its digest: hash = "sha256:<hex>", a compulsory expires (a bare date is the whole of that day, UTC), and grant, naming an [auth.grants] entry. The keys that would hold the token itself — token, value, secret — are refused by name, because this file is committed. No browser, no issuer, and it works when the authorization server is down, which is the hour a CI job most needs the corpus. Whatever presents one is attributed as a machine rather than a person. |
[projects.<key>] |
revetly.toml |
no | One project of this instance. The table name is the key, written by hand — never derived from a directory name, a path, or the namespace string. group_id is its own graph namespace and is never inherited; root (default .) is where its sources, ledger, health record and lock live. imports/exports are the two directions of what may cross, both closed by default and both required for anything to; isolated = true closes both and makes any other table naming it a config load error. A project may re-state any section above under [projects.<key>.<section>] and inherits the ones it does not. A section is read key by key, but a settings table under one ([projects.<key>.connectors.todoist]) is a single key and replaces the instance's whole — merging would leave no way to unset an inherited key. The one exception is the connector's own connection keys: on an instance declaring two or more projects, a project's table that states none of them takes exactly those keys from the instance's table — Todoist's token_env, base_url, timeout; Azure DevOps's token_env, organization, base_url, api_version, timeout; GitHub's token_env, base_url, api_version, timeout — and a table that states any of them states its own connection whole and inherits nothing. A connection is established once and reused; scope is never inherited, which is what replace-not-merge was always protecting. What that replacement dropped is named by revetly doctor and by revetly ingest --dry-run rather than left to be discovered in the ids, and revetly doctor --project NAME / revetly check name, per source, whether it runs on a connection of its own or the instance's, and revetly doctor --project NAME / --all walk the per-project chain for one or for every project rather than the default alone. [projects.<key>.routes] maps one of its connectors onto the shared namespace that source's artifacts are ingested into. Two projects may share neither a group_id nor a root. An instance declaring one project is unchanged in every observable way. |
[shared.<key>] |
revetly.toml |
no | A namespace beside the projects that several of them resolve into — a third namespace, never a merge of two. group_id is its own, distinct from every project's; exports names the projects that may read it, closed by default like any other reach. It carries its own manifest and its own budget line under .revetly/shared/<key>/, so a pass over a project still bills only that project's artifacts. Moving an artifact into or out of it is a re-extraction and is priced and confirmed by revetly rehome rather than happening because a line was edited. |
GIT_AUTHOR_* and GIT_COMMITTER_* are read by git rather than by this
engine, and override the image's baked revetly-docker autocommit identity
per instance. They are named here because an operator looking for them should
find them, not because anything in the engine reads them.
Every section above as something to copy. Nothing here is required except
group_id and the four credentials: an instance that declares none of the rest
gets the defaults the table states, which are the ones this engine was built
around.
Credentials and provider, in .env beside revetly.toml. Never in the
committed file — a value in a git history is not recoverable:
OPENAI_API_KEY=sk-... # the variable `[llm] key_env` names; ANTHROPIC_API_KEY on anthropic
NEO4J_URI=bolt://neo4j:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=... # minted per instance by `revetly init`
# Cost levers, all optional. The model is the big one.
REVETLY_LLM_MODEL=gpt-4.1-mini
REVETLY_LLM_CONCURRENCY=4
REVETLY_LLM_MAX_ATTEMPTS=5
A key saved into this file — by hand, or from the configuration page — is
read by the next command inside an engine container that is already running.
No docker compose up, no recreate: the compose file passes each provider key
through as ${OPENAI_API_KEY:-}, and an empty passthrough is not a value, so
the file outranks it exactly as it outranks an absent name. What still wins is
a value that was genuinely in the environment when the process started — a
secret a deployment injected, or a container started from an older .env.
revetly doctor's secrets line says, for each secret-bearing name, which
value is in force and where it came from, printing neither.
Declaring no provider at all. revetly init --lite writes
[llm] provider = "none" and [embedding] provider = "none", both in one
declaration in the revetly.toml it scaffolds. A lite instance calls no vendor,
reads no key and is billed for nothing: every kind is indexed, links are the
deterministic ones a source stated and search is keyword-only, with no semantic
search, no concepts, no inferred links and no prose-only statements. A kept
revetly.toml is never rewritten, so this reaches a file init writes and never
one it keeps; naming a real provider on both sinks is what makes such an
instance a full one. revetly manual show providers has what a lite instance
answers in full.
Security note. Two answers revetly init decides for you, because the
corpus is every artifact body this instance holds, in plaintext, behind them:
secrets.token_urlsafe) and
written into .env once; a re-run never rewrites one. No template and no
generated compose file carries a default — an empty NEO4J_PASSWORD stops
docker compose up by name rather than starting a graph on a password every
recipient of a handout knows, and revetly doctor FAILs the graph password
line on an instance still running the retired one, with the rotation named.revetly init --publish-graph (a re-run works) publishes Neo4j Browser and bolt on
127.0.0.1 — this machine only, and every process on it, which is what it
costs. The host transport publishes on 127.0.0.1 because the engine on the
host reaches the graph through it.[project] — the namespace this instance's artifacts live in. The one key
with no default:
[project]
group_id = "acme-platform" # this instance's graph namespace
source_system = "repo" # its half of the id namespace
source_instance = "" # defaults to group_id
[routing] — where this instance's own sources are. Three kinds, all with
defaults:
[routing]
pbi = "sources/backlog"
conversation = "sources/conversations"
delivery = "sources/deliveries"
[records] — where the records Revetly originates land. Its own seam,
deliberately not [backend] pbi: connecting a tracker replaces where the ask
lives and nothing else, because no tracker has a field for "criterion 3
departed, decided by the builder, because X":
[records]
conversation = "decisions"
delivery = "shipped"
[docs] — the doc roots read into CONTEXT. Every markdown file under one is
sent on the next ingest, so this is a spending decision:
[docs]
roots = ["docs", "architecture"]
[connectors] — which sources this instance reads, and their own settings.
A connector is enabled by being named here and nowhere else:
[connectors]
enabled = ["pm", "docs", "conversations", "deliveries", "todoist"]
[connectors.todoist]
account = "acme" # the namespace half of every id it mints
token_env = "TODOIST_API_TOKEN" # the VARIABLE, never the token
project = "Roadmap"
A source with no current state is floored per unit. A git
repository has no small snapshot — its history is its snapshot — so the
commits connector reads each repository forward from the moment that repository
was connected to this instance, recorded durably in .revetly/connections.json
and never re-derived from the current pass. Declaring nothing is now the ordinary
case, and it costs what the repository has done since somebody wired it up:
[connectors.commits]
repos = ["../product-api", "../product-web"]
[connectors.commits.since] # OPTIONAL, and per repository
"../product-api" = "2026-01-01" # keyed exactly as `repos` writes it
since as a bare string is the older shape and still honoured — one floor
standing in for every repository under it — and revetly doctor says so, per
repository, because a date chosen for a repository created last week decides
nothing sensible about a five-year one that joined later. Moving a floor
earlier does not make the next unattended pass read further back: the span
is reported as history that is now available and unread, and reading it takes
revetly backfill, which prices per window before it sends anything.
[backend] — where approved drafts land. A missing key is the repo store;
pbi = "" writes nowhere and says so at the gate:
[backend]
pbi = "todoist"
[backend.acme] # one adapter's own table, read nowhere else
module = "backends/acme.py"
project_key = "PROJ"
Two id levers, two different intentions — state one of them. Both matter only to a store that mints ids locally; a tracker assigns its own on create and reads neither.
[backend]
id_family = "PROJ" # ONE number space for the whole backlog:
# PROJ-001, PROJ-002, whatever the type.
# For a backlog adopted with its ids already
# decided — the id states no type, and it does
# not need to: the type is the `type` field.
[backend.id_prefixes] # ONE number space PER PBI type: the repo store
bug = "BUG" # has EPIC/FEAT/STORY built in, and a fourth
# type declared in [backlog] types needs a
# prefix of its own. A type not named here
# keeps its default, so declaring nothing
# mints exactly as it did before.
Declaring both is a named stop rather than a precedence rule nobody can read off
the file. A prefix is never derived from the type's name: bug does not
silently become BUG, because an id is a name people live with for years and
two types opening the same way have no non-arbitrary answer. A declared prefix
is shape-checked at load like id_family, and two types landing on one prefix —
declared or built in — is refused, because that is id_family written the hard
way. Existing ids never move: adding a type renumbers nothing.
[backlog] — this project's PBI type vocabulary. Declaring nothing is the
engine's own three. The hierarchy is [backlog.parents]; the order of types
means nothing, and default says which type a draft naming none becomes:
[backlog]
types = ["epic", "feature", "story", "bug"] # `levels` was the old name (retired)
default = "story" # else the last entry above
parent_required = ["feature"]
[backlog.parents]
epic = []
feature = ["epic"]
story = ["feature", "epic"]
bug = ["feature", "epic"] # a sibling of story, not a rung
A flat store is two lines — types = ["story"] with story = ["story"], which
is what a Todoist subtask is.
[capture] — what a write tool does before it returns:
[capture]
ingest = "background" # or "sync" — whether a write blocks on extraction
recover_attempts = 2 # how many times a failed pass retries itself
recover_delay = 60 # seconds before the first retry, doubling after
episode_window = 0 # preceding episodes inlined into every call
[watch] — unattended passes. enabled = true has the served engine
start the loop; revetly watch also runs one by hand. enabled defaults to
false, so a fresh instance runs nothing unattended until somebody turns it
on:
[watch]
enabled = true # let the served engine start the loop
interval = 300 # seconds between ticks; 0 declares watch off
full_interval = 21600 # a full read regardless, every 6 hours
backoff_max = 3600 # a failing source's ceiling, per source
enabled gates only the loop the served engine starts. A typed revetly watch runs on interval, full_interval and backoff_max whatever it says,
so the page shows those three rows whether the switch is on or off. Neither
loop runs a project's first pass: until a pass is on record, a tick reads
nothing and names the command that prices one.
[extraction] — whether the why is extracted at all, which kinds a model
reads, and what it may name:
[extraction]
statements = false # decline Decision/Assumption/Constraint
concepts = "declared" # the default; "open" lets the extractor name anything
[extraction.tiers] # merged over the engine's defaults, per kind
commit = "index" # the default: written by the engine, read by no model
delivery-record = "index" # the default
revision = "index" # the default
comment = "index" # an instance's own choice for a kind it reads
[extraction.aliases] # canonical name = its other spellings
Acme = ["acme", "acme_platform", "Acme Platform"]
Tiers. An entities-tier artifact is extracted as every artifact was
before: entities, edges, a summary, and — for prose — the three statement
shelves. An index-tier artifact is written by the engine: its whole body on an
episode of its own, searchable by keyword and served by search_context as a
passage; its node anchored and recorded by the deterministic passes; every
declared relation (implements, delivers), every explicit tracker-id mention,
every citation and supersession exactly as before; and no model call at all. It
loses only what a model would have inferred from it. The defaults are the three
kinds whose every link is already deterministic — commits, delivery records and
revisions — which on the corpus this engine was measured against were 63% of provider calls. revetly ingest --dry-run prices the two tiers apart. Moving a kind from index to entities
re-sends exactly the artifacts no model has read; the other direction re-sends
nothing and retires nothing (revetly forget is the gesture that takes an
extraction back). revetly manual show extraction-policy has the whole of it.
Concepts. Under declared every extracted episode carries instructions
rendered from this project — product concepts only; never code identifiers,
paths, shas, versions, git identities, or another artifact's id in passing; the
declared aliases as one entity — and two deterministic passes at the tail of
every ingest make the same rule true of the corpus that already exists: the
alias fold moves every edge of a matching concept node onto the canonical one
and takes the duplicate off the shelf, and the noise pass withdraws names shaped
like a sha, a path, a version or an identifier, reason recorded, node and edges
kept. Neither calls a provider. revetly audit-links --concepts is the census,
before and after. Under open no instructions are passed and the noise pass
does not run; the alias fold runs whenever aliases are declared.
The admission test. Five types ship — two shelves an artifact stands on, and three a statement does — and the count is load-bearing: every one costs on every artifact it is read for, forever. A sixth is argued against these four questions or it does not land —
epic, sprint, release, priority and assignee fail the first. OKRs,
opportunities and experiments fail it too — they are stated by other tools, and
the answer is a connector rather than a type. Decision, Assumption and
Constraint are what is left: the why behind a product, which exists only as
prose and which nobody's tracker has a column for.
And a fifth question the first build had to pay to learn: where does it
land? Offering the three to the entity extractor put propositions into the
same namespace concepts are resolved in, and a decision paragraph absorbed the
ProductBacklogItem node along with seven facts that were not about it. Entity
resolution decides identity by name similarity, and a statement is prose that
names concepts, so it will keep colliding with them. A type that names no
entity does not belong on that path — it is read by a pass of its own and written by
the engine, which is what statements.py is.
[search] — fuzzy matching, recency and what a copy is worth beside its
record. It is its own section rather than three more keys under [extraction]
because nothing here extracts or spends — it only reorders what a pass has
already read:
[search]
fuzzy = true # the default
recency_half_life_days = 180 # the default
[search.provenance_weights] # merged over the engine's own defaults
mirror = 0.5
fuzzy. On by default: a typed word, inside the keyword leg's own bounds
(one edit at five to eight characters, two at nine and over, none shorter),
also matches indexed words that close. The edit is measured against the
index's stems rather than the words, so it is not typo tolerance: a typo in a
word the stemmer shortens (ingestino, landng) still finds nothing. What it
does is rank — a word the stemmer leaves whole matches twice, and its
near-spellings with it. Off ranks real agent queries slightly better and
model-written ones slightly worse, which is why the default stays on. An
exact-id clause, a quoted phrase and a binding topic scope are never fuzzy
either way.
recency_half_life_days. How many days it takes a passages or
artifacts result's ranking weight to halve; 180 by default. It has to be a
positive number of days, 1 to 36500; a zero — the division nobody meant — is
refused naming the key. binding is never aged by it: what already binds
does not decay by being read again.
provenance_weights. How much a hit is worth by where it came from, one
weight per provenance a connector may declare — source-of-record, mirror,
human-authored, ai-derived — each greater than zero and at most one. The
engine's own defaults are source-of-record = 1.0, human-authored = 0.9,
mirror = 0.6 and ai-derived = 0.5 — a source-of-record hit above a mirror
of the same words; a declared table merges over them the way [extraction] tiers does,
so naming one provenance says nothing about the rest. A key outside those
four, or a weight outside the range, is refused naming what was wrong.
revetly manual show mcp-client-surface has what the four signals do to a
search_context answer; revetly manual show extraction-policy has what the
keyword leg itself does.
[sensitivity] — what the pre-capture scan looks for:
[sensitivity]
disabled = ["aws-access-key"] # engine patterns this instance does not run
[sensitivity.patterns]
acme-internal-id = "ACME-[0-9]{6}"
[egress] — what never reaches the LLM provider. Read, committed, in the
ledger, and never sent:
[egress]
withhold_artifacts = ["STORY-000"]
withhold_sources = ["todoist"]
withhold_kinds = ["comment", "todoist:comment"]
withhold_above = "internal"
library_telemetry = false
withhold_above names a classification tier rather than a list,
and it is the only one of the four that reaches an artifact nobody has met: the
other three name ids, sources and kinds an operator has already seen, while a
connector's declared sensitivity governs the ticket that arrived this morning.
Anything a source declares strictly above the named tier never crosses —
"internal" withholds confidential and restricted and sends the rest. The
vocabulary is public, internal, confidential, restricted, least to most
restricted; a value the engine cannot place fails fast, because an unplaceable
tier ranks below public and a typo would silently send exactly what this line
was written to keep back.
library_telemetry is the odd one out and off by default, where the other four
withhold nothing until you name something. It is not about your artifacts at
all: graphiti-core posts an anonymous id, its version and the class names of
the LLM, embedding and driver classes it was handed to its vendor's endpoint on every
graph it constructs, and an instance whose claim is that extraction is the only
sink outside your perimeter cannot ship with a second one on. Setting
GRAPHITI_TELEMETRY_ENABLED in the process environment yourself outranks this
line, the same way SEMAPHORE_LIMIT outranks the concurrency ceiling — reaching
for the library's own name is a decision, not an accident.
The graph beside it is the other half, and it is not a revetly.toml setting
because it is not this engine's process: Neo4j reports its own usage under
dbms.usage_report.enabled, and its browser and drivers under
client.allow_telemetry, both on by default. The compose file revetly init
writes turns both off; a graph you run yourself is yours to set, on the graph
service, and takes effect on a recreate:
environment:
- NEO4J_dbms_usage__report_enabled=false
- NEO4J_client_allow__telemetry=false
revetly doctor reports both — the library's ping and, by asking the running
server rather than reading your compose file, the graph's.
[backfill] — the only pair that bounds a backfill. Both default to
0, which is undeclared, and --run refuses rather than reading that as no
limit:
[backfill]
ceiling_episodes = 500
ceiling_spend = 25.0
[pricing.models] — what this instance actually pays. Per million tokens,
matched by prefix, longest first; what is declared here outranks the engine's
own table:
[pricing.models."gpt-4.1-mini"]
input = 0.40
cached_input = 0.10
output = 1.60
[pricing.models."text-embedding-3-small"]
input = 0.02
[update] and [updater] — updating this instance in place. Declaring
[updater] at all is what turns the endpoint on, because what it turns on is a
container holding a Docker socket:
[update]
source = "https://downloads.example.com/revetly/"
[updater]
port = 9713
expose = "loopback" # or "all"
swap_timeout = 120
[engine] — where the engine source is, for the one instance whose root is
not the engine's own checkout. Undeclared means src/revetly under the root,
which is what every instance has had; declaring it keeps the stale-image check
alive for an instance that sits beside the engine repo rather than inside it:
[engine]
source = "../revetly-engine/src/revetly"
Under the container transport that directory has to be mounted into the engine
service at the path it resolves to, or revetly doctor warns that the
comparison is not being made and names where it looked.
[auth] — who a served endpoint answers. Read by revetly serve --http
and by nothing else. An instance that serves stdio, or loopback behind a
tunnel, declares none of this; anything else refuses to start without it:
[auth]
mode = "oidc" # or "static": declared tokens only
issuer = "https://auth.example.com" # who signs the tokens
resource = "https://revetly.example.com/mcp" # what this endpoint calls itself
audience = "revetly-engine" # what a token's `aud` must carry
groups_claim = "groups" # the claim a grant matches on
verifier = "introspection" # how a presented token is checked
scopes = ["revetly.read"] # optional
[auth.introspection]
endpoint = "https://auth.example.com/oauth2/introspect"
client_id = "revetly-engine"
client_secret_env = "REVETLY_AUTH_CLIENT_SECRET" # the *name*, never the value
Two verifiers ship, and an instance picks by what it will spend per request.
introspection asks the issuer about every token — nothing cached, so a
withdrawal takes effect on the next call. jwt checks the signature locally
against the keys the issuer publishes:
[auth]
issuer = "https://auth.example.com"
resource = "https://revetly.example.com/mcp"
verifier = "jwt"
[auth.jwt]
# every key optional — discovery finds the key set, and the rest are the
# defaults spelled out
jwks_uri = "https://auth.example.com/keys" # only where discovery names none
algorithms = ["RS256"] # asymmetric only, always
key_ttl = 600 # how long a fetched key set stands
leeway = 60 # clock skew tolerated on exp
No network call per request, and a revocation that waits for the token to expire — which is the trade, and why an instance choosing it keeps access tokens short. A key set that cannot be refreshed keeps serving what it already holds, because somebody else's outage is not this engine's refusal; the engine says which of those two states it is in rather than answering identically from both.
Who a served call is attributed to follows from that and needs no config.
The verified claims bind to the request, so REVETLY_REVIEWER is
read only where there is no bound caller — the stdio path — and never on a
request path. Every served call also appends one line to
.revetly/audit.jsonl naming subject, issuer, tool, project and outcome:
instance-local, gitignored, never pushed, and never committed, because an audit
trail that inflates the records repository is one an operator turns off.
And every record says which of the two it was. A served record
and a stdio record are otherwise indistinguishable once written: both carry a
name, one checked against an issuer's published keys and one read out of
REVETLY_REVIEWER, which whoever runs the server can set to anything. So each
one carries attribution — verified, with attribution_issuer naming who
checked, or self-asserted — recorded where identity is resolved rather than at
each writer, and served beside provenance and authority.
This is a label and not a fix, deliberately: an engine cannot make the machine
it runs on unforgeable, and refusing to write on the stdio path would break the
single-operator case. The stdio write lands, marked. A record written before
this existed reads unknown, never self-asserted — an absent stamp is not
evidence of a weak one.
audience and groups_claim are two of the three lines that change when this
instance is repointed at another issuer, and issuer is the
third. audience is what a token's aud must actually carry, which is not
always the resource identifier — most authorization servers stamp an audience
their own configuration names, and that is a client id at least as often as it
is a URL. Left out, it is the resource, which is what every declaration
written before this key existed meant. groups_claim (default groups)
names the claim a grant matches on; what an issuer calls it is the issuer's
business, and there is no issuer-specific branch anywhere in the engine to
follow it.
[auth.grants.<claim value>] — what a caller may reach. The identity
provider owns who somebody is; this instance owns what they may read, and the
permitted set for a call is the intersection of the two. A grant can only
narrow: it cannot open a namespace the project never exported.
[auth.grants.engineering] # a value of the groups claim
role = "developer" # the lens — what a role may see, declared elsewhere
projects = ["platform"] # the namespaces it reaches
Exactly two keys, and anything else in the table is refused by name. A grant
naming a project this instance does not declare is revetly doctor's finding
rather than a load error — one mistyped project name must not stop ingest,
capture and every read, none of which authenticate anybody — and so is the other
direction, a declared project no grant reaches, which answers a served read
exactly the way an empty corpus does.
projects names namespaces in the vocabulary imports uses — a project or a
shared namespace — and nothing is inherited: a grant naming
platform reaches a shared namespace platform imports only if it names that
namespace too. Declaring the table at all is a decision about everybody:
a caller no grant names reaches nothing, a static token declaring no grant
included, while an instance with no table narrows nobody. What the intersection
does at each read surface is multi-project.md.
[auth.tokens.<name>] — a token this instance declares itself. The escape
hatch and the only credential here that authenticates a machine: no browser, no
issuer, and it works when the authorization server is down.
[auth.tokens.ci]
hash = "sha256:<hex>" # `printf %s "$TOKEN" | shasum -a 256`
expires = "2027-01-31" # compulsory; a bare date is the whole of that day, UTC
grant = "ci" # names an [auth.grants] entry
The keys that would hold the token itself — token, value, secret — are
refused by name, because this file is committed. expires is compulsory: a
token with none outlives whoever minted it, and this engine holds no revocation
list to make up for that. A declared token is checked by the static verifier
before the issuer's, since it costs a hash rather than a signature; mode = "static" accepts nothing else and needs no issuer at all. Whatever presents one
is attributed as a machine — attribution_actor: machine beside the trust
label, so a delivery a CI job recorded never reads as one a person decided.
revetly doctor covers the whole of it. Discovery reachable, keys fetched,
the audience it would pin, clock skew where the issuer sends a Date header,
every grant against every declared project in both directions, and whether the
issuer's RFC 7591 registration endpoint accepts an unauthenticated request —
reported as a finding with its remedy and never as a failure, since a hosted
chat client cannot connect without dynamic registration at all.
revetly doctor --token <bearer> answers the one question none of those can:
whether this token would get in, and as whom. The token is checked and never
printed.
Which authorization server issues those tokens is not the engine's, and none is
scaffolded into an instance: revetly init --auth writes this declaration and
nothing that runs one. Any conformant OIDC issuer satisfies it, and
revetly init --issuer-kit <path> writes a runnable one outside the instance
for an operator who has none. revetly manual show serving-people is the whole
surface — the contract an issuer must satisfy,
including the revocation window a short access token implies and what a
cloud-hosted chat client needs that a local one does not. What a client does
with the discovery document this publishes is
mcp-client-surface.md.
[projects.<key>], [shared.<key>] and [instance] — more than one project
in one instance. The whole surface is multi-project.md;
the shape is below.
A connection is established once, and scope is per project. A project may
configure any connector entirely alone — the token variable, the host and the
board in its own [projects.<key>.connectors.<name>] — and nothing at instance
level is required for anything. Where the instance does hold a table for that
connector, a project that states none of the connector's declared connection
keys takes exactly those from it and no others; one that states any of them
uses its own connection whole. Everything else in an instance-level table is
scope, is inherited only by a project that restates nothing at all, and fails
revetly doctor for each such project on an instance declaring two or more:
alpha: instance scope [connectors.todoist] names what to read — account, project — and alpha declares no [projects.alpha.connectors.todoist] of its own, so it reads them. Which keys are the
connection is the connector's to declare and never the engine's to infer —
SettingsDeclaration.connection, in
writing-a-connector.md §4b. On an instance declaring
one project none of this applies: its tables are that project's own.
One project. Everything an ordinary instance needs, and nothing else:
# revetly.toml — one project, its own repo, writing to its own files.
[project]
group_id = "acme-platform"
[routing]
pbi = "sources/backlog"
conversation = "sources/conversations"
delivery = "sources/deliveries"
[docs]
roots = ["docs"]
[connectors]
enabled = ["pm", "docs", "conversations", "deliveries"]
[backend]
pbi = "repo" # approved drafts land as files in this repo
[capture]
ingest = "background" # a write tool returns before extraction ends
[watch]
interval = 300 # `revetly watch` reads changed sources every 5m
Several projects and a shared namespace. Each project is its own namespace, its own root, its own ledger and its own bill. Nothing crosses unless both sides declare it, and the shared namespace is a third namespace rather than a merge of two:
# revetly.toml — an agency instance: two clients and one place for the
# decisions that are true for both.
[instance]
default_project = "platform" # required once there is more than one
[project]
group_id = "acme-platform" # the default project's own namespace
[routing]
pbi = "sources/backlog"
conversation = "sources/conversations"
delivery = "sources/deliveries"
[connectors]
enabled = ["pm", "docs", "conversations", "deliveries"]
[backend]
pbi = "repo"
[projects.platform]
group_id = "acme-platform"
root = "." # this repo
exports = ["decisions"] # may write into the shared namespace
[projects.beta]
group_id = "acme-beta" # never inherited, never shared
root = "../beta-client"
imports = ["decisions"] # may read the shared namespace
[projects.beta.connectors]
enabled = ["pm", "docs", "todoist"]
[projects.beta.connectors.todoist]
# A re-stated settings table REPLACES the instance's whole table — every key
# this one does not name is no longer in force for `beta`.
account = "beta-client"
token_env = "BETA_TODOIST_TOKEN"
project = "Delivery"
[projects.beta.backend]
pbi = "todoist" # beta's approved drafts land on beta's board
[projects.beta.routes]
conversations = "decisions" # this source's notes land in the shared namespace
[shared.decisions]
group_id = "acme-decisions" # its own namespace, distinct from both projects
exports = ["platform", "beta"] # who may read it
[projects.legal]
group_id = "acme-legal"
root = "../legal"
isolated = true # both directions closed; naming it elsewhere
# is a config load error
revetly doctor --all reports what each project resolved to after inheritance,
including the keys a re-stated table dropped, before anything bills.
Bind-mounted instance repo including .git. The MCP write tools
(submit_draft/submit_revision, set_status,
record_conversation)
autocommit source files to the instance repo via the git subprocess. The
engine therefore requires the instance root mounted at /project to be a
real git worktree, not loose sub-dirs. This is not removed — it is an explicit,
declared requirement of any deployment target:
capture.git_commit_paths), and REVETLY_AUTOCOMMIT=0 disables the behaviour
entirely for targets that manage source persistence another way./project as a
git safe.directory and sets a default committer identity; both are
overridable per instance (see identity rows above).Standing up the managed runtime (Cloud Run/Fly), the Workers control plane, secret custody, and multi-tenant orchestration. This story only guarantees the engine is ready to be dropped into either target unchanged.