{
  "openapi": "3.1.0",
  "info": {
    "title": "curly.ng gateway",
    "version": "1.0.0",
    "description": "The public API of curly.ng (path-based, https://curly.ng/api/...): public demo reads, the agent kit for npx skills (t244), and the HeyGen avatar-video capability. OpenAPI-first — this document is the source of truth; the oRPC contract and zod schemas are generated from it and never hand-edited."
  },
  "servers": [
    {
      "url": "https://curly.ng/api"
    }
  ],
  "paths": {
    "/health": {
      "get": {
        "operationId": "getHealth",
        "summary": "Liveness probe",
        "responses": {
          "200": {
            "description": "Worker is up",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthStatus"
                }
              }
            }
          }
        }
      }
    },
    "/demos/akashic/world": {
      "get": {
        "operationId": "getDemoAkashicWorld",
        "summary": "KV-cached proxy of the public Akashic world artifact",
        "description": "Read-only public read through the public KV cache; returns the world object directly.",
        "responses": {
          "200": {
            "description": "The Akashic world object",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "The upstream world.json object; its shape is upstream-owned."
                }
              }
            }
          }
        }
      }
    },
    "/demos/contrib/stream": {
      "get": {
        "operationId": "getDemoContribStream",
        "summary": "Server-sent events of the contributor feed",
        "description": "A text/event-stream; never cached (cache-control no-store) and rate-limited per IP. Each frame is one JSON contribution event.",
        "responses": {
          "200": {
            "description": "SSE stream of contribution events",
            "content": {
              "text/event-stream": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/demos/flint/series/{seriesName}": {
      "get": {
        "operationId": "getDemoFlintSeries",
        "summary": "Public Flint chart data for one series",
        "description": "Serves public and link charts through the public KV cache; a private or unknown chart answers 404 to anyone but the owner.",
        "parameters": [
          {
            "name": "seriesName",
            "in": "path",
            "required": true,
            "description": "The chart slug.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The chart series",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "The resolved chart; the route owns its shape."
                }
              }
            }
          },
          "404": {
            "description": "Unknown or private series"
          }
        }
      }
    },
    "/kit/{key}/.well-known/agent-skills/index.json": {
      "get": {
        "operationId": "readKitIndex",
        "summary": "The agent kit's skill index for `npx skills add`",
        "description": "The Polar license key is the path segment (the CLI drops query strings during well-known discovery). A valid key answers the discovery index; each archive URL is an R2 presigned link valid one hour. Skills over the CLI's 50 MiB / 1000-file archive cap are absent here and install from /kit/{key}/{skill}. Every answer is no-store and carries the developers note.",
        "parameters": [
          {
            "name": "key",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 8,
              "maxLength": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The discovery index",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KitIndex"
                }
              }
            }
          },
          "401": {
            "description": "Polar judged the key invalid, revoked or unknown",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KitError"
                }
              }
            }
          },
          "403": {
            "description": "A valid key for another Polar benefit",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KitError"
                }
              }
            }
          },
          "503": {
            "description": "Kit not configured, manifest missing, or Polar unreachable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KitError"
                }
              }
            }
          }
        }
      }
    },
    "/kit/{key}/{skill}": {
      "get": {
        "operationId": "readKitSkill",
        "summary": "One kit skill as a redirect to its R2 presigned zip",
        "description": "The direct-download source for skills over the index cap (`knowledges`). Same key checks as the index.",
        "parameters": [
          {
            "name": "key",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 8,
              "maxLength": 200
            }
          },
          {
            "name": "skill",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Location is the R2 presigned GET link, valid one hour"
          },
          "401": {
            "description": "Polar judged the key invalid, revoked or unknown",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KitError"
                }
              }
            }
          },
          "403": {
            "description": "A valid key for another Polar benefit",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KitError"
                }
              }
            }
          },
          "404": {
            "description": "No skill by that name in the kit",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KitError"
                }
              }
            }
          },
          "503": {
            "description": "Kit not configured, manifest missing, or Polar unreachable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KitError"
                }
              }
            }
          }
        }
      }
    },
    "/avatar-videos": {
      "post": {
        "operationId": "createAvatarVideo",
        "summary": "Create an avatar video job from a photo URL, script, and voice",
        "description": "Submits a HeyGen image-to-video generation request (POST /v3/videos, type \"image\"). Returns an opaque job token; poll getAvatarVideo for the video URL. Without HEYGEN_API_KEY the adapter answers in recorded-mock mode and the mode is reported honestly.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateAvatarVideoBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job accepted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateAvatarVideoResult"
                }
              }
            }
          }
        }
      }
    },
    "/avatar-videos/{jobId}": {
      "get": {
        "operationId": "getAvatarVideo",
        "summary": "One avatar video job with its live status",
        "description": "Polls HeyGen (GET /v3/videos/{video_id}) for live jobs, or derives the status deterministically from the job age in mock mode. The job token carries the HeyGen video id and creation time; no gateway table is read.",
        "parameters": [
          {
            "name": "jobId",
            "in": "path",
            "required": true,
            "description": "The opaque job token returned by createAvatarVideo.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The job status",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AvatarVideoStatus"
                }
              }
            }
          },
          "404": {
            "description": "Unknown job token"
          }
        }
      }
    },
    "/photo-avatars": {
      "get": {
        "operationId": "listPhotoAvatars",
        "summary": "List HeyGen photo avatar looks usable as avatar_id",
        "description": "Lists photo-avatar looks (GET /v3/avatars/looks with avatar_type=photo_avatar). Mock mode returns two recorded fixtures shaped exactly like the documented HeyGen response.",
        "responses": {
          "200": {
            "description": "Photo avatar looks",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PhotoAvatarsList"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "AvatarVideoStatus": {
        "type": "object",
        "required": [
          "jobId",
          "status",
          "heygenMode",
          "videoUrl",
          "failureMessage",
          "createdAt",
          "updatedAt"
        ],
        "properties": {
          "jobId": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "heygenMode": {
            "$ref": "#/components/schemas/HeygenMode"
          },
          "videoUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "MP4 URL once status is completed; HeyGen hosts it."
          },
          "failureMessage": {
            "type": [
              "string",
              "null"
            ],
            "description": "HeyGen failure_message when status is failed."
          },
          "createdAt": {
            "type": "string",
            "description": "ISO timestamp of job creation."
          },
          "updatedAt": {
            "type": "string",
            "description": "ISO timestamp of this status read."
          }
        },
        "additionalProperties": false
      },
      "CreateAvatarVideoBody": {
        "type": "object",
        "required": [
          "imageUrl",
          "script",
          "voiceId"
        ],
        "properties": {
          "imageUrl": {
            "type": "string",
            "minLength": 8,
            "maxLength": 2048,
            "description": "Public https URL of the person photo HeyGen animates."
          },
          "script": {
            "type": "string",
            "minLength": 1,
            "maxLength": 5000,
            "description": "Spoken script; HeyGen generates the speech."
          },
          "voiceId": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "HeyGen voice_id for the speech (GET /v3/voices)."
          },
          "title": {
            "type": "string",
            "maxLength": 200,
            "description": "Display name in the HeyGen dashboard. Optional."
          }
        },
        "additionalProperties": false
      },
      "CreateAvatarVideoResult": {
        "type": "object",
        "required": [
          "jobId",
          "heygenMode"
        ],
        "properties": {
          "jobId": {
            "type": "string",
            "description": "Opaque job token; pass to getAvatarVideo to poll."
          },
          "heygenMode": {
            "$ref": "#/components/schemas/HeygenMode"
          }
        },
        "additionalProperties": false
      },
      "HealthStatus": {
        "type": "object",
        "required": [
          "status",
          "worker"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok"
            ]
          },
          "worker": {
            "type": "string",
            "enum": [
              "curly-gateway"
            ]
          }
        },
        "additionalProperties": false
      },
      "HeygenMode": {
        "type": "string",
        "enum": [
          "live",
          "mock"
        ],
        "description": "Whether the HeyGen adapter actually called api.heygen.com."
      },
      "JobStatus": {
        "type": "string",
        "enum": [
          "pending",
          "processing",
          "completed",
          "failed"
        ],
        "description": "HeyGen's documented status vocabulary, mirrored verbatim."
      },
      "KitError": {
        "type": "object",
        "required": [
          "code",
          "message",
          "developers"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "UNAUTHORIZED",
              "FORBIDDEN",
              "NOT_FOUND",
              "UNAVAILABLE"
            ]
          },
          "message": {
            "type": "string"
          },
          "developers": {
            "type": "string"
          }
        },
        "additionalProperties": false
      },
      "KitIndex": {
        "type": "object",
        "description": "What `npx skills add https://curly.ng/api/kit/<key>` reads: the agentskills.io discovery index v0.2.0, plus the developers note.",
        "required": [
          "$schema",
          "skills",
          "developers"
        ],
        "properties": {
          "$schema": {
            "type": "string",
            "enum": [
              "https://schemas.agentskills.io/discovery/0.2.0/schema.json"
            ]
          },
          "skills": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/KitIndexSkill"
            }
          },
          "developers": {
            "type": "string"
          }
        },
        "additionalProperties": false
      },
      "KitIndexSkill": {
        "type": "object",
        "description": "One skill archive in the agentskills.io discovery index v0.2.0.",
        "required": [
          "name",
          "description",
          "type",
          "url",
          "digest"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 64,
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
          },
          "description": {
            "type": "string",
            "minLength": 1,
            "maxLength": 1024
          },
          "type": {
            "type": "string",
            "enum": [
              "archive"
            ]
          },
          "url": {
            "type": "string",
            "description": "R2 presigned GET link to the skill zip, valid one hour."
          },
          "digest": {
            "type": "string",
            "pattern": "^sha256:[a-f0-9]{64}$"
          }
        },
        "additionalProperties": false
      },
      "PhotoAvatar": {
        "type": "object",
        "required": [
          "avatarId",
          "name",
          "previewImageUrl",
          "defaultVoiceId"
        ],
        "properties": {
          "avatarId": {
            "type": "string",
            "description": "HeyGen look id; pass as avatar_id to POST /v3/videos."
          },
          "name": {
            "type": "string"
          },
          "previewImageUrl": {
            "type": [
              "string",
              "null"
            ]
          },
          "defaultVoiceId": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "additionalProperties": false
      },
      "PhotoAvatarsList": {
        "type": "object",
        "required": [
          "avatars",
          "hasMore"
        ],
        "properties": {
          "avatars": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PhotoAvatar"
            }
          },
          "hasMore": {
            "type": "boolean",
            "description": "HeyGen has_more; a next_token cursor exists when true."
          }
        },
        "additionalProperties": false
      }
    }
  }
}