The two credentials
Both are read by the orchestrator, and either one opens the access gate.ACCESS_PASSWORD_HASH — the sha256 of a password. POST /auth/verify
exchanges the password for a bearer token, and the editor prompts for it and
attaches the token as x-access-token (and as ?accessToken= on EventSource,
which cannot send headers). This is the one for people.
ORCHESTRATOR_ACCESS_TOKEN — a fixed bearer token, presented directly. This
is the one for scripts, CI, and the MCP server.
The production gate
This is the behaviour that surprises people, so it is worth stating plainly.createOrchestrator() — library mode — gates every route. With no auth
hook and neither variable set, it refuses every request under
NODE_ENV=production. An unauthenticated publish endpoint on your own domain
is not a state anyone should be able to reach by forgetting something.
The standalone server is different: only the agent surface enforces the gate
there, and /chat and /ops remain open.
A closed mount says so rather than leaving you to infer it from a wall of 401s:
GET /auth/statusreportsmode: "closed"with areasonnaming both variables.- Every gated route’s 401 carries the same
reason. POST /auth/verifyanswers503rather than minting a token — a login that succeeds against a shut system leaves you holding a credential that opens nothing.- The editor reads
modeand renders a screen for the state instead of loading itself around a site it cannot read.
next build that is a warning; at runtime it is an error, because then
requests really are being refused.
Opening it
Three things open the gate:ACCESS_PASSWORD_HASH, ORCHESTRATOR_ACCESS_TOKEN,
or an auth hook passed to createOrchestrator in code — including
auth: () => true, which is a line you type on purpose and cannot arrive at by
omission.
A few paths stay open either way, because they answer before any caller could
hold a credential: /health, /auth/status, /auth/verify, and the generated
images the rendered page loads in <img> tags.
Local next dev needs none of this.
The publish secret
POST /api/editor/publish replaces the site’s content, so it is guarded
separately and with a variable of its own.
Under NODE_ENV=production it refuses every request while publishSecret is
unset — 401, with a reason naming PUBLISH_TOKEN. Set that variable on the
site and set the same value in the orchestrator’s environment; the orchestrator
sends it as the x-publish-token header.
Development stays open while publishSecret is unset, because publishing to
your own machine is the point. Set the variable and the check runs in
development too.
One refusal applies in both environments: a publish whose pages array is empty
is answered with 409 unless the body carries "allowDelete": true. What
actually produces an empty array is a client publishing what it thinks it has
after its own state failed to load — losing a site to a failed fetch is not a
decision anyone made.
The agent surface
/agent/* and /sites-agent/* are not ordinary endpoints. They run open-ended
multi-turn agent loops with file and shell tools. The useCliAgent variant
spawns the Claude CLI with permissions bypassed and an allowed-tools list
containing Bash, Write and Edit, passing the request body through as the prompt.
That is arbitrary code execution as the orchestrator’s process user, by design —
it is what makes site onboarding work. The surface cannot be made safe by
narrowing what it may run. It can only be kept off by default and put behind a
credential.
So three decisions, in order:
1
Is the surface mounted at all?
Off in production unless
AGENT_SURFACE=on. Unmounted means 404 — no
handler, no stream context, no code path.2
May this caller reach it?
A valid access token, always, whenever the password gate is configured. In
development, loopback callers pass without one: a local process that can bind
a socket on your laptop already has your shell, so a token there guards
nothing.
3
May it spawn the CLI?
Separately, via
AGENT_CLI=1, and off everywhere by default — that
variant escapes the SDK’s tool boundary onto the host.AGENT_SURFACE=on in production with no password gate and no
static token refuses to mount and says why, rather than mounting open:
What an edit can and cannot reach
Worth restating alongside the credentials, because it is the guarantee that does not depend on anyone configuring anything: every edit the chat can make is one of a fixed set of typed operations against your content. That vocabulary has no verb for “change a file”. A model driving Avocado at full confidence with every flag disabled still cannot modify a component, add a dependency, alter a route, or touch your build. The agent surface above is the deliberate exception, which is why it is gated three times over and off by default.Cross-origin
localhost and 127.0.0.1 are treated as the same address wherever either is
written. A CORS answer a browser rejects is indistinguishable, from inside the
browser, from a server that is down — which is why “the editor cannot reach a
server that answers 200 to curl” is almost always this.
Checklist for a production deployment
- Set
ACCESS_PASSWORD_HASHorORCHESTRATOR_ACCESS_TOKEN. - Set
PUBLISH_TOKEN, on both the site and the orchestrator. - Set
DRAFT_MODE_SECRET, the same value the editor is given. - Set
NEXT_PUBLIC_SITE_URLto the real origin. - Leave
AGENT_SURFACEandAGENT_CLIunset unless you specifically want the onboarding agent. - Confirm with
GET /auth/status— it should not saymode: "closed".