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.
SCOPED_PAT_DISABLED4093409: scoped PAT issuance is disabled server-side. Operator must set SCOPED_PAT_ENFORCEMENT_ENABLED=true once all PAT-callable routes have been retrofitted with requirePatScope (Dashboard PR 3 prereq). Until then, full-access PATs (scopes: null / scopes omitted) still work; only r

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

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

On this page