One flow that configures an instance, at /api/v1/setup and — in the console —
the setup wizard (frontend/src/views/setup/).
Everything an operator has to decide to get a spun-up harness running used to live in four places that never met:
| Decision | Where it lived |
|---|---|
Host settings (bind, auth_mode, brain_mode, credentials, [workspace]) |
a hand-edited config.toml |
| Which company template to start from | a serve --company <dir> flag, or the desktop's hardcoded DEFAULT_PRESET_ID |
| Per-company settings (inference, MCP, team, connections) | six Settings sub-pages |
| Whether any of it had happened | nowhere |
A serve host with no --company booted an empty registry, and the console
dead-ended on "No companies are running on this host." That is the hole this
fills. It is the static-template half of
guided company blueprints;
the conversational Architect interview in agentic/setup.md
is separate and still to come.
GET /api/v1/setup returns everything the wizard needs to draw itself: each
configurable field with the layer that owns it, the shipped template
catalog, the sign-in modes this host accepts, which optional surfaces are
compiled into the build, and — in mail — what this host can do with a mailbox.
mail is two booleans, wired and echoes_code, computed from the very
predicates the login route branches on rather than re-read from the environment
here. It answers a different question from auth_modes: that list says which
modes are legal, and email stays on it whatever mail this host has, because
hub OAuth and passwords sign people in without a transport. mail says which
sign-in the wizard can honestly offer today — mailed on wired, handed back
as a link on echoes_code, and neither on a routable host that has configured
no transport.
That last case is why it is reported at all. The console used to infer mail from
whether the sign-in request echoed a code back, and a code is only ever echoed
on a loopback bind — so a routable host with no transport finished setup by
telling its operator to check an inbox that would stay empty forever. The echoed
code still sources the link it hands over; it no longer decides whether one
was sent. The sign-in step says the same thing before the choice is made, and
/auth/config carries it to the login screen as magicLink, which is the only
place a returning visitor could ever be told: auth/request answers sent: true on a host that delivered nothing exactly as on one that did.
POST /api/v1/setup applies a completed wizard: writes config.toml, seeds the
chosen template when the registry is empty, and stamps setup_completed_at.
Validation happens before anything is written — a partial apply is worse than a
refused one, because nothing tells the operator which half landed.
The Business step renders that catalog as a dropdown. The selected preset is sent with the optional details to the roster pass, so matching is anchored to a real shipped company type instead of inferred from a free-text keyword alone.
GET /spec additionally carries setup_complete. It is reported on that
unauthenticated handshake because an instance nobody has configured has nobody
who can sign in; gating the answer behind auth would make the wizard
unreachable exactly when it is needed. The boolean is the whole disclosure — the
configuration itself stays behind /api/v1/setup.
Six steps: model, business, sign-in, you, advanced, review.
Model is first because its failure is silent everywhere else — the design pass falls back to a curated team on a missing or bad credential, so an untested key produces a plausible company and the operator finds out several screens later, if at all. It is a gate, not a wall: skipping is a first-class answer.
For a local OpenAI-compatible provider, setup accepts the address local model
apps normally display (localhost:6969), normalizes it to
http://localhost:6969/v1, reads /models, and probes a concrete model id.
That provider, normalized endpoint, model mapping, and optional write-only key
are then persisted on the company the wizard creates. The roster-design pass
uses the same tested provider before the company exists; a green test is not a
temporary connection that setup forgets at Finish.
Sign-in comes before the address, and that is the point of it having a step. It used to be the first card inside Advanced — one screen after the wizard asked for an email address, under copy inviting the operator to press straight past the lot — so someone setting up on a laptop was asked for an address they need never have supplied, by a flow that already knew it might not want one. A question with a consequence does not sit behind "press on if none of it matters to you"; what stays in Advanced is settings that already work.
A skipped step gets no slot in the bar. Choosing none removes the address
step outright rather than making it optional, and the progress bar renumbers
with it — a five-step flow that says "step 3 of 6" is counting a screen that
will never arrive. The console holds its position as a step id rather than an
index for the same reason: a list that changes length behind the operator would
otherwise silently mean a different screen.
Resolution is env ⟵ config.toml ⟵ manifest ⟵ default
(config.md), and this flow can only write the second layer. A
hosted tenant has OPENCOMPANY_BIND, OPENCOMPANY_DATA_DIR and friends
injected by the control plane, so a wizard that accepted an edit to bind there
would write a file, report success, and change nothing at the next boot.
So each field reports its ConfigLayer and an editable flag, the console
renders an env-owned field read-only with the reason, and the route refuses
a write to one (409) rather than pretending. Silently ignored configuration is
the failure mode this surface exists to prevent.
The layer attribution is not computed here: it is the same ConfigProvenance
that opencompany doctor already reports (src/app/doctor.rs).
Host-level fields are read once, at boot — bind binds a socket, [workspace]
decides the data-dir lifecycle. Writing those is a staged change, and each says
so through requires_restart.
auth_mode is deliberately not in that category, though it is also resolved
at build and cached on the runtime (auth-modes.md). Choosing a
sign-in mode and then being shown a sign-in form is the most confusing thing this
flow could do, and "restart the host yourself" is not an answer on a first run.
So the apply makes the mode live on AppState before it builds anything — the
seeded company is built with it — and then rebuilds any company that was already
registered, through runtime::rebuild_company. AppState::auth_mode_override
exists for exactly this: AppConfig's field is the value boot resolved and can
never change, and a rebuild reading that frozen value would re-apply the mode the
process started with.
A host with no RuntimeRebuilder wired is the only case that still needs a
restart for it, and it is reported per company rather than assumed either way.
restart_required in the response names what is genuinely still pending — the
console shows that answer rather than its own prediction, and its button is
labelled "Open the console anyway" when something is outstanding, because
that button re-enters the console and cannot restart a process.
Where a restart genuinely is needed, it has to actually work. serve therefore
adopts the companies its data root already holds
(desktop::adopt_companies). Before, only --company put a company in the
registry, so an operator who finished setup, was told to restart, and did, came
back to an empty host with their company sitting unread on disk. Adopting is not
seeding: an empty root still starts empty. That registration path also applies
the host-wide auth_mode override, which it previously ignored.
Per-company settings (inference, MCP servers, team) are not written here at all —
they go through the existing ops routes, which apply live.
app::config::write_config_toml merges through toml_edit rather than
serializing a ConfigFile. The shipped file carries commented
[[default_mcp_server]] documentation that an operator is meant to read and
uncomment, and a struct round-trip would delete it along with every other
comment and any key ordering. Only the named keys are touched.
The write is atomic — rendered to config.toml.tmp in the same directory, then
renamed — so a crash mid-write cannot leave a config the next boot refuses to
parse. A malformed existing file is a hard error, matching ConfigFile::load:
merging into a document that could not be parsed would overwrite whatever the
operator actually had.
Clearing a field removes the key rather than writing "". An absent key
falls through to the next layer; a blank string is a set-but-empty value that
shadows it.
POST /api/v1/setup requires an application/json body: axum's Json
extractor rejects anything else, so a plain HTML form cannot submit to this
route the way a CSRF attack against a cookie-authenticated endpoint normally
would. A cross-origin page can still ask the browser to send JSON, but the
browser sends a CORS preflight first, and this host only answers one for an
origin on the exact OPENCOMPANY_CORS_ORIGINS allowlist — unset, the default,
answers none. That allowlist is this route's actual anti-forgery boundary; see
config.md for how it's configured.
Unauthenticated access is loopback-only, and only in exactly two situations, both meaning "there is nobody who could authorize it":
- setup has never completed, or
- the host has no companies, so there is no roster to hold an admin.
"Loopback-only" here means the configured bind and public_url say this host is
unreachable from outside and the request itself backs that up: its actual TCP
peer is loopback and it carries none of the proxy-forwarding headers
(X-Forwarded-For, X-Forwarded-Host, Forwarded, X-Real-IP) that would mean
something in front of this process terminated a connection it did not. Both
checks matter — a loopback-bound listener sitting behind an undeclared reverse
proxy would otherwise pass the first and fail only the second.
Openness on a routable host would let whoever reached a fresh deployment first configure it; openness on a configured laptop would let any page in the browser rewrite its settings. The second condition is not a nicety either — setup can complete without seeding a company (an operator who only changes host settings), and gating that host behind an admin check would leave it with no company to sign in to and no way back into setup to create one, which is this flow's own dead end reintroduced one step later.
Outside those two unauthenticated cases — a configured host with exactly one
company — the ordinary admin check applies instead: a live session belonging to
an admin of the sole company, resolved the same way server/users/admin.rs
resolves it. This path accepts an authenticated human session from anywhere, not
just loopback; it is require_admin, not the loopback gate, that authorizes it.
Two states reach neither the anonymous branch nor require_admin, and both
answer the same 409: a host serving several companies, which has no single
roster that could speak for the instance, and a routable host with none,
where the loopback condition above did not apply and there is no roster at all.
registry().sole() is None for both, so the refusal names what is true of
either rather than a count that might be wrong. The recovery is the one the
response itself gives — edit config.toml directly and restart — because the
alternative on a routable host is an unauthenticated write, which is exactly
what the loopback gate exists to prevent.
The console's 401 handling is excluded while the wizard is showing: on an unconfigured host every authenticated route answers 401, and letting that swap the wizard for a sign-in form would restore the dead end this flow removes.
ACP is a cargo feature (acp), not a setting. With the feature on, the
host mounts the authenticated HTTP JSON-RPC transport at /acp
(src/server/acp/); without it only the session and permission model plus the
reserved path compile (src/server/routes.rs). The flow therefore reports
acp_in_build and acp_transport_mounted separately and offers no switch,
which is the difference between telling an operator "not available" and sending
a client at an endpoint that 404s. The same reporting-not-writing rule covers
mcp, openhuman and oauth.
data_dir is excluded too: a running host has already opened and locked its
data root, so writing a new one into the file that lives inside that root
would produce a config nothing reads. Moving a data root is a relocation, not a
setting.