{"openapi":"3.1.0","info":{"title":"Charming API","version":"0.1.0","description":"Public HTTP surface for the Charming app runtime. The narrative agent guide lives at https://usecharming.com/llms-full.txt; this spec is the machine-readable source of truth for routes, request/response shapes, and error kinds.\n\nTimeout SLAs (`info[\"x-buildy-sla\"]` + per-operation `x-buildy-timeout-ms`) are documentation only — they describe the targets the server tries to hit, not enforced ceilings. Use them to set sensible client read timeouts, not as hard guarantees.","x-buildy-sla":{"read_ms":2000,"mutate_ms":10000,"call_ms":30000,"description":"Target SLAs the server tries to hit, in milliseconds. `read_ms` covers GET reads (source, describe, diag, activity, list, resolve). `mutate_ms` covers POST/PUT/PATCH/DELETE writes (create, update, source patch, delete, claim, secrets, remixable, starter-prompt, icon, feedback). `call_ms` covers `POST /app/{id}/api/{operation}` (the executor app-code budget). These are targets, not enforced ceilings — see `info.description`. Per-operation overrides ride on the operation object as `x-buildy-timeout-ms`."}},"servers":[{"url":"https://charm.ing"}],"components":{"securitySchemes":{"appToken":{"type":"http","scheme":"bearer","bearerFormat":"bld_app_*","description":"Per-app token returned once by `POST /app` for anonymous creates. Authorises mutations and `/api/*` calls on that one app only."},"userToken":{"type":"http","scheme":"bearer","bearerFormat":"bld_user_*","description":"User-scoped personal access token. Mint via `POST /api/token` after signing in, or via the device-pairing flow at `POST /api/pair/start`. Authorises everything the user can do."},"renderToken":{"type":"http","scheme":"bearer","bearerFormat":"bld_render_*","description":"Short-lived per-app token used by null-origin sandboxed iframes (Claude Desktop, ChatGPT Apps) to call back into the app without cookies. Minted server-side at render time and embedded in the runtime closure; not user-mintable. Bound to one app id."},"renderTokenQuery":{"type":"apiKey","in":"query","name":"t","description":"URL-signed render token, equivalent to `renderToken` but carried in the query string for GET /app/{id} loads where iframes cannot attach Authorization headers (the MCP viewer fetch+srcdoc path). Bound to one app id; expires after ~24 hours."}},"headers":{"RateLimit-Limit":{"description":"Total capacity of the most-restrictive bucket gating this request (draft IETF `RateLimit` header field). Pair with `RateLimit-Remaining` and `RateLimit-Reset` to project headroom; the legacy `X-RateLimit-Limit` alias carries the same value for older agent tooling.","schema":{"type":"integer","minimum":0}},"RateLimit-Remaining":{"description":"Whole tokens still available in the most-restrictive bucket. Floored, never negative. Drops to `0` on the response that exhausts the bucket.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until the next whole token is available in the most-restrictive bucket. `0` while remaining > 0. On a 429 this equals `Retry-After`.","schema":{"type":"integer","minimum":0}},"X-RateLimit-Limit":{"description":"Legacy alias of `RateLimit-Limit` — same value, kept for older tooling.","schema":{"type":"integer","minimum":0}},"X-RateLimit-Remaining":{"description":"Legacy alias of `RateLimit-Remaining` — same value, kept for older tooling.","schema":{"type":"integer","minimum":0}},"X-RateLimit-Reset":{"description":"Legacy alias of `RateLimit-Reset` — same value, kept for older tooling.","schema":{"type":"integer","minimum":0}},"Retry-After":{"description":"Integer seconds the client should wait before retrying. Mirrors `RateLimit-Reset` on 429 so generic HTTP clients can back off without parsing the body.","schema":{"type":"integer","minimum":0}}},"schemas":{"Error":{"type":"object","required":["ok","error"],"properties":{"ok":{"type":"boolean","enum":[false]},"error":{"type":"object","required":["kind"],"properties":{"kind":{"type":"string","description":"Stable enum-shaped error key. Branch on this for recovery flows.","enum":["not_found","unknown_operation","operation_not_found","operation_failed","unauthorized","token_expired","forbidden","forbidden_write","storage_quota_exceeded","invalid_module","invalid_ui","invalid_route_schema","invalid_request","invalid_input","invalid_output","unexpected_keys","capability_denied","capability_error","executor_error","invalid_call_shape","already_claimed","duplicate_remix","manifest_not_found","payload_too_large","module_too_large","ui_too_large","description_too_long","missing_description","invalid_description","rate_limited","too_many_requests","expired","already_approved","anon_not_remixable","invalid_if_match","precondition_required","version_mismatch","old_string_not_found","old_string_not_unique","edits_too_large","manifest_id_conflict","manifest_id_immutable","text_too_large","structured_data_too_large","app_not_found","asset_too_large","asset_count_exceeded","asset_quota_exceeded","asset_invalid_key","asset_invalid_content_type","asset_error","invalid_id","sse_cookie_expired","invalid_secret_name","secret_too_large","secret_already_exists","invalid_icon","team_slug_taken","already_member","grantee_not_found","invite_cap_reached","read_only","handle_taken","invalid_handle","name_taken","app_unclaimed","grantee_is_owner","already_invited"]},"message":{"type":"string"},"reason":{"type":"string","description":"Set on 401s to distinguish token failure modes when finer detail is needed. `token_revoked_post_claim` is the post-#1284 surface for a bld_app_* that was invalidated by a claim — the `recovery` field on the same `error` object carries the self-contained pairing-recovery hint the agent should follow instead of creating a new app.","enum":["token_malformed","token_unknown","token_revoked","token_revoked_post_claim"]},"recovery":{"description":"Self-contained recovery hint, branching on `kind`. Attached to error envelopes the agent can self-correct on: `pair` for `token_revoked_post_claim` 401s (#1284), `retry` for 429s, `shrink` for too-large / quota-exceeded 413s, `refetch` for 412 `version_mismatch` / `invalid_if_match` / 428 `precondition_required`, `fix_route_label` for 403 `forbidden_write`, `open_existing` for 409 `duplicate_remix`, and `rename` for 409 `manifest_id_immutable` / `manifest_id_conflict`. Every `instructions` string is a single-paragraph, action-first walk-through. Inlined per ADR 2026-05-06-agent-facing-error-surfaces-self-contain-the-fix.","oneOf":[{"$ref":"#/components/schemas/RecoveryPair"},{"$ref":"#/components/schemas/RecoveryRetry"},{"$ref":"#/components/schemas/RecoveryShrink"},{"$ref":"#/components/schemas/RecoveryRefetch"},{"$ref":"#/components/schemas/RecoveryFixRouteLabel"},{"$ref":"#/components/schemas/RecoveryOpenExisting"},{"$ref":"#/components/schemas/RecoveryRename"}],"discriminator":{"propertyName":"kind"}}},"additionalProperties":true}}},"RecoveryPair":{"type":"object","required":["kind","start","verification_url","instructions"],"properties":{"kind":{"type":"string","enum":["pair"]},"start":{"type":"string","format":"uri"},"verification_url":{"type":"string","format":"uri"},"instructions":{"type":"string"}},"additionalProperties":false},"RecoveryRetry":{"type":"object","required":["kind","retry_after_sec","retry_after_url","instructions"],"properties":{"kind":{"type":"string","enum":["retry"]},"retry_after_sec":{"type":"integer","minimum":0,"description":"Seconds to wait before retrying. Matches the `RateLimit-Reset` and `Retry-After` headers on the same response."},"retry_after_url":{"type":"string","format":"uri","description":"Absolute URL to retry verbatim once the wait elapses."},"instructions":{"type":"string"}},"additionalProperties":false},"RecoveryShrink":{"type":"object","required":["kind","field","size_bytes","cap_bytes","instructions"],"properties":{"kind":{"type":"string","enum":["shrink"]},"field":{"type":"string","enum":["module","ui","styles","description","text","structured_data","asset","edits","secret","payload"],"description":"Which input tripped the cap."},"size_bytes":{"type":"integer","minimum":0},"cap_bytes":{"type":"integer","minimum":0},"instructions":{"type":"string"}},"additionalProperties":false},"RecoveryRefetch":{"type":"object","required":["kind","refetch_url","instructions"],"properties":{"kind":{"type":"string","enum":["refetch"]},"refetch_url":{"type":"string","format":"uri","description":"Absolute URL to GET for the fresh ETag before retrying with a corrected If-Match."},"instructions":{"type":"string"}},"additionalProperties":false},"RecoveryFixRouteLabel":{"type":"object","required":["kind","instructions"],"properties":{"kind":{"type":"string","enum":["fix_route_label"]},"instructions":{"type":"string","description":"App-code recovery: the fix is in the route declaration, NOT in the request. Retrying the same request will fail again."}},"additionalProperties":false},"RecoveryOpenExisting":{"type":"object","required":["kind","existing_app_id","existing_url","instructions"],"properties":{"kind":{"type":"string","enum":["open_existing"]},"existing_app_id":{"type":"string"},"existing_url":{"type":"string","format":"uri"},"instructions":{"type":"string"}},"additionalProperties":false},"RecoveryRename":{"type":"object","required":["kind","instructions"],"properties":{"kind":{"type":"string","enum":["rename"]},"instructions":{"type":"string"}},"additionalProperties":false},"Icon":{"type":"object","description":"Optional home-screen / favicon icon. The server composes a colored rounded-square PNG/SVG from `emoji + bg` — NOT a list of image URLs like a W3C web manifest. Omit it to get the default brick. If `emoji` or `bg` is invalid the whole icon is silently coerced to the default and the create/update response carries a `warnings[]` entry.","required":["emoji","bg"],"properties":{"emoji":{"type":"string","description":"A single emoji, rendered centered (e.g. `⚽`). Extra glyphs are dropped."},"bg":{"type":"string","pattern":"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$","description":"Background color as a hex string (`#rgb`, `#rrggbb`, or `#rrggbbaa`). Named colors (`\"green\"`), `rgb()`/`hsl()`, and image URLs are rejected."}},"additionalProperties":false},"Manifest":{"type":"object","required":["id"],"properties":{"id":{"type":"string","description":"Short kebab-case identifier; passed to `window.buildy.app(...)`."},"version":{"type":"string"},"displayName":{"type":"string"},"icon":{"$ref":"#/components/schemas/Icon"},"capabilities":{"type":"object","properties":{"imports":{"type":"array","items":{"type":"string","enum":["buildy:storage/kv@1.0","buildy:storage/blob@1.0","buildy:logging/emit@1.0"]}},"exports":{"type":"array","items":{"type":"string"}}}}}},"AppCreateBody":{"type":"object","required":["module"],"properties":{"module":{"type":"string","description":"ES module source. Must export `manifest` (literal) and `default.fetch`."},"ui":{"type":"string","description":"Optional inline JS program (classic script, not a module). Populates `#app`."},"styles":{"type":"string","description":"Optional CSS injected alongside `ui`."},"pair":{"type":"boolean","description":"Anonymous POST only — silently ignored on authenticated upserts. When true, server mints a bound device_code alongside the app token; user-claim auto-approves the pairing and the agent ends up with both an app token and a `bld_user_*`."},"label":{"type":"string","maxLength":80,"description":"Anonymous POST only — silently ignored on authenticated upserts. Human-readable label for the agent, surfaced to the user on the `/pair` approval page when `pair: true`."}},"additionalProperties":false},"AppUpdateBody":{"type":"object","required":["module"],"properties":{"module":{"type":"string"},"ui":{"type":"string"},"styles":{"type":"string"}},"additionalProperties":false},"AppCreateResponse":{"type":"object","required":["id","manifestId","displayName","url","capabilities","claimed"],"properties":{"id":{"type":"string","format":"uuid"},"manifestId":{"type":"string"},"displayName":{"type":"string"},"url":{"type":"string","format":"uri"},"capabilities":{"$ref":"#/components/schemas/Manifest/properties/capabilities"},"icon":{"anyOf":[{"$ref":"#/components/schemas/Icon"},{"type":"null"}]},"claimed":{"type":"boolean"},"token":{"type":"string","description":"Anonymous-create only. `bld_app_*` plaintext, returned exactly once — irrecoverable."},"expiresAt":{"type":"string","format":"date-time","description":"Anonymous-create only. 7-day TTL unless claimed."},"mcpHint":{"type":"object","properties":{"setupUrl":{"type":"string","format":"uri"},"snippets":{"type":"object","additionalProperties":{"type":"string"}}}},"pairing":{"type":"object","description":"Present only when the request body included `pair: true`. Agent should poll `POST /api/pair/poll` with `device_code` until status flips to `approved`.","properties":{"device_code":{"type":"string"},"user_code":{"type":"string","pattern":"^BLDY-[A-Z2-9]{6}$","description":"Show verbatim to the user; they type it on `verification_url`."},"verification_url":{"type":"string","format":"uri"},"polling_interval":{"type":"integer"},"expires_in":{"type":"integer"}}},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-blocking publish feedback (#1126), present only when static validation found UI/backend contract mismatches — e.g. the UI calls an operation this version does not expose. The write succeeded; fix by adding the backend op or renaming the UI call."}}},"AppUpdateResponse":{"type":"object","required":["id","manifestId","displayName","url","capabilities","claimed","version"],"properties":{"id":{"type":"string","format":"uuid"},"manifestId":{"type":"string"},"displayName":{"type":"string"},"description":{"type":["string","null"]},"url":{"type":"string","format":"uri"},"capabilities":{"$ref":"#/components/schemas/Manifest/properties/capabilities"},"icon":{"anyOf":[{"$ref":"#/components/schemas/Icon"},{"type":"null"}]},"claimed":{"type":"boolean"},"version":{"$ref":"#/components/schemas/MonotonicVersion"},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-blocking publish feedback (#1126), present only when static validation found UI/backend contract mismatches. The write succeeded."}}},"AppListResponse":{"type":"object","required":["apps"],"properties":{"apps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"role":{"type":"string","enum":["owner","collaborator","end-user","viewer","team-admin","team-member"],"description":"`owner` = your app. `collaborator` = shared with you via an accepted invite (edit and run, never share/delete/transfer). `end-user` = shared use-and-write-data (run the app and write its data, but cannot edit the app source). `viewer` = shared read-only. `team-admin` / `team-member` = owned by a team you belong to. Non-owner rows carry the UUID-form `url`, which redirects to the owner-canonical URL."},"manifestId":{"type":"string"},"displayName":{"type":"string"},"version":{"type":"string"},"capabilities":{"$ref":"#/components/schemas/Manifest/properties/capabilities"},"url":{"type":"string","format":"uri"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}}},"nextCursor":{"type":["string","null"],"description":"Pass back as `cursor` query param to fetch the next page."}}},"DescribeResponse":{"type":"object","required":["id","manifestId","displayName","version","capabilities","ui","styles","claimed","expiresAt","api","storageKeys"],"properties":{"id":{"type":"string","format":"uuid"},"manifestId":{"type":"string"},"displayName":{"type":"string"},"version":{"type":"string"},"capabilities":{"$ref":"#/components/schemas/Manifest/properties/capabilities"},"ui":{"type":"boolean"},"styles":{"type":"boolean"},"claimed":{"type":"boolean"},"expiresAt":{"type":["string","null"],"format":"date-time"},"api":{"type":"object","description":"Machine-readable app API summary for agents. Raw module source is intentionally omitted; operations are derived from manifest exports and literal /api/<operation> routes.","required":["baseUrl","operations"],"properties":{"baseUrl":{"type":"string","format":"uri"},"operations":{"type":"array","items":{"type":"object","required":["name","path","url","tool","discoveredFrom"],"properties":{"name":{"type":"string"},"path":{"type":"string"},"url":{"type":"string","format":"uri"},"tool":{"type":"string","enum":["query_app","mutate_app"],"description":"MCP tool to invoke this op: query_app for read-only, mutate_app otherwise."},"discoveredFrom":{"type":"array","items":{"type":"string","enum":["manifest_export","module_route"]}},"manifestExport":{"type":"string"}}}}}},"storageKeys":{"type":"array","items":{"type":"string"}}}},"MonotonicVersion":{"type":"integer","minimum":0,"description":"Monotonic per-app version (`appModules.currentVersion`). The optimistic-concurrency primitive: new rows seed at 1 and bump on every successful write; truly-legacy pre-migration rows read 0. Pass back as `If-Match: \"v<N>\"` (REST) or `expected_version` (MCP) to gate edits."},"AppSourceResponse":{"type":"object","required":["id","url","manifestId","sourceManifestId","displayName","description","version","appVersion","manifestVersion","capabilities","claimed","expiresAt","source"],"properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"manifestId":{"type":"string"},"sourceManifestId":{"type":"string"},"displayName":{"type":"string"},"description":{"type":["string","null"]},"version":{"$ref":"#/components/schemas/MonotonicVersion"},"appVersion":{"type":"string","description":"Author-declared version string for the app (whatever the manifest source set as `manifest.version`, e.g. '0.0.1'). Distinct from the concurrency `version` int and from `manifestVersion`."},"manifestVersion":{"type":"string","description":"Charming manifest contract date (`YYYY-MM-DD`) the app is pinned to. The runtime branches on this value to pick contract-specific behaviour."},"capabilities":{"$ref":"#/components/schemas/Manifest/properties/capabilities"},"claimed":{"type":"boolean"},"expiresAt":{"type":["string","null"],"format":"date-time"},"source":{"type":"object","required":["module","ui","styles"],"properties":{"module":{"type":"string"},"ui":{"type":["string","null"]},"styles":{"type":["string","null"]}}}}},"ActivityResponse":{"type":"object","required":["ok","value"],"properties":{"ok":{"type":"boolean","enum":[true]},"value":{"type":"object","required":["items"],"properties":{"items":{"type":"array","description":"Newest-first durable runtime-failure events for this app. Bounded by per-kind insert caps and a 90-day retention window.","items":{"type":"object","required":["ts","kind","version","summary","data"],"properties":{"ts":{"type":"string","format":"date-time"},"kind":{"type":"string","enum":["api_proxy_result","diag_report","contract_validation"]},"version":{"type":["integer","null"],"description":"App version the event is attributed to."},"summary":{"type":"string","description":"Compact one-line description of the event."},"data":{"type":"object","description":"Kind-specific redacted payload (no stacks, no tokens, no source bytes)."}}}}}}}},"DiagResponse":{"type":"object","properties":{"ok":{"type":"boolean","enum":[true]},"value":{"type":"object","properties":{"entries":{"type":"array","description":"Most-recent-first ring buffer (50-entry cap per app).","items":{"type":"object","properties":{"ts":{"type":"string","format":"date-time"},"message":{"type":"string"},"source":{"type":"string"},"line":{"type":"integer"},"col":{"type":"integer"},"stack":{"type":"string"}}}}}}}},"FeedbackSubmitBody":{"type":"object","required":["category"],"properties":{"category":{"type":"string","enum":["bug","crash","enhancement","praise","other"],"description":"Feedback category. `crash` requires `crashData` (or its snake_case alias) carrying at least `message`."},"source":{"type":"string","enum":["user","agent","auto-crash"],"description":"Optional origin tag. Defaults to `user`. The MCP `submit_feedback` tool forces `agent` server-side; direct REST callers may pass `user` / `auto-crash` explicitly."},"text":{"type":"string","maxLength":16384,"description":"Free-form feedback body. 16384-character cap; oversize bodies return `text_too_large` (413)."},"structuredData":{"type":"object","additionalProperties":true,"description":"Caller-supplied JSON payload (e.g. route, repro inputs). Serialized payload is capped at 65536 bytes; oversize payloads return `structured_data_too_large` (413). Snake_case alias `structured_data` is also accepted."},"crashData":{"type":"object","required":["message"],"properties":{"message":{"type":"string"},"stack":{"type":"string"},"url":{"type":"string"},"userAgent":{"type":"string"},"traceId":{"type":"string"}},"description":"Crash details. Required when `category=crash`; optional otherwise. Snake_case alias `crash_data` is also accepted."}}},"FeedbackSubmitResponse":{"type":"object","required":["ok","value"],"properties":{"ok":{"type":"boolean","enum":[true]},"value":{"type":"object","required":["id","createdAt"],"properties":{"id":{"type":"string","format":"uuid"},"createdAt":{"type":"string","format":"date-time"}}}}},"AppEventStateChanged":{"type":"object","required":["kind","appId","op","source","ts","result"],"description":"JSON payload of a `state-changed` SSE frame. Emitted after a write lands (`source: \"agent\"`) or as a synthetic catch-up signal (`source: \"reconnect-resync\"`, `op: null`, `result: null`) when the client reconnects with a stale `Last-Event-ID`.","properties":{"kind":{"type":"string","enum":["state-changed"]},"appId":{"type":"string"},"op":{"type":["string","null"],"description":"Operation name that produced the state change, or `null` on synthetic `reconnect-resync` events."},"source":{"type":"string","enum":["agent","reconnect-resync"],"description":"`agent` for writes routed through the runtime; `reconnect-resync` for the server-generated catch-up signal."},"ts":{"type":"integer","description":"Unix milliseconds at the time the frame was written."},"result":{"description":"Operation result body when JSON-shaped, `null` on `reconnect-resync` events and on operations whose response was non-JSON or exceeded the 32 KiB cap."}}},"AppEventOvercapacity":{"type":"object","required":["message"],"description":"JSON payload of the `overcapacity` SSE frame emitted when the per-app subscriber cap is reached. The server closes the stream immediately after writing this frame; clients should back off before retrying.","properties":{"message":{"type":"string","enum":["too_many_subscribers"]}}},"OkResponse":{"type":"object","required":["ok"],"description":"Minimal success acknowledgement. `ok: true` confirms the mutation landed.","properties":{"ok":{"type":"boolean","enum":[true]}}},"AppClaimResponse":{"type":"object","required":["ok","id","manifestId","claimedAt"],"description":"Acknowledgement returned by POST /app/{id}/claim once ownership transfers.","properties":{"ok":{"type":"boolean","enum":[true]},"id":{"type":"string","format":"uuid"},"manifestId":{"type":"string"},"claimedAt":{"type":"string","format":"date-time"}}},"AppPublicResponse":{"type":"object","required":["public","public_url"],"description":"New public state plus the public URL when public is true.","properties":{"public":{"type":"boolean"},"public_url":{"type":["string","null"],"description":"Public URL when `public: true` (friendly `/{handle}/{app-name}` form when available, `/app/{id}` otherwise). Anyone can open it with no login and read+write the shared data. `null` when `public: false`."}}},"AppRemixableResponse":{"type":"object","required":["remixable","public_url"],"description":"New remixable state plus the shareable URL when remixable is true.","properties":{"remixable":{"type":"boolean"},"public_url":{"type":["string","null"],"description":"Shareable URL when `remixable: true` (friendly `/{handle}/{app-name}` form when available, `/app/{id}` otherwise); `null` when `remixable: false`."}}},"AppStarterPromptResponse":{"type":"object","required":["starter_prompt"],"description":"New starter-prompt value (or `null` after clearing).","properties":{"starter_prompt":{"type":["string","null"]}}},"AppSecretListResponse":{"type":"object","required":["ok","names"],"description":"Owner-visible list of secret NAMES (values are never echoed).","properties":{"ok":{"type":"boolean","enum":[true]},"names":{"type":"array","items":{"type":"string"}}}},"ManifestResolveResponse":{"type":"object","required":["appId"],"description":"Resolved app id for the supplied manifestId in the caller’s scope. `appId` is omitted when no match exists (404).","properties":{"appId":{"type":"string","format":"uuid"}}},"AssetUploadResponse":{"type":"object","required":["ok","key","url","sizeBytes"],"description":"Confirmation that an asset was stored, with a signed URL for direct access.","properties":{"ok":{"type":"boolean","enum":[true]},"key":{"type":"string"},"url":{"type":"string","description":"Signed URL for direct access."},"sizeBytes":{"type":"integer"}}},"AssetListResponse":{"type":"object","required":["ok","assets"],"description":"Stored assets for the app (newest-first).","properties":{"ok":{"type":"boolean","enum":[true]},"assets":{"type":"array","items":{"type":"object","required":["key","contentType","sizeBytes"],"properties":{"key":{"type":"string"},"contentType":{"type":"string"},"sizeBytes":{"type":"integer"}}}}}},"PairStartResponse":{"type":"object","required":["device_code","user_code","verification_url","polling_interval","expires_in"],"description":"RFC 8628-shaped device-code response. Poll `POST /api/pair/poll` until the user approves.","properties":{"device_code":{"type":"string","description":"Agent-private — store securely, never display or log. Anyone with the device_code can poll for the token."},"user_code":{"type":"string","pattern":"^BLDY-[A-Z2-9]{6}$"},"verification_url":{"type":"string","format":"uri"},"verification_url_complete":{"type":"string","format":"uri"},"polling_interval":{"type":"integer"},"expires_in":{"type":"integer"}}},"PairPollResponse":{"type":"object","required":["status"],"description":"Poll result for a previously-minted device_code. `token` is delivered exactly once.","properties":{"status":{"type":"string","enum":["pending","expired","approved"]},"token":{"type":"string","description":"Only on the first poll after approval. `bld_user_*` plaintext, irrecoverable."},"tokenPrefix":{"type":"string"},"already_delivered":{"type":"boolean"}}},"TokenCreateResponse":{"type":"object","required":["token","tokenPrefix","expiresAt"],"description":"Minted personal access token. `token` plaintext is returned exactly once.","properties":{"token":{"type":"string"},"tokenPrefix":{"type":"string"},"label":{"type":["string","null"]},"expiresAt":{"type":"string","format":"date-time"}}},"TokenInfo":{"type":"object","required":["id","tokenPrefix","expiresAt","createdAt"],"description":"Public metadata for one personal access token row. Plaintext and hash are never echoed.","properties":{"id":{"type":"string","format":"uuid"},"tokenPrefix":{"type":"string"},"label":{"type":["string","null"]},"expiresAt":{"type":"string","format":"date-time"},"lastUsedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"}}},"TokenListResponse":{"type":"object","required":["tokens"],"description":"List of the caller's personal access tokens.","properties":{"tokens":{"type":"array","items":{"$ref":"#/components/schemas/TokenInfo"}}}}}},"paths":{"/app":{"post":{"operationId":"create-app","summary":"Create an app (anonymous or authenticated upsert)","description":"Anonymous callers get a fresh app row with a `bld_app_*` token in the response. Authenticated callers (`bld_user_*` or session) upsert by `(userId, manifestId)` — second POSTs with the same `manifest.id` overwrite the existing row in place and the response includes the same `id`. Use `PUT /app/{id}` for deliberate updates against a known id. Pass `pair: true` to mint a bound device_code in the same call.","security":[{},{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppCreateBody"}}}},"responses":{"201":{"description":"App created or upserted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppCreateResponse"}}}},"400":{"description":"Invalid module / ui / unexpected body keys / capability_denied. See `error.kind`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"operationId":"list-apps","summary":"List the authenticated user's apps — owned plus shared-with-them (newest first)","description":"Paginated newest-first listing of apps the caller owns or has been granted access to. Use `cursor` (from the previous page) and `limit` to iterate. Each row carries the `role` (owner | collaborator | end-user | viewer | team-admin | team-member) and a UUID-form `url` that redirects to the friendly owner-canonical URL.","security":[{"userToken":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor returned as `nextCursor` from the previous page."}],"responses":{"200":{"description":"Page of apps","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppListResponse"}}}},"401":{"description":"Listing requires user-scoped auth (`bld_user_*` or session).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"put":{"operationId":"update-app","summary":"Update an existing app","description":"Storage survives updates. Iterate freely. Optional `If-Match: \"v<N>\"` (from a prior GET /source ETag) gates the write with optimistic concurrency — when present and stale, the response is 412 with the current ETag in the response header. Omitting `If-Match` retains pre-existing PUT semantics.","security":[{"appToken":[]},{"userToken":[]}],"parameters":[{"name":"If-Match","in":"header","required":false,"schema":{"type":"string"},"description":"Optional strong validator `\"v<N>\"` from a prior GET /app/:id/source. When supplied, the PUT only succeeds when this equals the current version."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppUpdateBody"}}}},"responses":{"200":{"description":"Updated","headers":{"ETag":{"description":"New strong validator `\"v<N>\"` after the update committed.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppUpdateResponse"}}}},"400":{"description":"Invalid module/ui/styles, unexpected body keys, or malformed If-Match.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Token mismatch or wrong scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Bearer is for a different app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"412":{"description":"If-Match did not match the current version; response carries the current ETag.","headers":{"ETag":{"description":"Current strong validator `\"v<N>\"`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"delete-app","summary":"Delete an app","description":"Permanently removes the app row. Bearer must authorize this app (`bld_app_*` for the same id, or the owning user). Pass `?purge=storage` to also wipe the app’s key-value rows; without it, the storage table is left as-is for forensic readability.","security":[{"appToken":[]},{"userToken":[]}],"parameters":[{"name":"purge","in":"query","schema":{"type":"string","enum":["storage"]},"description":"Pass `?purge=storage` to also wipe the app’s key-value rows."}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Bearer is for a different app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/api/{operation}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"operation","in":"path","required":true,"schema":{"type":"string"}}],"post":{"operationId":"call-app-operation","summary":"Call an operation defined inside the app's `fetch` handler","description":"External callers must use Bearer auth. The platform strips `/app/<id>` so the app sees `/api/<operation>`. App authors should follow the `{ ok, value | error }` JSON contract. The in-page UI uses `renderToken` automatically via the runtime closure — direct callers should use `appToken` or `userToken`.","security":[{"appToken":[]},{"userToken":[]},{"renderToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"Whatever the app returns. The conventional shape is `{ ok, value }`.","content":{"application/json":{"schema":{"type":"object","description":"Pass-through body returned by the app’s `fetch` / route handler. Apps SHOULD follow the `{ ok: true, value }` / `{ ok: false, error: { kind, message } }` envelope, but the shape is author-defined.","additionalProperties":true}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Either the app id is unknown, or the app has no handler for that operation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"App handler threw or violated the capability contract.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/claim":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"post":{"operationId":"claim-app","summary":"Claim an unclaimed app for the authenticated user","description":"Body must include the app’s `bld_app_*` token. On success, ownership transfers and a bound pairing (if any) is auto-approved.","security":[{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string"}}}}}},"responses":{"200":{"description":"Claimed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppClaimResponse"}}}},"400":{"description":"Body is not JSON or is missing `token`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Already claimed by someone (possibly the caller).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/openapi.json":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"operationId":"get-app-openapi","summary":"Per-app OpenAPI document derived from `export const routes`","description":"Owner-only OpenAPI 3.1 document with one method-specific path entry per route. requestBody schema = route.input; 200 response schema = `{ ok: true, value: route.output }`. Apps that do not declare `routes` get an empty `paths: {}` document.","security":[{"appToken":[]},{"userToken":[]}],"responses":{"200":{"description":"Per-app OpenAPI document.","content":{"application/json":{"schema":{"type":"object","description":"OpenAPI 3.1 document describing this app’s `routes`. Conforms to https://spec.openapis.org/oas/v3.1.0 — the per-app shape is too dynamic to enumerate inline."}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/describe":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"operationId":"describe-app","summary":"Owner-only metadata + storage key inventory","description":"Returns the resolved app envelope (manifest, capabilities, version), whether `ui`/`styles` are present, the persisted storage keys, and a machine-readable API summary so an agent can plan calls without reading the module source. Use `GET /app/{id}/source` when you also need the raw bytes.","security":[{"appToken":[]},{"userToken":[]}],"responses":{"200":{"description":"Metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DescribeResponse"}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/source":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"operationId":"get-app-source","summary":"Read the exact persisted source for an app","description":"Returns the stored `module`, `ui`, and `styles` exactly as persisted, plus the resolved metadata envelope. Requires a real bearer token for the app or its owning user; render tokens and claim cookies are rejected. Response carries an `ETag: \"v<N>\"` header tracking `appModules.currentVersion`; pass it back as `If-Match` on PUT or PATCH to gate optimistic concurrency.","security":[{"appToken":[]},{"userToken":[]}],"responses":{"200":{"description":"Exact persisted source payload","headers":{"ETag":{"description":"Strong validator `\"v<N>\"` where N is the monotonic app version.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppSourceResponse"}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"operationId":"patch-app-source","summary":"Apply exact-string find/replace edits to the persisted source","description":"Applies an `edits[]` array to the stored `module` / `ui` / `styles` buckets. Each edit names a `bucket`, an exact `old_string` to match, and a `new_string` replacement. Requires `If-Match: \"v<N>\"` carrying the ETag from a prior GET — a stale value returns 412 and surfaces the current ETag in the response header. Same auth as PUT.","security":[{"appToken":[]},{"userToken":[]}],"parameters":[{"name":"If-Match","in":"header","required":true,"schema":{"type":"string"},"description":"Strong validator `\"v<N>\"` returned by a prior GET /source."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["edits"],"properties":{"edits":{"type":"array","minItems":1,"items":{"type":"object","required":["bucket","old_string","new_string"],"properties":{"bucket":{"type":"string","enum":["module","ui","styles"]},"old_string":{"type":"string"},"new_string":{"type":"string"},"replace_all":{"type":"boolean"}}}}}}}}},"responses":{"200":{"description":"Edits applied; new monotonic version returned.","headers":{"ETag":{"description":"New strong validator `\"v<N>\"` after the edit committed.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"type":"object","required":["ok","id","version"],"properties":{"ok":{"type":"boolean"},"id":{"type":"string"},"version":{"$ref":"#/components/schemas/MonotonicVersion"},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-blocking publish feedback (#1126), present only when static validation found UI/backend contract mismatches. The write succeeded."}}}}}},"400":{"description":"Body malformed (missing edits, invalid bucket, malformed If-Match).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"An old_string matched more than once; refine or set replace_all.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"412":{"description":"If-Match did not match the current version; response carries the current ETag.","headers":{"ETag":{"description":"Current strong validator `\"v<N>\"`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Payload too large: too many edits, an oversize old_string/new_string (`edits_too_large`), or the post-edit module/ui breaches the 256 KiB cap (`module_too_large` / `ui_too_large`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"An old_string was not found in the named bucket.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"428":{"description":"If-Match header required; GET /source first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/remixable":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"put":{"operationId":"set-app-remixable","summary":"Toggle whether visitors auto-fork their own copy of the app","description":"Owner-only. When `remixable: true`, any visitor (signed in or anonymous) opening `/app/{id}` is auto-forked into a fresh editable copy. When `remixable: false`, the share URL stops auto-forking; existing remixes (independent anon rows) are unaffected. Idempotent: a repeated call with the current state is a no-op.","security":[{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["remixable"],"properties":{"remixable":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Remixable state updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppRemixableResponse"}}}},"400":{"description":"Body is not `{ remixable: boolean }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller does not own this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Race: the row was concurrently re-attributed (e.g. `userId` cleared) between the ownership pre-check and the service-layer mutation. Defense-in-depth — extremely unlikely in practice.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/public":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"put":{"operationId":"set-app-public","summary":"Toggle whether anyone can open the app with no login","description":"Owner-only. When `public: true`, anyone opening `/app/{id}` with no login is granted read+run against the app's SHARED owner-scope storage — every anonymous visitor reads and writes the SAME data pool (no per-visitor isolation), so anyone with the URL can overwrite or wipe the data. Use it for a totally-open surface (a poll, an RSVP list); for per-person access that requires login, share with the `end-user` role instead. When `public: false`, the URL requires login again; data already written by anonymous visitors is retained. Idempotent: a repeated call with the current state is a no-op.","security":[{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["public"],"properties":{"public":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Public state updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppPublicResponse"}}}},"400":{"description":"Body is not `{ public: boolean }`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller does not own this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Race: the row was concurrently re-attributed (e.g. `userId` cleared) between the ownership pre-check and the service-layer mutation. Defense-in-depth — extremely unlikely in practice.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/starter-prompt":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"put":{"operationId":"set-app-starter-prompt","summary":"Set or clear the authored chat-with-app starter prompt","description":"Owner-only. When non-null, the authored text leads the prompt body for the \"Open in Claude / Open in ChatGPT / Copy prompt\" control-panel actions, and the app's name, description, URL (and, for unclaimed apps, an access token) are appended automatically at render time. Write a generic getting-started instruction — do NOT embed the app's URL, or every remixer's deeplink would point at the template instead of their own copy. Pass `null` (or an empty string) to clear and revert to the generic default. Idempotent: a repeated call with the same value is a no-op.","security":[{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["starter_prompt"],"properties":{"starter_prompt":{"type":"string","nullable":true,"maxLength":2000,"description":"Prompt body prefilled in the chat host when a visitor clicks \"Open in Claude\" / \"Open in ChatGPT\". Max 2000 characters. Pass `null` or `\"\"` to clear."}}}}}},"responses":{"200":{"description":"Starter prompt updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppStarterPromptResponse"}}}},"400":{"description":"Body is malformed or the prompt exceeds the length cap.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller does not own this app, or the app is anon-owned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/icon":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"put":{"summary":"Set the home-screen / favicon icon (emoji + bg)","description":"Owner-only. Replaces the icon as a unit — both `emoji` and `bg` are required; there is no partial / PATCH semantics. Stricter than `PUT /app/{id}`: invalid `emoji` or `bg` is rejected with 400 `invalid_icon` rather than coerced to the default and folded into `warnings[]` (that warn-and-coerce contract is for agent publishes, where a typo must never fail). Last-writer-wins with a near-simultaneous agent publish; the rendered icon SVG / PNG caches revalidate on next read via their content-derived ETag.","security":[{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Icon"}}}},"responses":{"200":{"description":"Icon updated; the response echoes the normalized icon.","content":{"application/json":{"schema":{"type":"object","required":["icon"],"properties":{"icon":{"$ref":"#/components/schemas/Icon"}}}}}},"400":{"description":"Body is not `{ emoji: string, bg: string }` (`invalid_request`), or `emoji`/`bg` failed the icon validators (`invalid_icon`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller does not own this app, or the app is anon-owned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"summary":"Clear the icon and fall back to the default tambo mark","description":"Owner-only. Sets `icon` to `null` so the SVG / PNG renderers fall back to the default tambo mark. Idempotent.","security":[{"userToken":[]}],"responses":{"200":{"description":"Icon cleared.","content":{"application/json":{"schema":{"type":"object","required":["icon"],"properties":{"icon":{"type":"null"}}}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller does not own this app, or the app is anon-owned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/secrets":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"operationId":"list-app-secrets","summary":"List the app secret NAMES (never values)","description":"Owner-only. Returns the names of the secrets stored for this app so the owner can see what is set. Values are never returned by any endpoint — secrets are write-only from the dashboard and are only decrypted host-side when an app references one. Requires the owner's user token; app/render tokens are rejected.","security":[{"userToken":[]}],"responses":{"200":{"description":"The secret names for this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppSecretListResponse"}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller does not own this app, or the app is anon-owned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"create-app-secret","summary":"Create a new secret value","description":"Owner-only, write-only. Creates a new encrypted secret value under a name. The name must match `^[A-Z][A-Z0-9_]*$` (the same charset a `{{secret:NAME}}` reference uses, so a settable name is always referenceable); values are capped at 8 KiB. The value is never echoed back. Returns 409 `secret_already_exists` if a secret with the same name already exists for this scope; use PUT to replace it. Requires the owner's user token; app/render tokens are rejected.","security":[{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","value"],"properties":{"name":{"type":"string","pattern":"^[A-Z][A-Z0-9_]*$","description":"Env-var-shaped secret name (referenceable via `{{secret:NAME}}`)."},"value":{"type":"string","maxLength":8192,"description":"Plaintext secret value (max 8 KiB). Stored encrypted; never returned."}}}}}},"responses":{"200":{"description":"Secret stored.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"400":{"description":"Body is not JSON, name/value missing, name invalid, or value too large.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller does not own this app, or the app is anon-owned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"A secret with this name already exists; use PUT to replace, or DELETE it first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"operationId":"upsert-app-secret","summary":"Create or replace a secret value","description":"Owner-only, write-only. Stores an encrypted secret value under a name, replacing any existing secret with the same name. The name must match `^[A-Z][A-Z0-9_]*$` (the same charset a `{{secret:NAME}}` reference uses, so a settable name is always referenceable); values are capped at 8 KiB. The value is never echoed back. Requires the owner's user token; app/render tokens are rejected.","security":[{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","value"],"properties":{"name":{"type":"string","pattern":"^[A-Z][A-Z0-9_]*$","description":"Env-var-shaped secret name (referenceable via `{{secret:NAME}}`)."},"value":{"type":"string","maxLength":8192,"description":"Plaintext secret value (max 8 KiB). Stored encrypted; never returned."}}}}}},"responses":{"200":{"description":"Secret stored.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"400":{"description":"Body is not JSON, name/value missing, name invalid, or value too large.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller does not own this app, or the app is anon-owned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"delete-app-secret","summary":"Delete a secret by name","description":"Owner-only. Removes the named secret. The name is passed as the `name` query parameter. Requires the owner's user token; app/render tokens are rejected.","security":[{"userToken":[]}],"parameters":[{"name":"name","in":"query","required":true,"schema":{"type":"string"},"description":"The secret name to delete."}],"responses":{"200":{"description":"Secret deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"400":{"description":"The `name` query parameter is required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller does not own this app, or the app is anon-owned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Secret not found (or app not found).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/activity":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"operationId":"get-app-activity","summary":"Read the app's durable runtime-failure events","description":"Owner-only. Returns the app's own `api_proxy_result`, `diag_report` (runtime JS errors, CSP violations), and `contract_validation` events from durable storage — use this after publishing to learn what broke at runtime. Unlike `GET /app/{id}/diag` (in-memory ring buffer, lost on deploy), these rows survive deploys. Requires the app token or the owner's user token; render tokens and claim cookies are rejected. Not to be confused with `GET /app/{id}/events`, the live-state SSE stream.","security":[{"appToken":[]},{"userToken":[]}],"parameters":[{"name":"kinds","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated subset of `api_proxy_result,diag_report,contract_validation`. Unknown kinds are a 400."},{"name":"since","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"ISO-8601 cutoff; only events at or after this instant are returned."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":100,"maximum":500},"description":"Max events to return. Default 100, capped at 500."}],"responses":{"200":{"description":"Newest-first runtime-failure events","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivityResponse"}}}},"400":{"description":"Invalid app id, unknown kind, or malformed since/limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Authentication required (render tokens and claim cookies rejected).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/events":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"operationId":"subscribe-app-events","summary":"Subscribe to the live-state SSE stream for an app","description":"Server-Sent Events stream of `state-changed` notifications. Each event id is `<server-epoch>:<rev>`; the browser `EventSource` replays via `Last-Event-ID` on reconnect, and the server emits a synthetic `state-changed` event with `source: \"reconnect-resync\"` and `result: null` whenever the client's id belongs to an older epoch or claims an unseen revision. The `overcapacity` event is emitted (and the stream closed) when the per-app subscriber cap is reached. Heartbeats are SSE comment lines (`: ping`) every 15s.\n\nAuth posture is broader than the owner endpoints: appToken, userToken, renderToken (bearer or `?t=` query), or the path-scoped `buildy_sse_<id>` HMAC cookie minted by the `/app/{id}` render-bridge when a render-token rendered the page. The `{}` (no-auth) option in `security` covers the cookie path — OpenAPI cannot model a per-app cookie name as a fixed scheme.","x-buildy-timeout-ms":null,"x-buildy-timeout-note":"Long-lived SSE stream — do not set a client read timeout below the 15s heartbeat interval. The stream stays open until the client disconnects, the per-app subscriber cap is hit, or the server shuts down.","security":[{"appToken":[]},{"userToken":[]},{"renderToken":[]},{"renderTokenQuery":[]},{}],"parameters":[{"name":"Last-Event-ID","in":"header","required":false,"schema":{"type":"string"},"description":"Last event id the client saw, in the `<epoch>:<rev>` form the server emits. Triggers a synthetic `reconnect-resync` event when the epoch differs or the rev is forward-jumped."},{"name":"t","in":"query","required":false,"schema":{"type":"string"},"description":"URL-signed render token, equivalent to the `renderToken` bearer but carried in the query string for SSE connections from null-origin iframes that cannot attach Authorization headers."}],"responses":{"200":{"description":"Open SSE stream. Frames arrive as `event: state-changed` (or `event: overcapacity`) with a JSON `data:` payload. The stream stays open until the client disconnects, the per-app capacity cap is hit, or the server shuts down.","content":{"text/event-stream":{"schema":{"oneOf":[{"$ref":"#/components/schemas/AppEventStateChanged"},{"$ref":"#/components/schemas/AppEventOvercapacity"}]}}}},"400":{"description":"Invalid app id. Error kind: `invalid_id`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Anonymous caller's `buildy_sse_<id>` cookie was once valid but its 1-hour TTL elapsed. Error kind: `sse_cookie_expired` — reload `/app/{id}` so the render-token bridge re-issues a fresh cookie.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller does not have access to this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/diag":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"get":{"operationId":"get-app-diag","summary":"Read the per-app runtime error ring buffer","description":"Owner-only. Use this after a deploy to see whether the iframe surfaced runtime errors. 50-entry ring buffer; oldest entries roll off. Accepts `renderToken` so the in-page shell can fetch its own diag without cookies.","security":[{"appToken":[]},{"userToken":[]},{"renderToken":[]}],"responses":{"200":{"description":"Ring buffer contents","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiagResponse"}}}},"400":{"description":"Invalid app id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/diag/report":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"post":{"operationId":"report-app-diag","summary":"Report a runtime error from the in-page shell (anonymous)","description":"Public ingress for the rendered iframe’s `error` / `unhandledrejection` listeners. Body is capped at 16 KiB and rate-limited per (appId, IP). Agents normally read errors via `GET /app/{id}/diag`; they should not call this endpoint directly.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["message"],"properties":{"message":{"type":"string"},"source":{"type":"string"},"line":{"type":"integer"},"col":{"type":"integer"},"stack":{"type":"string"}}}}}},"responses":{"204":{"description":"Recorded","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}}},"400":{"description":"Invalid app id or malformed body.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Body exceeds 16 KiB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Retry-After":{"$ref":"#/components/headers/Retry-After"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/load-error/report":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"post":{"operationId":"report-app-load-error","summary":"Report a caught app load failure from the in-page bridge (anonymous)","description":"Public ingress for app load failures the bridge caught before they became an unhandled rejection — e.g. `bridge.resolve(manifestId)` 404s when the requested manifest does not exist in the caller’s scope. Body is capped at 2 KiB and rate-limited per (appId, IP). Agents normally read events via `GET /app/{id}/events`; they should not call this endpoint directly.","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["manifestId"],"properties":{"manifestId":{"type":"string"},"status":{"type":"integer"},"errorKind":{"type":"string"}}}}}},"responses":{"204":{"description":"Recorded","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}}},"400":{"description":"Invalid app id or malformed body.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Body exceeds 2 KiB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Retry-After":{"$ref":"#/components/headers/Retry-After"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/feedback":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"post":{"operationId":"submit-app-feedback","summary":"Submit durable feedback for an app","description":"Owner-only durable feedback ingest. Auth posture matches `GET /app/{id}/diag` (`requireAppAccess`): app token, user token, render token, or claim cookie. Body is capped at 96 KiB wire / 16 KiB text / 64 KiB serialized `structuredData`. The MCP `submit_feedback` tool forces `source=\"agent\"` server-side; direct REST callers may set `source` to `user` or `auto-crash`.","security":[{"appToken":[]},{"userToken":[]},{"renderToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackSubmitBody"}}}},"responses":{"201":{"description":"Feedback recorded.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackSubmitResponse"}}}},"400":{"description":"Invalid app id, malformed body, or category=crash without crashData.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Body exceeds the 96 KiB wire cap, `text` exceeds 16 KiB, or `structuredData` exceeds 64 KiB serialized. Error kinds: `payload_too_large`, `text_too_large`, `structured_data_too_large`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limit exceeded (30/min/app/ip, 200/min/app).","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Retry-After":{"$ref":"#/components/headers/Retry-After"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/assets":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"post":{"operationId":"upload-app-asset","summary":"Upload an asset for an app","description":"Multipart upload of a single file asset. Auth posture: `requireAppAccess` (app token, user token, render token, or claim cookie). Hard caps: 10 MiB/asset, 50 assets/app, 100 MiB/app total.","x-buildy-timeout-ms":20000,"security":[{"appToken":[]},{"userToken":[]},{"renderToken":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["key","file"],"properties":{"key":{"type":"string","description":"Asset key (filename). Must be non-empty and valid."},"file":{"type":"string","format":"binary","description":"File bytes. Max 10 MiB."}}}}}},"responses":{"201":{"description":"Asset uploaded.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetUploadResponse"}}}},"400":{"description":"Missing or invalid field. Error kinds: `invalid_request`, `asset_invalid_key`, `asset_invalid_content_type`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"File or quota exceeded. Error kinds: `asset_too_large` (file > 10 MiB), `asset_count_exceeded` (50 asset cap), `asset_quota_exceeded` (100 MiB app cap).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Asset write rate limit exceeded. Error kind: `too_many_requests`.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Retry-After":{"$ref":"#/components/headers/Retry-After"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"operationId":"list-app-assets","summary":"List assets for an app","description":"Returns all stored assets for the app. Auth posture: `requireAppAccess` (app token, user token, render token, or claim cookie).","security":[{"appToken":[]},{"userToken":[]},{"renderToken":[]}],"responses":{"200":{"description":"Asset list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetListResponse"}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/{id}/assets/{key}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"key","in":"path","required":true,"schema":{"type":"string"}}],"delete":{"operationId":"delete-app-asset","summary":"Delete an asset","description":"Deletes one asset by key. Returns `{ok:true}` when deleted, `{ok:false}` (404) when the asset does not exist. Auth posture: `requireAppAccess` (app token, user token, render token, or claim cookie).","security":[{"appToken":[]},{"userToken":[]},{"renderToken":[]}],"responses":{"200":{"description":"Asset deleted. A missing asset returns 404 (not 200).","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Token does not authorize this app.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"App not found or asset not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Asset write rate limit exceeded. Error kind: `too_many_requests`.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Retry-After":{"$ref":"#/components/headers/Retry-After"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/app/resolve/{manifestId}":{"parameters":[{"name":"manifestId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"operationId":"resolve-app-manifest","summary":"Resolve a manifestId to an appId in the caller’s scope","description":"Owner-only lookup: returns the `appId` for the caller’s app whose manifest `id` matches `manifestId`. Used by `window.buildy.api(manifestId)` / `bridge.resolve()` to turn a manifest reference into a routable UUID.","security":[{"appToken":[]},{"userToken":[]}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ManifestResolveResponse"}}}},"401":{"description":"Authentication required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No app with that manifestId in the caller’s scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/pair/start":{"post":{"operationId":"start-pairing","summary":"Mint a device_code for an unauthenticated agent (RFC 8628-shaped)","description":"Returns a `device_code` (agent-private) and a `user_code` (`BLDY-XXXXXX`, displayed to the user). Codes expire after 10 minutes. Agent then polls `POST /api/pair/poll` until status flips to `approved`. The legacy `/pair/start` path 308s here.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"label":{"type":"string","maxLength":80,"description":"Optional human-readable label. Surfaced to the user on the approval page so they know which agent they’re connecting."}}}}}},"responses":{"200":{"description":"Codes minted","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PairStartResponse"}}}},"429":{"description":"Rate limited (per-IP).","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Retry-After":{"$ref":"#/components/headers/Retry-After"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/pair/poll":{"post":{"operationId":"poll-pairing","summary":"Poll a device_code until the user approves","description":"Plaintext `bld_user_*` token is returned exactly once when status flips to `approved`. Subsequent polls return `approved` with `already_delivered: true` and no token. The legacy `/pair/poll` path 308s here.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["device_code"],"properties":{"device_code":{"type":"string"}}}}}},"responses":{"200":{"description":"Poll result","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PairPollResponse"}}}},"400":{"description":"Body is not JSON or `device_code` is missing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited (per-IP / per-device_code).","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Retry-After":{"$ref":"#/components/headers/Retry-After"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/pair/approve":{"post":{"operationId":"approve-pairing","summary":"Approve a pairing (browser-side, signed-in user)","description":"Idempotent. The plaintext token is delivered to the agent via `/api/pair/poll`, never returned here. The legacy `/pair/approve` path 308s here.","security":[{"userToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["user_code"],"properties":{"user_code":{"type":"string","pattern":"^BLDY-[A-Z2-9]{6}$"}}}}}},"responses":{"200":{"description":"Approved","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"400":{"description":"Body is not JSON or `user_code` is missing/malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Sign in required to approve a pairing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No pairing matches that code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Pairing already approved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"410":{"description":"Pairing expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited.","headers":{"RateLimit-Limit":{"$ref":"#/components/headers/RateLimit-Limit"},"RateLimit-Remaining":{"$ref":"#/components/headers/RateLimit-Remaining"},"RateLimit-Reset":{"$ref":"#/components/headers/RateLimit-Reset"},"X-RateLimit-Limit":{"$ref":"#/components/headers/X-RateLimit-Limit"},"X-RateLimit-Remaining":{"$ref":"#/components/headers/X-RateLimit-Remaining"},"X-RateLimit-Reset":{"$ref":"#/components/headers/X-RateLimit-Reset"},"Retry-After":{"$ref":"#/components/headers/Retry-After"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/token":{"post":{"operationId":"create-token","summary":"Mint a personal access token (`bld_user_*`)","description":"Plaintext returned exactly once. 7-day TTL. The legacy `/token` path 308s here.","security":[{"userToken":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"label":{"type":"string","maxLength":80}}}}}},"responses":{"200":{"description":"Token minted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenCreateResponse"}}}},"401":{"description":"User-scoped auth required to mint a token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"operationId":"list-tokens","summary":"List the caller’s tokens (no plaintext, no hash)","description":"Returns the caller's personal access token metadata: stable `id`, public `tokenPrefix`, optional `label`, `expiresAt`, last-used timestamp, and `createdAt`. Plaintext and hashed values are never echoed — store the plaintext returned by `POST /api/token`; if it's lost, revoke and mint a new one.","security":[{"userToken":[]}],"responses":{"200":{"description":"Tokens","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenListResponse"}}}},"401":{"description":"User-scoped auth required to list tokens.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/token/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"delete":{"operationId":"revoke-token","summary":"Revoke a token","description":"The legacy `/token/{id}` path 308s here.","security":[{"userToken":[]}],"responses":{"200":{"description":"Revoked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkResponse"}}}},"401":{"description":"User-scoped auth required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Token not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/prompts/buildy-app-guide":{"get":{"operationId":"get-app-guide-prompt","summary":"Fetch the canonical Charming authoring guide as Markdown","description":"Same prose served as the `buildy:app-guide` MCP prompt. Useful for agents that want the full authoring contract without going through MCP. The legacy `/prompts/buildy-app-guide` path 308s here.","responses":{"200":{"description":"Markdown","content":{"text/markdown":{"schema":{"type":"string"}}}}}}},"/api/prompts/buildy-design-guide":{"get":{"operationId":"get-design-guide-prompt","summary":"Fetch the Charming design guide as Markdown","description":"Same prose served as the `buildy:design-guide` MCP prompt — the default visual look for generated app UIs (override when the user asks for a different vibe). Fetch before authoring or restyling an app. The legacy `/prompts/buildy-design-guide` path 308s here.","responses":{"200":{"description":"Markdown","content":{"text/markdown":{"schema":{"type":"string"}}}}}}},"/.well-known/openapi.json":{"get":{"operationId":"get-openapi-spec","summary":"This document","description":"Serves the canonical machine-readable OpenAPI 3.1 spec for the Charming API. The unversioned `/openapi.json` path 308s here.","responses":{"200":{"description":"OpenAPI 3.1 spec","content":{"application/json":{"schema":{"type":"object","description":"OpenAPI 3.1 document. Conforms to https://spec.openapis.org/oas/v3.1.0 — the spec is too deep to enumerate inline."}}}}}}},"/.well-known/oauth-authorization-server":{"get":{"operationId":"get-oauth-authorization-server","summary":"OAuth/MCP discovery metadata (delegated to better-auth)","description":"Returns the RFC 8414 authorization-server metadata document for the Charming auth surface. Consumed by MCP clients during the OAuth handshake. The base payload shape is owned by better-auth; Charming splices a WorkOS-style `agent_auth` extension block carrying `register_uri` (`/api/pair/start`), `claim_uri` (`/app/{id}/claim`), `revocation_uri` (`/api/token/{id}`), `skill` (`https://usecharming.com/auth.md`), `identity_types_supported` (`[\"anonymous\"]`), and an `anonymous` sibling block.","responses":{"200":{"description":"OAuth authorization-server discovery JSON.","content":{"application/json":{"schema":{"type":"object","description":"RFC 8414 authorization-server metadata (issuer, authorization_endpoint, token_endpoint, jwks_uri, …) plus the Charming `agent_auth` extension. Base schema is delegated to better-auth."}}}}}}},"/.well-known/http-message-signatures-directory":{"get":{"operationId":"get-http-message-signatures-directory","summary":"Web Bot Auth signing-key directory (RFC 9421)","description":"Returns a JWK Set advertising the Ed25519 public key Charming uses to sign outbound HTTP messages. Single-entry today: `{ keys: [{ kty: \"OKP\", crv: \"Ed25519\", kid, x, nbf, exp }] }`. `Content-Type: application/jwk-set+json`, `Cache-Control: public, max-age=600`. Storage v0: key is pinned via env vars or generated ephemerally per boot until `WEB_BOT_AUTH_PUBLIC_JWK`/`WEB_BOT_AUTH_KID`/`WEB_BOT_AUTH_NBF` are set on Railway.","responses":{"200":{"description":"JWK Set with the active Web Bot Auth signing key(s).","content":{"application/jwk-set+json":{"schema":{"type":"object","description":"RFC 7517 JWK Set carrying RFC 9421 / Web Bot Auth signing keys. Each entry has `kty: \"OKP\"`, `crv: \"Ed25519\"`, `kid`, `x` (base64url public key bytes), `nbf`, `exp`."}}}}}}},"/.well-known/oauth-protected-resource":{"get":{"operationId":"get-oauth-protected-resource","summary":"OAuth protected-resource metadata (delegated to better-auth)","description":"Returns the RFC 9728 protected-resource metadata for the Charming API origin. Pointed at by the `WWW-Authenticate` header MCP returns on 401s so clients can discover the matching authorization server.","responses":{"200":{"description":"OAuth protected-resource discovery JSON.","content":{"application/json":{"schema":{"type":"object","description":"RFC 9728 protected-resource metadata (resource, authorization_servers, scopes_supported, …). Schema is delegated to better-auth."}}}}}}}}}