VibeHost
Reference

Error codes

Every error code the API and CLI can return, what it means, and how to handle it.

Every 4xx response carries {ok: false, error: {code, message, details?}}. Branch on code (stable), not message (free-text, may change).

The CLI maps each HTTP status family to a documented exit code (see CLI reference). The mapping below summarizes the canonical pairings.

This page is generated. To add a new code, edit the canonical error-code list in the VibeHost source and run pnpm errors:generate.

Auth & sessions

CodeHTTPCLI exitMeaning
UNAUTHENTICATED4012
EMAIL_NOT_VERIFIED4032
TOKEN_NOT_FOUND4003Single-use token failure modes — shared between email-verification (#125) and password-reset (#126) by design. Both flows use the same hash-then-claim shape, both surface "expired / used / not found" as the only end-user-actionable failure modes, and the dashboard renders the sam
TOKEN_EXPIRED4003
TOKEN_ALREADY_USED4003
IDENTITY_PROVIDER_MISMATCH4093OAuth identity collision: the user already has an identity for this provider but with a different sub. Surfaces when, e.g., a user signs in via Google with a different Google account than the one previously linked to the same email. Caller should ask the user to unlink the exis
TURNSTILE_TOKEN_MISSING4003Cloudflare Turnstile gate rejected the signup attempt. Two codes so the HTTP status is consistent with the wire-level meaning: TURNSTILE_TOKEN_MISSING — request didn't include a token (400). TURNSTILE_FAILED — token was rejected by siteverify (403). The CLI matches on either to s
TURNSTILE_FAILED4032
REAUTH_REQUIRED4012Sensitive auth-change endpoint (e.g. POST /account/set-password) requires a recent re-authentication on the current session within REAUTH_WINDOW_MS (5 min). Outside the window the route returns 401 with this code so the dashboard can prompt for a fresh login and the CLI can exit
PASSWORD_ALREADY_SET4093POST /account/set-password called on a user whose passwordHash is already set. Use /account/change-password instead. 409.
INVALID_CREDENTIALS4012POST /account/change-password supplied wrong oldPassword. 401. Distinct from UNAUTHENTICATED (which means "no/expired session") so the dashboard can show "wrong current password" without bouncing the user to /login. Same code path as login's bad-creds — also bumps auth.login.fail
PASSWORD_SAME_AS_OLD4093POST /account/change-password where newPassword === oldPassword. Cheap defence against accidental no-op submissions (and against a confused user thinking the form took it but it actually didn't change anything). 409.
WEBAUTHN_REGISTRATION_FAILED4003WebAuthn / passkey error codes (#193). The shapes split by HTTP status so the dashboard can distinguish "your authenticator gave a bad signature" from "we never registered that credential". 400 — registration response failed structural / cryptographic verification (bad attestatio
WEBAUTHN_VERIFICATION_FAILED4012401 — login response failed verification (bad signature, challenge mismatch, counter regression). Also returned for the silent "credential was revoked" case, since revealing "this credential exists but we revoked it" is a useful enumeration signal we'd rather not give an attacker
WEBAUTHN_CREDENTIAL_NOT_FOUND4043404 — credentialId in the assertion is unknown to us. The lookup in finishLogin filters revoked_at IS NULL, so two cases collapse here: credential never registered AND credential revoked. This is intentional — both should look identical to the caller so an attacker can't distin
RECOVERY_CODE_INVALID4012401 — recovery code didn't match any active row for the asserted email, OR the email itself doesn't exist. Both branches return the same code (and matched-work latency in the service) so the attacker can't enumerate users via the recovery-code endpoint.

RBAC & grants

CodeHTTPCLI exitMeaning
FORBIDDEN4032
INVALID_GRANT_TARGET4003Workspace / RBAC-related.
CROSS_WORKSPACE_GRANT4003
LAST_OWNER4093
SELF_GRANT_FORBIDDEN4032
TOKEN_SCOPE4032
TOKEN_WORKSPACE_MISMATCH4032Token's workspaceId doesn't match the target workspace. Distinct from TOKEN_SCOPE (wrong permission level) so callers can fail loud rather than silently retrying when the user's config points at the wrong host.
TOKEN_TEAM_MISMATCH4032SP-2.5 analogue: token's teamId doesn't match the target team. PATs are minted with a specific teamId; using one against a different team (even one the holder is a member of) is a scope-broadening attempt and gets 403 rather than silently widening. Browser sessions are exempt — t
NOT_PLATFORM_ADMIN4032Platform-admin gate (apps/admin / /admin/v1/*). Distinct from the generic FORBIDDEN so the admin frontend can render /forbidden with copy pointing at the platform:grant CLI, and audit dashboards can isolate "non-admin tried to hit the admin namespace" from ordinary cross-team acc
PLATFORM_VIEWER_READONLY4032Platform admin with role='viewer' attempted a non-GET on /admin/v1/*. Phase 1 has no writes so this is mostly defensive, but the middleware enforces it now so adding a write endpoint later doesn't accidentally let viewers through.
ACCESS_REQUEST_PENDING4093Request-access flow (Google-Drive-style 403 → "Request access"). 409 — a pending request from this requester for this app already exists; the partial unique on (app_id, requester_user_id) WHERE status='pending' is the source of truth. Service surfaces this so the dashboard can di
ACCESS_REQUEST_ALREADY_GRANTED4093409 — requester already has a grant on this app (most-recent request was approved). Distinguishes the "you already have access" path from the throttle errors so the UI can reason about it.
ACCESS_REQUEST_COOLDOWN4296429 — most-recent request was denied within DENY_COOLDOWN_MS (24h). Body includes details.retryAt so the UI can render a countdown without parsing the message.
BROWSER_SESSION_REQUIRED4032── Browser-session gate (deepsec #389, epic C) ────────────────── 403: route is dashboard-only ("authorized apps" settings page, session-management endpoints, etc.) and must reject every non-browser credential class — CLI / device-flow tokens, MCP OAuth bearers, PATs — even when
USER_NOT_FOUND4043404: CLI's --user-email flag couldn't resolve the email to a user id in the target workspace. Distinct from generic NOT_FOUND so the CLI can render a precise hint ("That email isn't a member of <workspace>.") without string-matching.

Personal access tokens

CodeHTTPCLI exitMeaning
PAT_NOT_ALLOWED4012── Personal Access Tokens (#PAT) ───────────────────────────────── 401: route doesn't accept PATs (e.g. the PAT management endpoints themselves — preventing privilege escalation where a leaked read-only PAT mints a full-access one).
PAT_SCOPE_INSUFFICIENT4032403: PAT presented a valid token but lacks the required action scope. details: { required: PatScope }.
PAT_RESOURCE_NOT_ALLOWED4032403: PAT is restricted to specific resource IDs and the target isn't one of them (or the underlying resource was deleted after the PAT was issued). details: { resourceType, resourceId, allowed }.
PAT_CAP_REACHED4093409: user already has the max active PATs (20) in this workspace.
PAT_INVALID_SCOPE4003400: scope string in create request isn't a member of PAT_SCOPES. Reached only when a malformed client bypasses the typed enum; normal callers fail at Zod parse.
PAT_INVALID_RESOURCE_CONSTRAINT4003400: resources references a resource type without the matching action scope, or full-access PAT (scopes: null) tried to set resources.
PAT_INVALID_RESOURCE_ID4003400: resources.apps[i] is not an app in this workspace.

Resources & quota

CodeHTTPCLI exitMeaning
NOT_FOUND4043
CONFLICT4093
RATE_LIMITED4296
QUOTA_EXCEEDED4023
PLAN_LIMIT_EXCEEDED4023
OG_WATERMARK_TOGGLE_REQUIRES_PAID4023Removing the OG watermark (disabled=true) requires a paid plan. Setting disabled=false (re-enabling the watermark) is always allowed.
SERVICE_UNAVAILABLE5034503 — a stack-level secret the route depends on is missing or in a transitional state, so the request would fail in a way that retrying later (once the operator finishes configuring it) succeeds. Examples: env-var writes when ENV_VAR_ENCRYPTION_KEY hasn't been set yet (pre-rollou
INTERNAL5001
CONCURRENT_MODIFICATION4093Your request raced with another writer on the same record. Retry the request — the conflict is transient.
INVALID_CURSOR4003Cursor-paginated list endpoints: the cursor query param could not be decoded or its sort key doesn't match the current sort param. 400 — caller should discard the cursor and start from page 1.
EXPORT_LIMIT_EXCEEDED4003400: audit-log export filter matched more rows than the export endpoint will stream in a single response (default 10k). The caller should narrow the date range (since/until) and retry. Distinct from VALIDATION_FAILED so the dashboard/CLI can render a precise hint ("Filter matches

Deploy & runtime

CodeHTTPCLI exitMeaning
VALIDATION_FAILED4003
TARBALL_INVALID4003
UPSTREAM_FAILED5024
UNSUPPORTED_RUNTIME4003
RELEASE_UNAVAILABLE5034
RELEASE_GONE4104
DRAFT_FILE_NOT_FOUND4043MCP server-side draft file tools.
EDIT_NO_MATCH4003edit_file: oldString not present in the file.
EDIT_NOT_UNIQUE4003edit_file: oldString occurs >1 time and replaceAll was false.
CONTENT_TOO_LARGE4003create_file/edit_file: content exceeds the 1 MB per-file soft cap.

Custom domains

CodeHTTPCLI exitMeaning
RECLAIM_LOST_RACE4093Custom-domain reclaim race: two workspaces both passed DNS verification for the same hostname concurrently and the loser's commitReclaim hit the partial unique index. Distinct from a plain CONFLICT so CLI / agents can recognise it ("the other side just won; refetching will show y
DOMAIN_VERIFY_RECORD_NOT_FOUND4003Custom-domain verify() failure granularity. Pre-#866 these all collapsed into VALIDATION_FAILED with the same human message, which made the "why isn't this working?" customer-support surface bigger than it needed to be. Each code maps to one distinct root cause + actionable next
DOMAIN_VERIFY_RECORD_MISMATCH4003
DOMAIN_VERIFY_DNS_TIMEOUT4003

Skill registry

CodeHTTPCLI exitMeaning
INSTALL_TOKEN_NOT_FOUND4043Skill install-token failure modes. The revoked/expired split is deliberate — the CLI's native fetcher silently re-mints on INSTALL_TOKEN_EXPIRED but fails loudly on INSTALL_TOKEN_REVOKED. See spec docs/superpowers/specs/2026-05-13-vibehost-skill-registry-design.md for the retry-v
INSTALL_TOKEN_REVOKED4032
INSTALL_TOKEN_EXPIRED4104
INSTALL_TOKEN_WORKSPACE_MISMATCH4032Token's stored workspaceId doesn't match the request's host → someone tried to replay the token at a different workspace's hostname. Distinct from REVOKED / NOT_FOUND so CLI retry logic can fail loud (a host mismatch is not an "out-of-date hostname, refresh me" condition; the use
INSTALL_TOKEN_QUOTA_EXCEEDED4023
INSTALL_URL_ADMIN_ONLY4032POST /install-tokens called with forEmail by a non-admin.
INSTALL_URL_FOR_NON_MEMBER4003forEmail resolved to no workspace-member user. Admin-on-behalf-of can only mint for someone already in the workspace; minting for an outsider would create an orphan token.

Redeem & referral

CodeHTTPCLI exitMeaning
REDEEM_INVALID4003Redeem code (#553). Distinct codes so the CLI / dashboard can show a precise message and so funnel analytics can bucket failure reasons per batch.
REDEEM_NOT_YET_VALID4003
REDEEM_EXPIRED4104
REDEEM_EXHAUSTED4093
REDEEM_DUPLICATE4093
REDEEM_RATE_LIMITED4296
REDEEM_NOT_FOUND_OR_REVOKED4043Admin revoke: code not found or already soft-deleted.
REFERRAL_INVALID4003Referral binding (#548). Split so the bind endpoint can return a precise reason (invalid code / self-referral / already bound / rate limited) for funnel analytics, mirroring the REDEEM_* split.
REFERRAL_SELF4003
REFERRAL_ALREADY_BOUND4093
REFERRAL_RATE_LIMITED4296
REFERRAL_CODE_TAKEN4093
REFERRAL_CODE_INVALID4003
REFERRAL_CODE_LIMIT4093
REFERRAL_RENAME_RATE_LIMITED4296
REFERRAL_CREDIT_NOT_FOUND4043Referral credit claim (#referral-claim). Split so the claim endpoint returns a precise reason for the dashboard / CLI.
REFERRAL_CREDIT_ALREADY_CLAIMED4093
REFERRAL_CREDIT_REVOKED4104
REFERRAL_CLAIM_FORBIDDEN4032
REFERRAL_REWARD_APPLY_FAILED5001Referral reward coupon could not be applied to Stripe during claim (Stripe upstream error). 502 — claim CAS compensates; caller retries.

Generic

CodeHTTPCLI exitMeaning
ROOT_NOT_AN_ENDPOINT4043Distinct from generic NOT_FOUND so agents that hit the api host root (GET /, POST /, …) can tell "you reached the right host, but / is not an endpoint" apart from "this specific resource doesn't exist". The error.details payload doubles as an agent-discovery signpost (docs
WORKSPACE_NOT_FOUND4043Workspace-specific not-found, distinct from generic NOT_FOUND so the admin MCP onboarding tools can tell "no workspace matched this ref/id" apart from other not-found resources (the composite onboard flow branches on it to prompt the customer to sign up).
SECRET_DETECTED4003A deploy upload was blocked because the secret scanner (issue #1407) found a high-confidence leaked credential (API key / private key) in the files that would be publicly served. 400. The deployer can re-run with --allow-secrets (header x-allow-secrets: true / manifest body `
FROZEN4032Workspace is frozen by a platform admin — deploys are refused (403).
GRANT_REQUIRES_WORKSPACE_MEMBERSHIP4003A deployer/admin email grant was attempted for someone who is not a member of the app's workspace. Those roles are inert for non-members (every management route is workspace-membership-gated), so we reject rather than silently create a dead grant. Viewer grants are allowed for an
PIN_LIMIT_REACHED4093A user can PIN (folder-file) at most PIN_LIMIT apps across all their workspaces; pinning beyond the cap returns this 409. Re-pinning an already-pinned app, and MOVING a pin between folders, are idempotent and never trip it. Replaces the retired STAR_LIMIT_REACHED.
FOLDER_LIMIT_REACHED4093A user can have at most FOLDER_LIMIT folders per (user, workspace); creating beyond the cap returns this 409.
FOLDER_NOT_EMPTY4093A DELETE was attempted on a workspace-shared folder that still has member apps (apps.folderId points at it). RESTRICT semantics — move the apps to another folder first. Surfaced before the DB-level ON DELETE RESTRICT FK so callers get a clean, actionable error instead of an opaqu
COMMENTS_DISABLED4043── Comment mode (Phase 1) ────────────────────────────────────── Master env switch (COMMENTS_ENABLED) is off, or the per-app toggle write was attempted while the feature is globally disabled.
COMMENT_THREAD_NOT_FOUND4043Unified not-found for thread lookups (Hard Rule #8 fail-mode convention — cross-app probing must be indistinguishable).
COMMENT_BODY_TOO_LONG4003
COMMENT_RATE_LIMITED4296
COMMENT_MESSAGE_NOT_FOUND4043
COMMENT_NOT_AUTHOR4032
COMMENT_ROOT_DELETE4093Per-message DELETE was aimed at the thread's root message — delete the whole thread instead.
COMMENT_ACCESS_DENIED4032Signed-in but no comment access (spec decision 3: commenting needs a membership/grant path, not just view access). Carries no info the caller can't already see in the pill state.
CAMPAIGN_INACTIVE4104Campaign deploy challenge (#campaign). Split so the claim endpoint returns a precise reason for the dashboard / CLI.
CAMPAIGN_NOT_ENROLLED4032
CAMPAIGN_CODE_REVOKED4104
CAMPAIGN_NOT_QUALIFIED4093
CAMPAIGN_ALREADY_CLAIMED4093
CAMPAIGN_REDEEM_CODE_NOT_FOUND4003Admin campaign create/update: the enroll-gate value matched neither an existing redeem-code id nor a plaintext code.
WORKSPACE_PROVISIONED_COMP_FAILED4093admin_provision_customer_workspace created the workspace but the follow-up Business comp failed (the two are not one transaction). Distinct from CONFLICT (a slug clash → retry with a new slug) because the recovery action is the OPPOSITE: do NOT re-run the tool (the slug now exist
DB_PROVISION_FAILED5024Managed-database (Neon) provisioning failed: the provider API errored, or NEON_API_KEY is unset (fail closed). 502.
DB_ALREADY_EXISTS4093The app already has a live managed database. Phase 1 allows one database per app. 409.
DB_NOT_FOUND4043No managed database is bound to this app (status/claim/delete). 404.
DB_ENV_VAR_CONFLICT4093db create found a user-set DATABASE_URL / DATABASE_URL_UNPOOLED that provisioning would clobber. The user must remove it first. 409.
DB_NOT_READY4093The managed database exists but isn't claimable yet (still provisioning, or errored mid-provision). 409.
ENV_VAR_MANAGED4093A vibehost env set/rm targeted a platform-managed env var (managed_by != 'user', e.g. the injected DATABASE_URL). Users can't edit these directly. 409.
STORAGE_PROVISION_FAILED5024Provider-agnostic storage codes (db/kv/blob). The DB_* codes above are retained for back-compat but new routes emit these. Provisioning failed: provider API errored, or the provider key is unset (fail closed). 502.
STORAGE_ALREADY_EXISTS4093The app already has a live resource for this (provider, prefix). 409.
STORAGE_NOT_FOUND4043No matching managed resource on this app (status/claim/delete). 404.
STORAGE_ENV_VAR_CONFLICT4093create found a user-set env var the resource would inject. 409.
STORAGE_NOT_READY4093The resource exists but isn't claimable yet (provisioning/errored). 409.
STORAGE_NOT_CLAIMABLE4093The provider doesn't support claiming (Upstash api-key, R2). 409.
STORAGE_DISABLED4043Managed-storage feature is gated off (STORAGE_ENABLED unset/false). The whole surface returns this as a 404 so a disabled feature is indistinguishable from "not a thing" (no oracle). 404.
SHEET_DB_DISABLED4043Sheet-db Phase 1 — Google Sheets as a read-only queryable provider. Feature gate: SHEET_DB_ENABLED. 404 (oracle-safe, same pattern as STORAGE_DISABLED).
SHEET_CONNECTION_NOT_FOUND4043The referenced Google Sheets connection (by id) doesn't exist for this workspace. 404.
SHEET_NOT_FOUND4043The referenced sheet (spreadsheet) was not found (deleted on Google's side, or the connection lost access). 404.
SHEET_TOO_LARGE4223The spreadsheet exceeds the size limit that can be safely cached / queried in a single request. 422.
SHEET_TAB_NOT_FOUND4043The named tab (sheet within a spreadsheet) doesn't exist. 404.
SHEET_TAB_NOT_EXPOSED4043The tab exists but has not been exposed via the connection config. 404.
SHEET_COLUMN_NOT_FOUND4223A column referenced in the query doesn't exist in the tab's schema. 422.
SHEET_ROW_NOT_FOUND4043Phase 2 writes: where-clause row location outcomes.
SHEET_ROW_NOT_UNIQUE4093
SHEET_COLUMN_NOT_EXPOSED4223Body or where referenced a column that isn't exposed on the tab.
SHEET_DUPLICATE_HEADER4223tabs expose: the tab's header row repeats a column name. columnMap is keyed by name, so a duplicate would silently shadow the earlier same-named column (unreachable for read/filter/where). Reject rather than store ambiguity.
SQL_UNSUPPORTED4223The SQL dialect feature used is not supported by the GViz translator (e.g. subqueries, CTEs). 422.
QUERY_INVALID4223The query could not be parsed or is structurally invalid. 422.
GOOGLE_AUTH_DENIED4032The user declined the Google OAuth consent screen. 403.
GOOGLE_AUTH_EXPIRED4012The stored Google OAuth refresh token has expired or been revoked. 401.
GOOGLE_SCOPE_INSUFFICIENT4032The token's granted scopes don't cover the required Google Sheets / Drive scope. 403.
GOOGLE_RATE_LIMITED5034Google Sheets API returned 429 (rate limit / quota). 429.
SHEET_API_KEY_INVALID4012The provided API key is malformed or does not match the stored hash. 401.
SHEET_API_KEY_NOT_FOUND4043No API key exists for this connection. 404.
SCHEMA_DRIFT4093The cached column schema no longer matches the live sheet (structural change detected). Callers should prompt a re-sync. 409.

On this page