> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Manual setup

> The same wiring the agent prompt does, written out — as one shell block, or as seven steps with the reasoning for each.

This is the other half of the [quickstart](/quickstart): everything its prompt
tells a coding agent to do, written out so you can do it yourself, or read it
when something did not do what it said it would.

Two ways through. The [shell block](#the-same-thing-as-one-shell-block) is one
paste against a brand-new app. The [seven steps](#step-by-step) are the same
work with the reasoning attached, which is what you want when you are bringing
an **existing** project in.

<Note>
  Both produce the library-mode shape: the orchestrator runs inside your Next.js
  app at `/api/avocado`, and `content/pages.json` is the site. Bringing a real
  site in — your components, your CMS, your content — is a different and larger
  job. Start at [bring your site in](/sites).
</Note>

## Prerequisites

* **Node.js 22+** — check with `node --version`
* **A Next.js 15 or 16 project on the App Router**, or an empty directory
* **One LLM API key** — Anthropic, OpenAI or Google Gemini

No database, no Docker, no auth setup for local development.

<Warning>
  **Using a Google Gemini key?** Add `@google/genai` too. It is an optional peer
  dependency of `@avocadostudio-ai/orchestrator-core`, loaded lazily, so no
  package manager installs it for you and a Gemini plan fails at the first call
  without it. `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` need nothing extra — both
  vendor SDKs are ordinary dependencies.
</Warning>

## The same thing, as one shell block

No coding agent, or you would rather watch it happen? This is the same wiring in
one paste, against a brand-new app. It was run end to end on Node 22 / npm 10 /
Next 16.3.5; the `siteUrl`, `writeOnPublish` and `publishSecret` lines it has
gained since are the ones `create-avocado-site` emits, which `pnpm test:build`
builds and serves on every run.

```bash theme={null}
npx create-next-app@latest avocado-demo --ts --app --tailwind --eslint \
  --no-src-dir --import-alias "@/*" --use-npm --yes
cd avocado-demo
npm install @avocadostudio-ai/site-sdk @avocadostudio-ai/orchestrator-core

# create-next-app writes a starter page at app/page.tsx. It matches "/" more
# specifically than the catch-all route below, so leaving it in place means
# your site is the Next.js starter and nothing anywhere says why.
rm app/page.tsx
mkdir -p content lib "app/api/editor/[...path]" "app/api/avocado/[[...path]]" "app/[[...slug]]"

cat > next.config.ts <<'EOF'
import { withAvocado } from "@avocadostudio-ai/site-sdk/next-config"
export default withAvocado({})
EOF

cat > content/pages.json <<'EOF'
[
  {
    "id": "home",
    "slug": "/",
    "title": "Home",
    "updatedAt": "2026-01-01T00:00:00.000Z",
    "blocks": [
      { "id": "hero-1", "type": "Hero", "props": {
          "heading": "Welcome to my site",
          "subheading": "A Next.js page you can edit by talking to it.",
          "ctaText": "Get started", "ctaHref": "#cta-1" } },
      { "id": "cta-1", "type": "CTA", "props": {
          "title": "Ready when you are",
          "description": "Ask the chat editor to change any of this.",
          "ctaText": "Book a demo", "ctaHref": "/contact" } }
    ]
  }
]
EOF

cat > lib/content.ts <<'EOF'
import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared"
import pages from "../content/pages.json"

const all = pages as PageDoc[]

export async function getPage(slug: string): Promise<PageDoc | null> {
  return all.find((page) => page.slug === slug) ?? null
}
export async function getSlugs(): Promise<string[]> {
  return all.map((page) => page.slug)
}
export async function getPages(): Promise<PageDoc[]> {
  return all
}
export async function getSiteConfig(): Promise<SiteConfig> {
  return { name: "My Site" }
}
EOF

cat > "app/api/editor/[...path]/route.ts" <<'EOF'
import { resolve } from "node:path"
import { createEditorApiHandler } from "@avocadostudio-ai/site-sdk/routes"
import { createJsonFilePublishHandler } from "@avocadostudio-ai/site-sdk/publish-handlers/json-file"
import { getPages } from "../../../../lib/content"

export const { GET, POST, OPTIONS } = createEditorApiHandler({
  getPages,
  onPublish: createJsonFilePublishHandler(resolve(process.cwd(), "content/pages.json")),
  // This route replaces the site's content. Unset, it refuses everything under
  // NODE_ENV=production rather than running unguarded.
  publishSecret: process.env.PUBLISH_TOKEN?.trim() || undefined,
})
EOF

cat > "app/api/avocado/[[...path]]/route.ts" <<'EOF'
import path from "node:path"
import { createOrchestrator } from "@avocadostudio-ai/site-sdk/server"
import { jsonFileAdapter } from "@avocadostudio-ai/orchestrator-core/cms"

export const runtime = "nodejs"
export const dynamic = "force-dynamic"

const handler = createOrchestrator({
  // writeOnPublish defaults to false, which leaves the adapter with no
  // onPublish — and Publish then reports success and rewrites nothing.
  adapter: jsonFileAdapter({
    path: path.join(process.cwd(), "content", "pages.json"),
    writeOnPublish: true,
  }),
  siteId: "my-site",
  siteName: "My Site",
})

export const POST = handler
export const GET = handler
export const OPTIONS = handler
EOF

cat > "app/[[...slug]]/page.tsx" <<'EOF'
import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
import { getPage, getSlugs, getSiteConfig } from "../../lib/content"

const { Page, generateStaticParams, generateMetadata } = createSitePage({
  siteId: "my-site",
  siteName: "My Site",
  getPage,
  getSlugs,
  getSiteConfig,
  // Where this site lives. Nothing in a page's own content can supply it, and
  // three tags need it: the canonical link, og:url, and an absolute og:image.
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
})

export default Page
export { generateStaticParams, generateMetadata }
EOF

# The blocks ship their own stylesheet. Without this the page renders, and
# renders unstyled.
printf '@import "@avocadostudio-ai/blocks/styles.css";\n%s' "$(cat app/globals.css)" > app/globals.css

# The orchestrator writes its SQLite state into .data/ on the first request, and
# create-next-app's .gitignore does not cover it. Without this line the first
# `git add .` after the first run commits a database.
echo '.data/' >> .gitignore

{
  echo 'ANTHROPIC_API_KEY=sk-ant-REPLACE_ME'
  echo 'ORCHESTRATOR_URL=http://localhost:3000/api/avocado'
  echo 'NEXT_PUBLIC_SITE_URL=http://localhost:3000'
  echo "DRAFT_MODE_SECRET=$(openssl rand -hex 32)"
  echo "PUBLISH_TOKEN=$(openssl rand -hex 32)"
} > .env.local
```

Put a real key in `.env.local`, then
[start both processes](/quickstart#start-both-processes).

## Step by step

### 1. Install the packages

From your project directory:

```bash theme={null}
pnpm add @avocadostudio-ai/site-sdk @avocadostudio-ai/orchestrator-core
```

`site-sdk` is the integration surface — routes, page factory, markers,
publishing. `orchestrator-core` is the brain, and it is an **optional** peer of
the SDK, so it is not installed for you: a site that only renders blocks and
talks to a remote orchestrator does not need it, and you do. Both pull in
`@avocadostudio-ai/blocks`, `@avocadostudio-ai/shared`,
`@avocadostudio-ai/preview-adapter` and `@avocadostudio-ai/richtext` as
transitive dependencies.

### 2. Wrap your Next config

```ts theme={null}
// next.config.ts
import { withAvocado } from "@avocadostudio-ai/site-sdk/next-config"

export default withAvocado({ /* your existing config */ })
```

`orchestrator-core` carries native dependencies (`better-sqlite3`, `sharp`).
`withAvocado` sets `serverExternalPackages`, the matching server externals, and
`transpilePackages` together — `serverExternalPackages` alone is not enough,
because `transpilePackages` overrides it for a transitive dependency. See
[server externals](/integration/nextjs-integration#server-externals).

Then import the block stylesheet, at the top of `app/globals.css`:

```css theme={null}
@import "@avocadostudio-ai/blocks/styles.css";
```

The built-in blocks ship their own CSS. Without this line everything below still
works — the page renders, the editor drives it, the chat edits land — and it all
looks like unstyled HTML, which reads as a broken install rather than a missing
import.

### 3. Give the site some content

Avocado edits a `PageDoc` — a page with an ordered list of typed blocks. For
this quickstart, keep it in a JSON file.

```json content/pages.json theme={null}
[
  {
    "id": "home",
    "slug": "/",
    "title": "Home",
    "updatedAt": "2026-01-01T00:00:00.000Z",
    "blocks": [
      {
        "id": "hero-1",
        "type": "Hero",
        "props": {
          "heading": "Welcome to my site",
          "subheading": "A Next.js page you can edit by talking to it.",
          "ctaText": "Get started",
          "ctaHref": "#cta-1"
        }
      },
      {
        "id": "cta-1",
        "type": "CTA",
        "props": {
          "title": "Ready when you are",
          "description": "Ask the chat editor to change any of this.",
          "ctaText": "Book a demo",
          "ctaHref": "/contact"
        }
      }
    ]
  }
]
```

Then the three fetchers the SDK asks for:

```ts theme={null}
// lib/content.ts
import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared"
import pages from "../content/pages.json"

const all = pages as PageDoc[]

export async function getPage(slug: string): Promise<PageDoc | null> {
  return all.find((page) => page.slug === slug) ?? null
}

export async function getSlugs(): Promise<string[]> {
  return all.map((page) => page.slug)
}

export async function getPages(): Promise<PageDoc[]> {
  return all
}

export async function getSiteConfig(): Promise<SiteConfig> {
  return { name: "My Site" }
}
```

`Hero` and `CTA` are two of the 20 built-in block types, which is why this
renders with no components of your own. On a real site the blocks are **your**
React components, registered with a schema — see
[custom blocks](/integration/custom-blocks).

### 4. Mount the editor API route

```ts theme={null}
// app/api/editor/[...path]/route.ts
import { createEditorApiHandler } from "@avocadostudio-ai/site-sdk/routes"
import { getPages } from "../../../../lib/content"

export const { GET, POST, OPTIONS } = createEditorApiHandler({
  getPages,
})
```

One catch-all route serves the block manifest, the page list, and draft-mode
entry and exit. Pass `onPublish` — and, with it, `publishSecret` — when you want
[publishing](/integration/publishing) through this route.

### 5. Mount the orchestrator inside your app

```ts theme={null}
// app/api/avocado/[[...path]]/route.ts
import path from "node:path"
import { createOrchestrator } from "@avocadostudio-ai/site-sdk/server"
import { jsonFileAdapter } from "@avocadostudio-ai/orchestrator-core/cms"

export const runtime = "nodejs"
export const dynamic = "force-dynamic"

const handler = createOrchestrator({
  adapter: jsonFileAdapter({
    path: path.join(process.cwd(), "content", "pages.json"),
    writeOnPublish: true,
  }),
  siteId: "my-site",
  siteName: "My Site",
})

export const POST = handler
export const GET = handler
export const OPTIONS = handler
```

`siteName` is what the editor greets you with. Leave it out and the editor
title-cases the site id instead, so a mount called `my-shop` is introduced as
"My Shop" — a reasonable guess, and a poor one for anything whose id is not its
name. There is also `demoContent: true`, which tells the editor this mount is
serving Avocado's shipped demo pages and turns on the first-run suggestions
written against them; `create-avocado-site` sets it, and a real site should not.

This is library mode: the planner, the operations engine, the draft state and
the version log all run inside your Next.js app at `/api/avocado`. The adapter
is how it reads your content on a cold session — and, when someone publishes,
how it writes back. `writeOnPublish` is what gives `jsonFileAdapter` an
`onPublish` at all; it defaults to `false`, and without it Publish reports
success and leaves the file untouched.

<Note>
  `createOrchestrator()` is open in development and **closed under
  `NODE_ENV=production`** when neither `ACCESS_PASSWORD_HASH` nor
  `ORCHESTRATOR_ACCESS_TOKEN` is set, and no `auth` hook is passed. That is
  deliberate — an unauthenticated publish endpoint on your own domain is not a
  default anyone should reach by forgetting something. Local `next dev` needs
  nothing. Full detail: [security and access](/reference/security).
</Note>

### 6. Render the page

```tsx theme={null}
// app/[[...slug]]/page.tsx
import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
import { getPage, getSlugs, getSiteConfig } from "../../lib/content"

const { Page, generateStaticParams, generateMetadata } = createSitePage({
  siteId: "my-site",
  siteName: "My Site",
  getPage,
  getSlugs,
  getSiteConfig,
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
})

export default Page
export { generateStaticParams, generateMetadata }
```

`generateMetadata` derives a page's title, description and Open Graph tags from
the page itself. Three tags it cannot derive, because none of them is knowable
without knowing where the site lives: `<link rel="canonical">`, `og:url`, and an
`og:image` resolved to an absolute URL. `siteUrl` is how you say. Leave it out
and the SDK emits none of the three rather than guessing — a wrong canonical is
worse than an absent one — and a relative `ogImage` goes out relative, which is
correct in an `<img src>` and ignored by every social crawler, so the page's
cards render as a bare text link. Read it from an environment variable at the
call site, as above, so a preview deployment describes itself rather than
claiming to be production.

<Warning>
  **Delete `app/page.tsx` if `create-next-app` made one.** Both it and
  `app/[[...slug]]/page.tsx` match `/`, and the more specific route wins — so the
  starter page keeps being served, no error is logged, no route conflict is
  reported, and the site you just wired up is invisible. This is the single most
  common way a first run ends in "nothing happened".
</Warning>

`mode` defaults to `"auto"`: one route handles both the published page and the
editor preview, with no middleware or proxy to set up. It is not statically
rendered, because deciding between the two modes means reading `searchParams` on
every request. A production site wants `mode: "static"` plus a separate
`mode: "preview"` route — see
[Next.js integration](/integration/nextjs-integration).

### 7. Set your key

```bash theme={null}
# .env.local
ANTHROPIC_API_KEY=sk-ant-...
ORCHESTRATOR_URL=http://localhost:3000/api/avocado
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```

`ORCHESTRATOR_URL` tells the SDK's server-side draft fetch where the
orchestrator is. A library-mode mount registers itself, so this is usually
redundant — set it anyway, because the fallback when nothing is registered is
`http://127.0.0.1:4200`, and whatever else is listening there will answer.

`NEXT_PUBLIC_SITE_URL` is the origin `createSitePage({ siteUrl })` reads. Point
it at your real domain when you deploy; the canonical link and the social cards
are only as right as this value.

`OPENAI_API_KEY` and `GOOGLE_GENAI_API_KEY` work in the same slot. The editor's
model picker offers whichever keys are present.

Every variable Avocado reads: [environment reference](/reference/environment).

## Troubleshooting

**`pnpm dev` fails with a `better-sqlite3` or `sharp` error.** Your Next config
is not wrapped. Apply `withAvocado` ([step 2](#2-wrap-your-next-config)) — the
native binaries must stay external to the bundle.

**The chat answers, but nothing changes, and the model picker is empty.** No
provider key reached the orchestrator. Check
`curl http://localhost:3000/api/avocado/status/planner`: `availableProviders`
should list your provider, and `plannerSource` should not be `"demo"`. If it is,
the key is missing from `.env.local` or the dev server has not been restarted
since you added it.

**The preview iframe is blank.** Open `http://localhost:3000` in a normal tab
first. The editor frames your real site, so if the site is not serving, the
preview has nothing to show.

**The preview shows the published page and never your edits.** The property
panel saying *"this block is not in the page the orchestrator returned"* is the
same symptom: the editor is talking to a different orchestrator than the one
inside your site. Confirm you passed
`--orchestrator http://localhost:3000/api/avocado`, or set the site's
**Orchestrator URL** in the editor's site settings.

**The editor says nothing was written, and `content/pages.json` is unchanged.**
The adapter has no `onPublish`, so there was nowhere to put the pages — your
edits are still there as a draft. Pass `writeOnPublish: true` to
`jsonFileAdapter` ([step 5](#5-mount-the-orchestrator-inside-your-app)). The API
answers this one as `ok: true, written: false` with the same thing in `reason`,
so a script that checks only `ok` will not notice.

**Publishing answers 401 or 409.** A `401` is `POST /api/editor/publish`: either
it is running under `NODE_ENV=production` with no `publishSecret` — the body's
`reason` names `PUBLISH_TOKEN` — or the `x-publish-token` it received does not
match the one configured. A `409` means the publish would have removed every
page, and the `reason` says what it protected; resend with `"allowDelete": true`
only if emptying the site is genuinely what you meant.

**A production build serves the site but the editor loads nothing.**
`createOrchestrator()` is closed under `NODE_ENV=production` with no credential.
`curl http://localhost:3000/api/avocado/auth/status` answers `mode: "closed"`
and a `reason` naming `ACCESS_PASSWORD_HASH` and `ORCHESTRATOR_ACCESS_TOKEN`;
set one of them. See [security and access](/reference/security).

**Node version errors.** Install Node 22 with your version manager —
`fnm install 22`, `nvm install 22`, or `mise use node@22`.

**Something deeper.** See
[chat troubleshooting](/observability/chat-troubleshooting).
