Skip to main content
This page is for the self-hoster: what is closed by default, what has to be set, and what happens on restart. Three separate gates protect an Avocado deployment, and they are configured independently. Knowing which one is refusing you is most of the work of fixing it.

The two credentials

Both are read by the orchestrator, and either one opens the access gate. ACCESS_PASSWORD_HASH — the SHA-256 hex digest 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 as Authorization: Bearer, x-access-token or ?accessToken=. This is the one for scripts and CI. The site SDK sends it when it reads drafts over HTTP from a gated orchestrator, and avocado-register and avocado qa send it too.

What happens on restart

Tokens minted by /auth/verify are valid for 12 hours and are held in memory. A restart or a redeploy voids every one of them, so everyone signed in to the editor is asked for the password again. GET /auth/status reports tokenValid: false for a token the server no longer knows, which is how the editor knows to prompt. ORCHESTRATOR_ACCESS_TOKEN is read from the environment and survives restarts.

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. It enforces the gate only on the agent surface and on GET /editor/credentials. /chat, /ops, /history/*, /telemetry/* and the draft routes remain open to anyone who can reach the server. /publish checks x-publish-token when PUBLISH_TOKEN is set, and nothing else.
Do not expose a standalone orchestrator to the open internet and rely on the password. Keep it reachable only by the people who edit, for example on a private network or behind a reverse proxy that restricts access. Library mode gates every route and is the shape to choose when that is not possible.
A closed mount says so rather than leaving you to infer it from a wall of 401s:
  • GET /auth/status reports mode: "closed" with a reason naming both variables. The other modes are token, hook and open-dev.
  • Every gated route’s 401 carries the same reason.
  • POST /auth/verify answers 503 rather than minting a token — a login that succeeds against a shut system leaves you holding a credential that opens nothing.
  • The editor reads mode and renders a screen for the state instead of loading itself around a site it cannot read.
At boot it logs:
During 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, OPTIONS preflights, and GET /generated-images/*, which the rendered page loads in <img> tags. With the pre-alpha SITE_OPS_AGENTS=1, /connections/google/callback is open too. It acts only on a single-use state value the orchestrator issued to a signed-in editor. Draft reads from the same process skip the gate. When the site renders in the process that mounts createOrchestrator(), and ORCHESTRATOR_URL is unset or names that mount, the SDK reads /draft/pages, /draft/slugs and /draft/site-config in-process. Only those three GET reads skip the gate. When the site reads drafts over HTTP instead, it needs ORCHESTRATOR_ACCESS_TOKEN in production. Over HTTP means ORCHESTRATOR_URL names another orchestrator, or the route runs in a separate serverless function. Local next dev needs none of this.

The editor’s credentials

The editor needs the site’s draft secret to open the preview in draft mode, and the publish token to publish. Neither belongs in the editor’s page on a public URL, because that page is served before the password prompt.
  • avocado-studio start writes them into the page only on a loopback --host.
  • On any other bind, the editor fetches them after sign-in from GET /editor/credentials. That route returns the orchestrator’s own DRAFT_MODE_SECRET and PUBLISH_TOKEN, with cache-control: no-store.
  • In library mode the route is gated like every other route. On the standalone server it checks the access token itself. With no credential configured it answers 403 in production.
  • VITE_* variables are inlined into an editor you build from source, so they are public too. Do not build VITE_SITE_DRAFT_SECRET or VITE_PUBLISH_TOKEN into a public editor.
So a hosted editor needs the orchestrator to hold DRAFT_MODE_SECRET, PUBLISH_TOKEN and ACCESS_PASSWORD_HASH. A library-mode orchestrator already has the site’s own DRAFT_MODE_SECRET, because it runs in the site’s process.

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. For a tighter bound, pass maxPagesRemoved to createEditorApiHandler(). A publish that removes more pages than that is then refused unless it carries "allowDelete": true.

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.
It fails closed. AGENT_SURFACE=on in production with no password gate and no static token refuses to mount and says why, rather than mounting open:
If you do not use the built-in onboarding agent, leave AGENT_SURFACE unset. Nothing else in the product needs it, and the editor’s per-edit chat does not go through it.

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

  1. Set ACCESS_PASSWORD_HASH or ORCHESTRATOR_ACCESS_TOKEN.
  2. Set PUBLISH_TOKEN, on both the site and the orchestrator.
  3. Set DRAFT_MODE_SECRET on the site and on the orchestrator, which hands it to the signed-in editor.
  4. Set NEXT_PUBLIC_SITE_URL to the real origin.
  5. Leave AGENT_SURFACE and AGENT_CLI unset unless you specifically want the onboarding agent.
  6. Leave SITE_OPS_AGENTS unset. Those features are pre-alpha and off by default.
  7. On the standalone server, restrict who can reach it at the network level.
  8. In library mode, confirm with GET /auth/status. It should not say mode: "closed".
Every variable above: environment reference.