# Zerg App Store API

Base URL: `https://apps.zergai.com`. This reference lists every route the App
Store serves. The App Store's tests check it against the code: each route row
against its route file, the Shapes block against the schemas the routes
validate with, and the error tables against every error the server raises.
Agent setup and rules are at https://apps.zergai.com/agents.md.

## What an agent can call today

There is **no agent credential**: no API token, API key, service account or
OAuth client. An agent can call the **public routes** below and nothing else.
Every other route accepts only a member's signed-in browser session, created by
Zerg SSO and kept in the browser as an HTTP-only cookie. Those routes are
listed so you can read data a signed-in person shares with you and explain what
their browser is doing. Called without that session, a store read answers
`401 authentication_required`; a write is refused before that with
`403 cross_origin_mutation`, because it does not come from the store's own
pages. (The feedback launcher's configuration answers everyone
`{"enabled":false}` while feedback capture is off.) Never copy, export or
replay a session cookie.

An `Authorization: Bearer` header never signs you in. The only bearer the
server accepts is its own internal tool credential (`ast1.` prefix), which it
mints for its built-in assistant and never issues to people or agents. Any
other bearer only changes which refusal comes back: a store write that carries
one skips the origin check and answers `401 authentication_required`, and a
bearer shaped like the internal credential answers
`401 invalid_tool_credential`.

## Conventions

- **Errors** are JSON: `{"error":true,"url":"…","statusCode":401,"statusMessage":"Authentication required","message":"Authentication required","data":{"code":"authentication_required"}}`.
  Errors the store raises carry a stable `data.code`, and some add fields to
  `data` (named under Errors). A few framework errors carry no code; they are
  listed separately.
- **Stores** are addressed by `{org}`, the organization's slug (2 to 63
  lowercase letters, digits and hyphens). A store that does not exist, one the
  caller does not belong to and a malformed slug all answer the same
  `404 organization_not_found`.
- **Writes** (`POST`, `PUT`) from a browser must carry
  `Origin: https://apps.zergai.com`. A write with another `Origin`, or with
  none, answers `403 cross_origin_mutation` before authentication, unless it
  carries an `Authorization: Bearer` header (see above).
- **Idempotency.** Catalog writes need an `Idempotency-Key` header (1 to 128 printable ASCII characters).
  Repeating a request with the same key replays the stored reply with
  `Idempotent-Replayed: true`; reusing a key for a different request answers
  `422 idempotency_key_reused`.
- **Bodies** are closed: a field the request shape does not name fails, except
  in `FeedbackGrantRequest`, which ignores unknown keys. Request and response
  shapes are named in the route tables and defined under Shapes.
- **Unmeasured numbers are `null`.** `runs`, `savedUsd` and `weeklyUsers` are
  not measured yet; an app with no reviews has `rating.average: null`. Never
  read `null` as zero.
- Store responses are never cached (`Cache-Control` includes `no-store`). The
  public app showcase is the exception: it is the same for everyone, so it
  answers `Cache-Control: public, max-age=300, stale-while-revalidate=3600` and
  `Access-Control-Allow-Origin: *`, and any site may read it.
- The public documents and the public app showcase answer `HEAD` with the same
  headers as `GET`. Other routes answer only the methods listed.

## Public routes

| Method | Path | Access | Preconditions | Request | Success |
| --- | --- | --- | --- | --- | --- |
| GET | `/api/health` | Public | — | — | 200 `{"ok":true,"status":"live"}` (liveness, never touches the database) |
| GET | `/api/health/ready` | Public | — | — | 200 `{"ok":true,"status":"ready","database":"ok","schemaVersion":N}`; otherwise 503 with `status` `schema_mismatch` or `unavailable` |
| GET | `/api/version` | Public | — | — | 200 `{"version","commit","schemaVersion"}`; `commit` is the deployed git SHA or `null` |
| GET, HEAD | `/agents.md` | Public | — | — | 200 agent instructions, `text/markdown` |
| GET, HEAD | `/AGENTS.md` | Public | — | — | 200 the same document as `/agents.md` |
| GET, HEAD | `/llms.txt` | Public | — | — | 200 index of these documents, `text/plain` |
| GET, HEAD | `/docs/api.md` | Public | — | — | 200 this reference, `text/markdown` |
| GET, HEAD | `/skills/zerg-appstore/SKILL.md` | Public | — | — | 200 the portable skill, `text/markdown` |
| GET, HEAD | `/api/v1/public/apps` | Public; the same answer for everyone, with no session lookup | — | — | 200 `PublicApps`: the public app showcase, in the order the store lists it. Each app's `url` is its public https address, and `tone` its tile colour |
| GET | `/api/v1/appstore/orgs/{org}/brand.css` | Public; a member gets their store's accent | — | — | 200 `text/css`; anyone who is not a signed-in member gets the empty override sheet, whether the store exists or not. A malformed slug answers `404 organization_not_found` |

## Sign-in routes (the person's browser only)

An agent must never drive these: they complete Zerg SSO for the person in front
of the browser.

| Method | Path | Access | Preconditions | Request | Success |
| --- | --- | --- | --- | --- | --- |
| GET | `/api/v1/auth/zerg/start` | Public; browser sign-in only | — | Query `next`: a same-site path to return to | 302 to Zerg SSO (PKCE) |
| GET | `/api/v1/auth/zerg/callback` | Public; browser sign-in only | — | Query `code`, `state` from Zerg SSO | 302 to the return path, with the session cookie (at most 12 hours). A callback with no live sign-in behind it (reloaded, replayed, cancelled, or from a retired host) answers 302 to `/` with a session and to `/login?error=expired` without one |
| POST | `/api/v1/auth/logout` | Signed-in session | `Origin` | — | 204, session revoked and cookie cleared |
| GET | `/api/v1/session` | Signed-in session | — | — | 200 `{identity:{id,email,displayName},session:{expiresAt},organizations:[{slug,storeName}]}` |

## Store routes (member's browser session only)

All paths start with `/api/v1/appstore/orgs/{org}`. "Member" means a signed-in
member of that store whose roles grant the named capability; a missing
capability answers `403 capability_denied` with `data.capability`.

| Method | Path | Access | Preconditions | Request | Success |
| --- | --- | --- | --- | --- | --- |
| GET | `/api/v1/appstore/orgs/{org}/profile` | Member `app.read` | — | — | 200 `{slug,storeName,banner,supportChannel,hostingMode,region,status}` |
| GET | `/api/v1/appstore/orgs/{org}/brand` | Member `app.read` | — | — | 200 `{accent,accentPreset,logoMonogram,logoTone,storeName,banner,updatedAt,defaultAccent,viewer:{canManageBrand,canCurate,canReadCosts,isAdmin}}`; `ETag` names the revision |
| PUT | `/api/v1/appstore/orgs/{org}/brand` | Member `brand.manage` | `Origin`; If-Match (optional) with the `ETag` from GET | `BrandKitUpdate`; each value must also pass the brand kit rules, including the contrast check | 200 the same shape as GET; new `ETag` |
| GET | `/api/v1/appstore/orgs/{org}/catalog` | Member `app.read` | — | — | 200 `Catalog` (the Discover page) |
| GET | `/api/v1/appstore/orgs/{org}/stats` | Member `app.read` | — | — | 200 `Stats` |
| GET | `/api/v1/appstore/orgs/{org}/apps` | Member `app.read` | — | Query `AppListQuery` | 200 `AppPage` |
| POST | `/api/v1/appstore/orgs/{org}/apps` | Member `app.submit` | `Origin`; `Idempotency-Key` | `NewDraft` | 201 `AppDetail` of a private draft (`status` `draft`, `origin` `agent`) |
| GET | `/api/v1/appstore/orgs/{org}/apps/{slug}` | Member `app.read` | — | — | 200 `AppDetail` |
| POST | `/api/v1/appstore/orgs/{org}/apps/{slug}/installs` | Member `app.read` | `Origin`; `Idempotency-Key` | — | 200 `InstallResult`; records that the member got or opened the app |
| GET | `/api/v1/appstore/orgs/{org}/apps/{slug}/reviews` | Member `app.read` | — | — | 200 `ReviewList`, newest first |
| POST | `/api/v1/appstore/orgs/{org}/apps/{slug}/reviews` | Member `app.read` | `Origin`; `Idempotency-Key` | `NewReview` | 201 `Review` |
| GET | `/api/v1/appstore/orgs/{org}/categories` | Member `app.read` | — | — | 200 `Category[]` |
| GET | `/api/v1/appstore/orgs/{org}/categories/{slug}` | Member `app.read` | — | — | 200 `CategoryPage` |
| GET | `/api/v1/appstore/orgs/{org}/collections` | Member `app.read` | — | — | 200 `Collection[]` |
| GET | `/api/v1/appstore/orgs/{org}/collections/{slug}` | Member `app.read` | — | — | 200 `CollectionPage` |
| GET | `/api/v1/appstore/orgs/{org}/creators` | Member `app.read` | — | — | 200 `Creator[]` |
| GET | `/api/v1/appstore/orgs/{org}/bounties` | Member `app.read` | — | — | 200 `Bounty[]`, open ones first |
| POST | `/api/v1/appstore/orgs/{org}/bounties` | Member `catalog.curate` | `Origin`; `Idempotency-Key` | `NewBounty` | 201 `Bounty` |
| GET | `/api/v1/appstore/orgs/{org}/featured` | Member `app.read` | — | — | 200 `Featured` (app slugs); `ETag` |
| PUT | `/api/v1/appstore/orgs/{org}/featured` | Member `catalog.curate` | `Origin`; `Idempotency-Key`; If-Match (required) with the `ETag` from GET | `FeaturedUpdate`; the app of the week must be live, and every staff pick a distinct listed app | 200 `Featured`; new `ETag` |
| GET | `/api/v1/appstore/orgs/{org}/me/apps` | Member `app.read` | — | — | 200 `MyApps` |
| POST | `/api/v1/appstore/orgs/{org}/assistant/ask` | Member `app.read` | `Origin` | `AskRequest` | 200 `text/event-stream` (see Assistant event stream) |
| POST | `/api/v1/appstore/orgs/{org}/assistant/draft-listing` | Member `app.submit` | `Origin` | `DraftListingRequest` | 200 `ListingDraft`; nothing is saved |

## Feedback routes (member's browser session only)

| Method | Path | Access | Preconditions | Request | Success |
| --- | --- | --- | --- | --- | --- |
| GET | `/api/zerg-feedback/config` | Anyone while capture is off; otherwise a signed-in session | — | — | 200 `{"enabled":false}` while capture is off; otherwise the hosted feedback launcher's configuration |
| POST | `/api/zerg-feedback/grant` | Signed-in session | `Origin` exactly the store's; no `Authorization` header | `FeedbackGrantRequest` (the feedback SDK sends it) | 200 a one-use capture grant |
| POST | `/api/zerg-feedback/recovery` | Signed-in session | `Origin` exactly the store's; no `Authorization` header | `FeedbackRecoveryRequest` | 200 which saved captures belong to the member |

## Shapes

Generated from the schemas the routes validate with: requests first, as you
send them, then responses, as the server returns them. `?` marks an optional
field, and a comment gives each limit, pattern, format or default.
`AppListQuery` fields are query-string parameters. A search without `status`
lists `live`, `in_development` and `in_review` apps. Pass `nextCursor` back as
`cursor` for the next page; `null` means there are no more.

<!-- shapes:start (generated by npm run agents:shapes from the route schemas; do not edit by hand) -->
```ts
type AppListQuery = {
  q?: string                             // up to 200 characters
  category?: string                      // matches ^[a-z0-9][a-z0-9-]{0,62}$
  tag?: string                           // matches ^[a-z0-9][a-z0-9-]{0,31}$
  status?: 'draft' | 'in_review' | 'live' | 'in_development' | 'archived'
  origin?: 'platform' | 'agent'
  sort?: 'featured' | 'recent' | 'name'  // default 'featured'
  cursor?: string                        // 1 to 200 characters
  limit?: number                         // integer 1 to 50; default 24
}

type NewDraft = {
  name: string           // 1 to 80 characters
  tagline: string        // 1 to 200 characters
  descriptionMd: string  // up to 20,000 characters
  kind: 'web' | 'chat' | 'workflow' | 'workflow_chat'
  categories: string[]   // up to 4 items; each matches ^[a-z0-9][a-z0-9-]{0,62}$; distinct; each one of this store's category slugs
  tags: string[]         // up to 12 items; each matches ^[a-z0-9][a-z0-9-]{0,31}$; distinct
  icon: {
    monogram: string  // matches ^[\p{L}\p{N}]{1,3}$
    tone: 'violet' | 'blue' | 'teal' | 'green' | 'yellow' | 'orange' | 'red' | 'pink' | 'navy' | 'slate'
    imageUrl: null
  }
  brief: string          // 1 to 8,000 characters
}

type NewReview = {
  rating: number  // integer 1 to 5
  body: string    // 1 to 2,000 characters
}

type NewBounty = {
  title: string             // 1 to 120 characters
  descriptionMd: string     // up to 4,000 characters
  rewardUsd: number | null  // integer 0 to 10,000,000
  closesAt: string | null   // ISO 8601 time with offset
}

type FeaturedUpdate = {
  appOfTheWeek: string | null  // matches ^[a-z0-9][a-z0-9-]{0,62}$
  staffPicks: string[]         // up to 12 items; each matches ^[a-z0-9][a-z0-9-]{0,62}$
}

type BrandKitUpdate = {
  accent: string | null        // up to 16 characters
  accentPreset: string | null  // up to 32 characters
  logoMonogram: string | null  // up to 16 characters
  logoTone: string | null      // up to 16 characters
  storeName: string | null     // up to 200 characters
  banner: string | null        // up to 400 characters
}

type AskRequest = {
  question: string  // 1 to 2,000 characters
  appSlug?: string  // matches ^[a-z0-9][a-z0-9-]{0,62}$
}

type DraftListingRequest = {
  brief: string  // 10 to 4,000 characters
}

type FeedbackGrantRequest = {  // other keys are ignored
  captureStrategy: 'none' | 'pixel' | 'dom-replay' | 'hybrid'
  idempotencyKey: string                                                          // 1 to 200 characters
  modalities: ('text' | 'voice' | 'image' | 'page-screenshot' | 'walkthrough')[]  // 1 to 5 items
  surfaceKey: 'appstore-app'
  page: {  // other keys are ignored
    route: string   // 1 to 4,096 characters
    title?: string  // up to 1,000 characters
    custom?: {  // other keys are ignored
      feedbackKind?: string  // up to 20 characters
      appSlug?: string       // up to 63 characters
    }
  }
}

type FeedbackRecoveryRequest = {
  sessionIds: string[]  // 1 to 50 items; each matches ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$
}

type AppIcon = {
  monogram: string         // matches ^[\p{L}\p{N}]{1,3}$
  tone: 'violet' | 'blue' | 'teal' | 'green' | 'yellow' | 'orange' | 'red' | 'pink' | 'navy' | 'slate'
  imageUrl: string | null  // URL
}

type AppSummary = {
  slug: string              // matches ^[a-z0-9][a-z0-9-]{0,62}$
  name: string              // 1 to 80 characters
  tagline: string           // 1 to 200 characters
  origin: 'platform' | 'agent'
  kind: 'web' | 'chat' | 'workflow' | 'workflow_chat'
  status: 'draft' | 'in_review' | 'live' | 'in_development' | 'archived'
  icon: AppIcon
  categories: {
    slug: string  // matches ^[a-z0-9][a-z0-9-]{0,62}$
    name: string  // not empty
  }[]
  tags: string[]            // each matches ^[a-z0-9][a-z0-9-]{0,31}$
  openUrl: string | null    // URL
  owner: {
    name: string         // not empty
    team: string | null  // not empty
  }
  updatedAt: string | null  // ISO 8601 time with offset
  health: {
    state: 'up' | 'down' | 'unknown'
    checkedAt: string | null  // ISO 8601 time with offset
  }
}

type AppDetail = AppSummary & {
  descriptionMd: string
  repoPath: string | null
  boardUrl: string | null        // URL
  version: string | null
  lastDeployedAt: string | null  // ISO 8601 time with offset
  audience: string               // not empty
  rating: {
    average: number | null  // 1 to 5
    count: number           // integer 0 or more
  }
  releases: Release[]
  viewer: {
    canManage: boolean
    installed: boolean
    reviewed: boolean
  }
}

type Release = {
  version: string           // not empty
  commitSha: string | null  // matches ^[0-9a-f]{40}$
  releasedAt: string        // ISO 8601 time with offset
  notes: string
}

type AppPage = {
  items: AppSummary[]
  nextCursor: string | null
}

type Stats = {
  apps: number                // integer 0 or more
  liveApps: number            // integer 0 or more
  categories: number          // integer 0 or more
  creators: number            // integer 0 or more
  reviews: number             // integer 0 or more
  runs: number | null         // integer 0 or more
  savedUsd: number | null     // 0 or more
  weeklyUsers: number | null  // integer 0 or more
}

type Category = {
  slug: string   // matches ^[a-z0-9][a-z0-9-]{0,62}$
  name: string   // not empty
  description: string
  tone: 'violet' | 'blue' | 'teal' | 'green' | 'yellow' | 'orange' | 'red' | 'pink' | 'navy' | 'slate'
  count: number  // integer 0 or more
}

type CategoryPage = {
  category: Category
  apps: AppSummary[]
}

type Collection = {
  slug: string   // matches ^[a-z0-9][a-z0-9-]{0,62}$
  title: string  // not empty
  description: string
  tone: 'violet' | 'blue' | 'teal' | 'green' | 'yellow' | 'orange' | 'red' | 'pink' | 'navy' | 'slate'
  count: number  // integer 0 or more
}

type CollectionPage = {
  collection: Collection
  apps: AppSummary[]
}

type Creator = {
  name: string         // not empty
  team: string | null  // not empty
  appCount: number     // integer 0 or more
  liveCount: number    // integer 0 or more
}

type Bounty = {
  id: string                // UUID
  title: string             // 1 to 120 characters
  descriptionMd: string     // up to 4,000 characters
  rewardUsd: number | null  // integer 0 or more
  status: 'open' | 'closed'
  closesAt: string | null   // ISO 8601 time with offset
  createdAt: string         // ISO 8601 time with offset
  createdBy: {
    name: string  // not empty
  }
}

type Review = {
  id: string         // UUID
  rating: number     // integer 1 to 5
  body: string       // 1 to 2,000 characters
  createdAt: string  // ISO 8601 time with offset
  author: {
    name: string  // not empty
  }
}

type ReviewList = {
  items: Review[]
  rating: {
    average: number | null  // 1 to 5
    count: number           // integer 0 or more
  }
}

type Catalog = {
  org: {
    slug: string
    storeName: string  // not empty
    banner: string | null
  }
  stats: Stats
  featured: {
    appOfTheWeek: AppDetail | null
    staffPicks: AppSummary[]
  }
  categories: Omit<Category, 'description'>[]
  recentlyUpdated: AppSummary[]
  collections: Collection[]
  freshlyPublished: AppSummary[]
  creators: Creator[]
  bounties: Bounty[]
  popularSearches: string[]
}

type MyApps = {
  owned: AppSummary[]
  member: AppSummary[]
  installed: AppSummary[]
  drafts: AppSummary[]
}

type Featured = {
  appOfTheWeek: string | null  // matches ^[a-z0-9][a-z0-9-]{0,62}$
  staffPicks: string[]         // each matches ^[a-z0-9][a-z0-9-]{0,62}$
}

type InstallResult = {
  openUrl: string  // URL
}

type PublicApp = {
  slug: string        // matches ^[a-z0-9][a-z0-9-]{0,62}$
  name: string        // 1 to 80 characters
  tagline: string     // 1 to 140 characters; plain text, written for the public
  category: {
    slug: string  // matches ^[a-z0-9][a-z0-9-]{0,62}$
    name: string  // not empty
  } | null
  url: string | null  // URL; https only
  tone: 'violet' | 'blue' | 'teal' | 'green' | 'yellow' | 'orange' | 'red' | 'pink' | 'navy' | 'slate'
}

type PublicApps = {
  schemaVersion: 1
  store: {
    name: 'Zerg App Store'
    url: 'https://apps.zergai.com'
  }
  apps: PublicApp[]
}

type ListingDraft = {
  name: string           // matches ^[^\r\n]*$; 2 to 60 characters
  tagline: string        // matches ^[^\r\n]*$; 4 to 140 characters
  descriptionMd: string  // 20 to 6,000 characters
  kind: 'web' | 'chat' | 'workflow' | 'workflow_chat'
  categories: string[]   // 1 to 3 items; each one of this store's category slugs; empty when the store has none
  tags: string[]         // up to 8 items; each matches ^[a-z0-9][a-z0-9-]{0,31}$
  icon: {
    monogram: string  // matches ^[A-Z0-9]{1,3}$
    tone: 'violet' | 'blue' | 'teal' | 'green' | 'yellow' | 'orange' | 'red' | 'pink' | 'navy' | 'slate'
    imageUrl: null
  }
}
```
<!-- shapes:end -->

### Assistant event stream

`POST /api/v1/appstore/orgs/{org}/assistant/ask` answers `text/event-stream`.
Each `data:` line is one JSON event: `{"type":"delta","text":…}` (answer text),
`{"type":"citation","appSlug":…,"name":…}` (an app the answer used), then
exactly one `{"type":"done"}` or
`{"type":"error","code":"assistant_failed" | "router_account_required","message":…}`.
Lines starting with `:` keep the connection open. The answer is advisory and
nothing is saved.

## Errors

### Errors with a code

Errors the store raises carry a stable `data.code`. This is every one a person
or an agent can meet.

| Status | Code | Message | When |
| --- | --- | --- | --- |
| 401 | `authentication_required` | Authentication required | No signed-in session: a store or session route called without the person (a write gets this only when it carries an `Authorization: Bearer` header) |
| 401 | `invalid_tool_credential` | The tool credential is invalid or expired | An `Authorization: Bearer ast1.…` header that is not a live internal credential |
| 403 | `cross_origin_mutation` | Cross-origin mutation rejected | A write whose `Origin` is missing or is not the store's, without an `Authorization: Bearer` header; checked before anything else |
| 403 | `capability_denied` | Capability {capability} is required | The member's roles lack it; `data.capability` names it |
| 404 | `organization_not_found` | Organization not found | Unknown store, not a member, or malformed slug |
| 400 | `invalid_query` | The listing query is not valid | A search parameter is out of range; `data.issues` |
| 400 | `invalid_cursor` | The cursor is not valid for this listing | `cursor` from another listing or tampered |
| 404 | `app_not_found` | App not found | No such app in this store, or it is not visible to the member |
| 404 | `app_not_found` | Not found | A malformed app slug |
| 404 | `category_not_found` | Category not found | No such category in this store |
| 404 | `category_not_found` | Not found | A malformed category slug |
| 404 | `collection_not_found` | Collection not found | No such collection in this store |
| 404 | `collection_not_found` | Not found | A malformed collection slug |
| 428 | `idempotency_key_required` | This mutation requires an Idempotency-Key header | A catalog write without the header |
| 400 | `invalid_idempotency_key` | Idempotency-Key must be 1 to 128 printable ASCII characters | Malformed key |
| 422 | `idempotency_key_reused` | This Idempotency-Key was already used for a different request | Same key, different route or body |
| 409 | `idempotency_conflict` | A request with this Idempotency-Key is already in progress; retry it | Two requests with one key at once |
| 422 | `unknown_category` | Unknown categories | A draft names a category this store does not have; `data.categories` |
| 409 | `slug_unavailable` | Choose a more distinctive name for this app | The draft's name cannot be placed |
| 409 | `app_not_reviewable` | Only live apps can be reviewed | Review of a draft or unreleased app |
| 403 | `owner_cannot_review` | Owners cannot review their own app | Review by one of the app's owners |
| 409 | `review_exists` | You have already reviewed this app | A second review by the same member |
| 409 | `app_not_openable` | This app has no deployment to open yet | Get or open an app with no deployment |
| 428 | `precondition_required` | This update requires an If-Match header with the current ETag | `PUT /featured` without `If-Match` |
| 412 | `precondition_failed` | The featured selection changed; reload and try again | Stale `If-Match`; `data.etag` is current |
| 422 | `invalid_featured` | Staff picks must be at most 12 distinct apps | A staff pick is repeated |
| 422 | `invalid_featured` | Featured apps must be listed in the store | An unknown or unlisted slug; `data.slugs` |
| 422 | `invalid_featured` | The app of the week must be live | `appOfTheWeek` is not live; `data.slugs` |
| 400 | `if_match_invalid` | If-Match must be the ETag from GET /brand | Malformed `If-Match` on `PUT /brand` |
| 412 | `brand_conflict` | The brand kit changed since it was read | Stale `If-Match` on `PUT /brand` |
| 422 | `brand_invalid` | The brand kit is not valid | A value fails the brand kit rules, including the contrast check; `data.issues` |
| 503 | `assistant_unavailable` | The App Store assistant is not available on this server | The server's assistant is not configured (it runs through ZergRouter) |
| 429 | `rate_limited` | Too many assistant requests; try again shortly | Over the member's limit (by default 20 questions and 6 drafts per member, per server instance, every 10 minutes); `Retry-After` |
| 429 | `assistant_in_progress` | Your previous assistant request is still running | One assistant request per member at a time; `Retry-After` |
| 503 | `assistant_busy` | The assistant is busy; try again in a few seconds | Every assistant slot is in use; `Retry-After` |
| 403 | `identity_unverified` | Your Zerg identity could not be verified | The member's Zerg identity is not on record |
| 403 | `router_account_required` | The assistant runs on your own ZergRouter account. Sign in once at {routerBaseUrl}, then ask again. | Draft requests by a member without a ZergRouter account (the ask stream sends the same code as an event) |
| 502 | `catalog_unavailable` | The store catalog could not be read; try again shortly | A draft could not read the store's categories |
| 502 | `draft_invalid` | The assistant could not produce a valid listing; edit the brief and try again | The model's draft failed validation twice; `data.issues` |
| 502 | `assistant_failed` | The assistant could not draft this listing. Try again in a moment. | Any other draft failure |
| 404 | `feedback_unavailable` | Feedback capture is unavailable | Feedback capture is off |
| 401 | `browser_session_required` | A signed-in browser session is required | A feedback request with an `Authorization` header |
| 403 | `feedback_origin_denied` | Feedback origin is not allowed | A feedback request from another origin |
| 502 | `feedback_grant_failed` | Feedback capture could not start; try again shortly | Zergboard did not issue a grant |
| 502 | `feedback_recovery_failed` | Saved feedback could not be checked; try again shortly | Zergboard did not answer |
| 403 | `userinfo_unverified` | Zerg sign-in failed | Zerg could not confirm the person's account during sign-in |
| 502 | `token_exchange_failed` | Zerg sign-in failed | Zerg did not complete the sign-in code exchange |
| 502 | `provider_response_invalid` | Zerg sign-in failed | Zerg answered sign-in with an unreadable response |
| 502 | `userinfo_failed` | Zerg sign-in failed | Zerg did not return the person's account |
| 502 | `userinfo_invalid` | Zerg sign-in failed | Zerg returned an account the store cannot use |
| 403 | `account_inactive` | This account is disabled | The person's App Store account is disabled |
| 503 | `database_unconfigured` | The App Store database is not configured | The server is misconfigured; try again later |
| 503 | `sessions_unconfigured` | Sessions are not configured | The server is misconfigured; try again later |
| 503 | `sso_unconfigured` | Zerg sign-in is not configured | The server is misconfigured; try again later |

### Errors without a code

The framework sends these; they have no `data.code`.

| Status | `statusMessage` | `message` | When |
| --- | --- | --- | --- |
| 400 | Bad Request | Invalid JSON body | The body is not valid JSON; there is no `data` |
| 400 | Validation Error | The schema's issues, as JSON text | The JSON body does not match the route's request shape; `data` is `{"name":"ZodError","message":…}` |
| 404 | Page not found: {path} | Page not found: {path} | No route at this address for this method; `data.path` |
