Who this is for: whoever runs an instance somewhere they do not log into,
and whoever is building the UI that drives it. You need: an instance whose
revetly.toml declares [updater] — declaring the section is what turns the
endpoint on, because what it turns on is a container holding a Docker socket.
Not here: what an update does to the data
(failure-and-staleness.md covers the corpus
revision) and the keys themselves
(engine-config-surface.md).
./revetly-update moves an instance to a later engine in one command. It
assumes a person standing at a shell on the machine Docker runs on.
Some recipients are not that person. They run the stack somewhere they do not log into and drive it from a UI they built themselves. For them the script is not a smaller gesture than a button — it is unreachable, and the instance quietly stops receiving engine updates.
The update endpoint is the seam that UI needs. It runs on your own infrastructure, against your own instance, and it reports to nobody.
A third service in the same compose stack:
| service | what it is | Docker socket |
|---|---|---|
neo4j |
the graph | no |
revetly |
the engine — stdio MCP, ingest, everything | no |
revetly-updater |
the update endpoint | yes |
The engine service is untouched by any of this. The swap cannot be done by the process being swapped, and the engine must never hold a socket — both are satisfied by a service of its own, running the same engine image under its own command.
The same image rather than a second one: this distribution has no registry, so a
second image would be a second tarball in every release, a second digest in the
release statement and a second copy of the release reader drifting from the
engine's. Under one image the endpoint calls the same plan() the host script
calls. The cost is that Graphiti and the provider SDK sit on disk in the
privileged container. They never execute there, it holds no key, and no artifact
passes through it: everything it reads inside your instance is revetly.toml,
the compose file, the corpus stamp under .revetly/, and its own job records.
Absent unless you ask for it — nobody gets a Docker socket in their compose file by default:
docker compose exec revetly revetly init --transport container --updater
A re-run of init is also how an instance that already exists gains it. That
run writes three files and never overwrites a fourth:
[updater] in revetly.toml — the port, and whether it is published on this
host only;revetly-updater service in the compose file, if that file is one
Revetly wrote. If it is yours, the block is printed for you to paste: a
Docker socket is not something to add to somebody's compose on their behalf;.revetly/updater-token — the bearer token, git-ignored with the rest of
.revetly/. The instance's .revetly/, beside revetly.toml and the
compose file: an update swaps the engine every project on this instance runs
on, so its credential and its job records are not one project's. On an
instance declaring several projects, each project keeps its own
ledger, health record, backfill checkpoint and lock under its own root, and
none of those move.Then docker compose up -d, and the endpoint answers.
Two surfaces answer it, and they answer different halves — because the engine holds no Docker access and never gains any, not even to run a check.
revetly doctor reports one update endpoint line on every
container-transport instance, and it is a line whether or not you run one: an
instance with no [updater] gets a skip naming the reason, so a reader scanning
for the endpoint learns it is off rather than learning nothing.
What it can see, and does:
| it warns when | because |
|---|---|
| the token is absent or empty | the endpoint refuses to start without one, so the service is declared and nothing is listening. It names the command that writes it and does not write it — a check that creates what it was asked to verify has verified nothing |
[updater] is declared and the compose declares no revetly-updater service, or the reverse |
the two are written together, and a drifted pair points a UI at an endpoint that does not exist |
the published port or its exposure disagrees with [updater] |
the compose file is what the daemon acts on, and a hand edit moves one without the other |
| the engine builds from source, or no update source is declared | the instance is sound and the endpoint will answer — and every update it is asked for will be refused |
None of that fails the command. A doctor failure means an ingest would fail,
and none of these stop a pass, a capture or a review: the instance works and
cannot be updated. The token's presence and file mode are reported and its value
never is, so the whole line can be pasted into an issue. The restart policy on
the updater service stays on doctor's restart line, which already reports it.
A healthy instance reads:
[PASS] update endpoint token present (mode 0600); published on 127.0.0.1:9713
(this host only, declared and not confirmed) by docker-compose.yml; the
engine is pinned to revetly:0.2.1 and updates come from
https://example.com/handout. What the file asks for: docker-compose.yml
publishes 127.0.0.1:9713:9713. Nothing here confirms what is published:
this port is published by another container in this stack, and a process
can ask the daemon only what is published for its own, and a compose
override, a `-f`, a `COMPOSE_FILE` or a plain `docker run -p` moves the
mapping without changing the file this read. what only the socket can
answer — the daemon, both containers, whether it is listening where
docker-compose.yml publishes it — is on `GET /status`
The reach in brackets is the compose file's, said as the compose file's. Doctor
runs in the engine container, which holds no Docker socket, and this endpoint
runs in one of its own — so the one read available to the engine is of its own
container and answers nothing about this port. An override file here publishing
9713 on another address is a warning of its own, on a service that holds a
Docker socket: docker compose merges it without being asked, so which of the
two the stack came up on is not visible from in here.
GET /status answers that other half, from the process that holds the
socket, under setup:
"setup": {
"ok": true,
"docker": true,
"containers": {
"engine": { "name": "revetly-engine-acme", "exists": true },
"updater": { "name": "revetly-updater-acme", "exists": true }
},
"listening": {
"port": 9713, "bind": "0.0.0.0",
"published": [{ "container_port": 9713, "host_ip": "127.0.0.1", "host_port": 9713 }],
"matches": true, "reachable_from": "this host only"
},
"engine_running": true,
"findings": []
}
ok is false and findings says what, in sentences: the daemon unreachable,
a named container that is not on this daemon, or an endpoint listening on a port
the daemon forwards nothing to. listening.port is what this process actually
bound, not what was declared — a port already spent by something else is a
service that never came up. listening.published and reachable_from are read
off the daemon's own bindings for this container, which is why they are here
rather than on the doctor line above: this is the process that can ask.
reachable_from is one of the same two phrases the doctor line prints, this host only or every interface on this host, so the line and the answer it
sends you to name one fact once. What the line adds and this does not is the
qualification: the line read a file, and this asked the daemon.
Every call needs the token:
TOKEN=$(cat .revetly/updater-token)
curl -sH "Authorization: Bearer $TOKEN" http://127.0.0.1:9713/status
GET /statusWhat this instance is running, what is published, and what an update would mean. No provider call, and nothing is changed:
POST /updateReturns immediately with a job handle. The body may name a version, and nothing else:
curl -sX POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"version": "0.2.0"}' \
http://127.0.0.1:9713/update
| key | meaning |
|---|---|
version |
install this release, newer or older. Absent: whichever release the source currently names, and only if it is newer. |
migrate_ok |
consent to the corpus migration this release needs. Without it, an update that would need one is refused and nothing is installed. |
A request may never name a source, a URL or an image, and a key this endpoint does not read is refused by name rather than ignored. That is the whole security argument: the release source is read from your configuration, and every tarball is verified against the digest in the release statement before it is loaded. With both, a stolen token buys a version change from a folder you already trust. Without either, it buys the host.
GET /jobs, GET /jobs/<id>The record of an update, phase by phase. It lives on the bind mount at
.revetly/updates/, so it survives the swap — including the last phase, which
replaces the endpoint that would have been reporting it. GET /jobs is
{"jobs": [...]}, newest first.
POST /config/apply, POST /config/restore, GET /config/statusThe configuration page's restart, and the way back from it. They live here
because this service holds the Docker socket and the engine does not, and
because a rollback offered by the engine is no rollback when the engine is what
failed to come back. Each takes the same token; a browser may read the answer
only from the page's own origin, matched on the port [config] declares.
POST /config/apply recreates the engine container on the saved
revetly.toml and .env, and answers "status": "restarting". The page keeps the previous revetly.toml
and .env aside when it first saves after the engine started.POST /config/restore puts that kept configuration back and recreates the
engine on it, answering "status": "restored" with the files under moved.
Where nothing was kept aside it answers the same status, an empty moved and
nothing was kept aside to put back, so nothing was changed, and makes no
call to the container runtime: the engine is not stopped or recreated.GET /config/status is what the daemon says about the engine container
while it is being recreated: state, running, restarts, exit_code and
started_at.Both writes carry page, which says whether the configuration page comes back
with the engine: supervised where the compose file runs revetly supervise,
not needed where nothing was recreated.
Two shapes, and a code per outcome. Both are JSON; both are what the engine actually emits rather than a summary of it.
{
"instance": "acme",
"updater": { "version": "0.2.1", "container": "revetly-updater-acme", "docker": true },
"engine": {
"version": "0.2.1",
"container": "revetly-engine-acme",
"running": true,
"ingest_pass_running": false,
"ingest_pass_owner": "",
"note": ""
},
"corpus": { "revision": 1, "stamped_by": "0.2.1", "where": "manifest" },
"setup": { "ok": true, "docker": true, "containers": {}, "listening": {}, "findings": [] },
"source": "https://example.com/handout",
"compose_file": "docker-compose.yml",
"pinned_image": "revetly:0.2.1",
"job": null,
"available": {
"version": "0.3.0",
"image": "revetly:0.3.0",
"direction": "newer",
"uptodate": false,
"filename": "revetly-engine-0.3.0-linux-arm64.tar.gz",
"commit": "0123456789abcdef0123456789abcdef01234567",
"released": "2026-08-26",
"notes": "https://example.com/handout/0.3.0/CHANGELOG.md",
"sha256": "e3b98a4d…"
},
"migration": {
"needed": true,
"verdict": "migration",
"detail": "0.3.0 reads corpus revision 2 and up; your data is revision 1. …",
"from": 1, "to": 2,
"price": {
"known": true,
"artifacts": 298, "model": "gpt-4.1-mini",
"upload": "64.0MB", "calls": 10430, "duration": "12 min-38 min",
"note": "an upper bound: every artifact in this corpus. …"
},
"consent": "POST /update with {\"migrate_ok\": true} — …"
},
"can_update": true,
"rollback": { "available": false, "why": "no update has been made from here, …" }
}
| field | what to read off it |
|---|---|
updater.docker |
whether this service can reach the daemon at all. false means every verb below will fail. |
engine.version |
asked of the engine container over an exec, not assumed from this image. engine.running: false is not an error — a stopped engine runs no pass. |
engine.ingest_pass_running |
a POST /update right now would be refused. ingest_pass_owner names who holds it. |
engine.note |
why the two fields above are unknown, when they are. Empty when the engine answered. |
corpus.where |
manifest, record, or baseline when nothing has recorded one. baseline is revision 1, not "unknown". |
setup |
whether this endpoint is set up, as only the socket can say it. See Whether it is set up. |
available |
absent (null) when the stack cannot be updated from here at all — read detail, and see below. |
available.direction |
newer, older, same, or unknown. Anything but newer needs version named in the request. |
migration.verdict |
ok, migration, downgrade, unknown. downgrade has no override. |
migration.price |
present only when needed is true, because computing it reads every source. known: false carries a why. The numbers are a ceiling, not the bill — see the note in the object. |
can_update |
false when already current, or when the stack is one this path cannot move. |
job |
the running job's whole record, or null. |
rollback |
taken from the last completed job. See Rollback. |
When the stack cannot be updated from here, available is null, can_update
is false, and detail says which of the two it is — no source declared, or an
engine built from source. Nothing else in the object changes shape.
{
"job_id": "UPD-20260814125208-0e4a94",
"created_at": "2026-08-14T12:52:08+00:00",
"updated_at": "2026-08-14T12:52:50+00:00",
"status": "done",
"phase": "done",
"requested": { "version": "", "migrate_ok": false },
"plan": { "current": "0.2.1", "version": "0.2.2", "image": "revetly:086b", "url": "…", "sha256": "…", "corpus_verdict": "ok", "…": "…" },
"trail": [
{ "phase": "planning", "at": "…", "note": "reading the release statement at …" },
{ "phase": "checking", "at": "…", "note": "asking whether this release can read your data" },
{ "phase": "downloading", "at": "…", "note": "https://…/revetly-engine-0.2.2-linux-arm64.tar.gz" },
{ "phase": "verifying", "at": "…", "note": "sha256 against the published digest" },
{ "phase": "loading", "at": "…", "note": "loading revetly:086b into the daemon" },
{ "phase": "pinning", "at": "…", "note": "repointing docker-compose.yml at revetly:086b" },
{ "phase": "recreating-engine", "at": "…", "note": "recreating revetly-engine-acme on revetly:086b" },
{ "phase": "replacing-updater", "at": "…", "note": "starting a one-shot swap container …" },
{ "phase": "done", "at": "…", "note": "Revetly 0.2.1 -> 0.2.2. …" }
],
"detail": "Revetly 0.2.1 -> 0.2.2. The engine, and this update service, are both on revetly:086b …",
"from_version": "0.2.1",
"to_version": "0.2.2",
"image": "revetly:086b",
"migration": { "needed": false, "consented": false, "detail": "…", "ran": false },
"rollback": { "available": true, "to": "0.2.1", "how": "POST /update with {\"version\": \"0.2.1\"}", "why": "…" },
"updater": { "container": "revetly-updater-acme", "superseded_as": "revetly-updater-acme-superseded-UPD-…", "payload": {} }
}
status is one of five, and only the first is not final:
status |
means |
|---|---|
running |
in flight. phase says where. |
done |
finished. detail is the sentence to show. |
refused |
stopped on purpose, and the instance is where it was. detail says why. |
failed |
stopped on something unexpected. detail carries the exception. |
stalled |
it has sat in one phase past the window this instance declares, and nothing is coming back to it. Derived when the job is read, never written — see When a swap does not finish. |
phase is one of queued, planning, checking, downloading, verifying,
loading, pinning, recreating-engine, migrating, replacing-updater,
done, failed, refused — in the order above.
trail is every phase this job has entered, appended, never rewritten.
migration.price on a job is the new engine's own dry run, verbatim — the exact
figure, unlike the ceiling on /status. migration.output is what the applied
migration printed. updater.payload is the create call that restores this
container on the new image; it is in the record so a swap that goes wrong is
recoverable by hand.
Polling: the endpoint is unreachable for a few seconds during
replacing-updater, because that phase replaces the container serving you. Treat
a connection error there as "still working" rather than a failure, and read the
job again — the record is on the bind mount and the new endpoint finishes it. A
few seconds; not indefinitely. See below for how the two are told apart.
replacing-updater is the one phase where nothing else is standing. The swap
container renames and stops the old endpoint, then creates and starts the new
one. Between those two moves there is no update endpoint on this instance, and
the connection error your UI gets is the same connection error a swap that is
working normally produces.
Three differences separate them, and none of them needs a shell.
The swap waits for the endpoint it installed. Starting a container is not the
same as it staying up, so the swap container does not record success at start.
It waits for the replacement to close the job itself — which it can only do from
inside its own boot, after the port is bound and the record has been read. A
replacement that exits, or that never binds, is not a swap that worked.
Past the window, the endpoint that was superseded goes back. The old
container is renamed rather than removed for exactly this. Where the replacement
does not answer within swap_timeout, the swap puts it back and starts it, and
the job ends failed saying so: this instance keeps an update endpoint one
version behind the engine, rather than none at all. Nothing is re-downloaded and
nothing is re-attempted — the only container ever put back is the one that was
already there.
Every stop is read off the daemon's own answer. A 304 means the container was
already stopped; any error the daemon answers to a stop stops the job as an
error, whatever digits its text holds, and an error body that is not a JSON
object is reported with its status and the body as it came.
A job nobody is coming back to reads as stalled. Where the swap container
itself dies mid-move there is no process left to record anything, so the stall is
derived when the job is read, from the record and the declared window:
"status": "stalled",
"recorded_status": "running",
"stalled": {
"phase": "replacing-updater",
"since": "2026-08-14T12:52:50+00:00",
"seconds": 431,
"window": 120.0,
"why": "this job has been in replacing-updater for 431s, past the 120s window this instance declares. …",
"recovery": {
"record": ".revetly/updates/UPD-20260814125208-0e4a94.json",
"create_call": ".revetly/updates/UPD-20260814125208-0e4a94.json -> updater.payload",
"container": "revetly-updater-acme",
"superseded_as": "revetly-updater-acme-superseded-UPD-20260814125208-0e4a94",
"steps": ["docker start revetly-updater-acme-superseded-UPD-…", "…"]
}
}
recovery.steps are the commands, in the order to try them: start the container
this swap superseded, or bring the stack up on the pin the job already moved, or
replay the exact create call the record has been carrying since before the swap.
They are a description. Nothing in this engine runs them.
The window is yours:
[updater]
swap_timeout = 120
It governs the phases that are daemon calls and a container boot — queued,
planning, checking, pinning, recreating-engine, replacing-updater. The
phases that wait on bytes or on a provider — downloading, verifying,
loading, migrating — are deliberately outside it, because a window over those
would call a slow download a stall. A job that dies inside one of those is
caught another way: the updater that comes back finds a job still running that no
process is running, and records it rather than leaving it reading as work in
flight.
When the endpoint is what died, the endpoint cannot be what says so. The records are on the bind mount, and the engine reads them — with no Docker access, which it does not have and does not gain here:
docker compose exec revetly revetly update jobs # the last one, --all for every one
docker compose exec revetly revetly doctor # an `update job` line, every run
revetly update jobs prints the recovery under a stalled job. revetly doctor
carries one update job line: skipped where no update has been driven from here,
a pass naming the last one, and a warning where the last one failed or where one
is stalled — with the first recovery step and the path to the record. It warns
and never fails, for the reason the endpoint check does: the instance works, the
graph is fine, and what is broken is the ability to update it.
| code | when |
|---|---|
200 |
a read answered |
202 |
POST /update accepted; the body carries job_id and job |
400 |
the body is not JSON, or names a key this endpoint does not read |
401 |
no token, or the wrong one |
404 |
no such route, or no such job |
409 |
refused, and nothing was changed: a pass is running, a migration needs consent, the release would downgrade the corpus, the release is older and was not named, the compose file is not one Revetly wrote, no source is declared, the stack builds from source, or an update is already running |
500 |
the migration failed, or a container could not be recreated. The detail says what state that leaves |
502 |
the download failed, or the digest did not match. Nothing was installed |
A 409 and a refused job say the same at two moments: the endpoint
refuses what it can see synchronously, and the job refuses what only turns up
once it has read the release. Both leave the instance untouched.
Everything that can refuse happens before anything is downloaded; everything reversible happens before the pin moves.
docker load and not after.image: line of the engine service, and of the updater
service. Nothing else in the file.Because the component that ships with version N installs N+1, an instance several versions behind is carried forward one release at a time with nobody touching the host.
Offered where no migration ran: every release stays where it was published, so installing the earlier one by name is the rollback.
curl -sX POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"version": "0.2.0"}' \
http://127.0.0.1:9713/update
Stated as unavailable where one did. A migration moves your data into the shape
the newer engine writes; installing the older one again would not undo that, it
would leave an engine that refuses to read its own corpus. GET /status says
which of the two you are in.
GET /status and refused by POST /update, rather than
discovered halfway through.[updater] expose decides, and its default is loopback: the compose file
publishes the port on this host's loopback interface only. Reaching it from
another host is a tunnel you make, or expose = "all" with the compose port line
changed to match — a deliberate choice about a service holding a Docker
socket.
[updater]
port = 9713
expose = "loopback"
swap_timeout = 120
The token is the only credential. It is minted once at init and kept, so a
restart never invalidates the one your UI is holding. Rotate it by deleting
.revetly/updater-token and re-running init --updater.
Revetly's own hosted shell — auth, billing, licence issuance, MCP-over-HTTP routing — owns the managed tier, and this is not it. This is the seam a self-hosted recipient's own UI needs against their own instance, on their own infrastructure, with Revetly seeing nothing. It is also, later, what a control plane would drive rather than reinventing.