OpenAPI 3.0 Spec (v0.1)
openapi: 3.0.3
info:
title: KILO Photography Platform API
version: “0.1.0”
description: |
KILO is an AI-first photography platform for ingest, culling, editing, search, and client delivery.
This API is designed for web + desktop clients with resumable ingest and async processing jobs.
servers:
– url: https://api.kilo.photo/v1
tags:
– name: Auth
– name: Projects
– name: Assets
– name: Clusters
– name: Culling
– name: Edits
– name: Search
– name: Galleries
– name: Exports
– name: Jobs
– name: Realtime
security:
– bearerAuth: []
paths:
/auth/login:
post:
tags: [Auth]
summary: Start passwordless login (magic link / code)
description: Creates a login challenge and sends a verification code to the email.
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/LoginStartRequest”
responses:
“200”:
description: Challenge created
content:
application/json:
schema:
$ref: “#/components/schemas/LoginStartResponse”
“400”:
$ref: “#/components/responses/BadRequest”
/auth/verify:
post:
tags: [Auth]
summary: Verify login challenge and mint tokens
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/LoginVerifyRequest”
responses:
“200”:
description: Tokens minted
content:
application/json:
schema:
$ref: “#/components/schemas/LoginVerifyResponse”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
/projects:
get:
tags: [Projects]
summary: List projects
parameters:
– $ref: “#/components/parameters/Cursor”
– $ref: “#/components/parameters/Limit”
responses:
“200”:
description: Projects page
content:
application/json:
schema:
$ref: “#/components/schemas/PagedProjects”
“401”:
$ref: “#/components/responses/Unauthorized”
post:
tags: [Projects]
summary: Create project
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/CreateProjectRequest”
responses:
“201”:
description: Project created
content:
application/json:
schema:
$ref: “#/components/schemas/Project”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
/projects/{projectId}:
get:
tags: [Projects]
summary: Get project
parameters:
– $ref: “#/components/parameters/ProjectId”
responses:
“200”:
description: Project
content:
application/json:
schema:
$ref: “#/components/schemas/Project”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
patch:
tags: [Projects]
summary: Update project
parameters:
– $ref: “#/components/parameters/ProjectId”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/UpdateProjectRequest”
responses:
“200”:
description: Project updated
content:
application/json:
schema:
$ref: “#/components/schemas/Project”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/projects/{projectId}/archive:
post:
tags: [Projects]
summary: Archive project
parameters:
– $ref: “#/components/parameters/ProjectId”
responses:
“200”:
description: Archived
content:
application/json:
schema:
$ref: “#/components/schemas/Project”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/projects/{projectId}/assets:prepareUpload:
post:
tags: [Assets]
summary: Prepare signed URLs for upload
description: |
Returns signed upload URLs for direct-to-object-storage upload.
Use Idempotency-Key to safely retry.
parameters:
– $ref: “#/components/parameters/ProjectId”
– $ref: “#/components/parameters/IdempotencyKey”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/PrepareUploadRequest”
responses:
“200”:
description: Signed upload URLs
content:
application/json:
schema:
$ref: “#/components/schemas/PrepareUploadResponse”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
“409”:
$ref: “#/components/responses/Conflict”
/projects/{projectId}/assets:finalizeUpload:
post:
tags: [Assets]
summary: Finalize uploaded assets and enqueue processing
parameters:
– $ref: “#/components/parameters/ProjectId”
– $ref: “#/components/parameters/IdempotencyKey”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/FinalizeUploadRequest”
responses:
“200”:
description: Assets finalized and jobs queued
content:
application/json:
schema:
$ref: “#/components/schemas/FinalizeUploadResponse”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
“409”:
$ref: “#/components/responses/Conflict”
/projects/{projectId}/assets:
get:
tags: [Assets]
summary: List assets in a project
parameters:
– $ref: “#/components/parameters/ProjectId”
– $ref: “#/components/parameters/Cursor”
– $ref: “#/components/parameters/Limit”
– name: picked
in: query
schema: { type: boolean }
– name: rejected
in: query
schema: { type: boolean }
– name: ratingMin
in: query
schema: { type: integer, minimum: 1, maximum: 5 }
responses:
“200”:
description: Assets page
content:
application/json:
schema:
$ref: “#/components/schemas/PagedAssets”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/assets/{assetId}:
get:
tags: [Assets]
summary: Get asset
parameters:
– $ref: “#/components/parameters/AssetId”
responses:
“200”:
description: Asset
content:
application/json:
schema:
$ref: “#/components/schemas/Asset”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
patch:
tags: [Assets]
summary: Update asset metadata/flags
parameters:
– $ref: “#/components/parameters/AssetId”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/UpdateAssetRequest”
responses:
“200”:
description: Asset updated
content:
application/json:
schema:
$ref: “#/components/schemas/Asset”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/assets/{assetId}/files:
get:
tags: [Assets]
summary: Get asset file variants (signed URLs)
parameters:
– $ref: “#/components/parameters/AssetId”
responses:
“200”:
description: File variants
content:
application/json:
schema:
$ref: “#/components/schemas/AssetFilesResponse”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/assets/{assetId}/ratings:
post:
tags: [Assets]
summary: Set rating / pick / reject / star for an asset
parameters:
– $ref: “#/components/parameters/AssetId”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/UpsertRatingRequest”
responses:
“200”:
description: Rating upserted
content:
application/json:
schema:
$ref: “#/components/schemas/Rating”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/projects/{projectId}/clusters:
get:
tags: [Clusters]
summary: List clusters (moments/bursts/duplicate groups)
parameters:
– $ref: “#/components/parameters/ProjectId”
– name: kind
in: query
schema:
type: string
enum: [moment, burst, duplicate_group]
– $ref: “#/components/parameters/Cursor”
– $ref: “#/components/parameters/Limit”
responses:
“200”:
description: Clusters page
content:
application/json:
schema:
$ref: “#/components/schemas/PagedClusters”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/clusters/{clusterId}:
get:
tags: [Clusters]
summary: Get cluster details
parameters:
– $ref: “#/components/parameters/ClusterId”
responses:
“200”:
description: Cluster
content:
application/json:
schema:
$ref: “#/components/schemas/Cluster”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
patch:
tags: [Clusters]
summary: Update cluster (rename, split/merge flags, manual overrides)
parameters:
– $ref: “#/components/parameters/ClusterId”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/UpdateClusterRequest”
responses:
“200”:
description: Cluster updated
content:
application/json:
schema:
$ref: “#/components/schemas/Cluster”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/clusters/{clusterId}/winner:
post:
tags: [Clusters]
summary: Set cluster winner
parameters:
– $ref: “#/components/parameters/ClusterId”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/SetWinnerRequest”
responses:
“200”:
description: Winner updated
content:
application/json:
schema:
$ref: “#/components/schemas/Cluster”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/projects/{projectId}/cull:applyAction:
post:
tags: [Culling]
summary: Apply bulk culling actions (pick/reject/rate)
parameters:
– $ref: “#/components/parameters/ProjectId”
– $ref: “#/components/parameters/IdempotencyKey”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/BulkCullActionRequest”
responses:
“200”:
description: Actions applied
content:
application/json:
schema:
$ref: “#/components/schemas/BulkCullActionResponse”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/assets/{assetId}/edits:
get:
tags: [Edits]
summary: List edit versions for an asset
parameters:
– $ref: “#/components/parameters/AssetId”
responses:
“200”:
description: Edit versions
content:
application/json:
schema:
$ref: “#/components/schemas/EditVersionList”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
post:
tags: [Edits]
summary: Create new edit version for an asset
parameters:
– $ref: “#/components/parameters/AssetId”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/CreateEditVersionRequest”
responses:
“201”:
description: Edit version created
content:
application/json:
schema:
$ref: “#/components/schemas/EditVersion”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/edits/{editId}:
get:
tags: [Edits]
summary: Get edit version
parameters:
– $ref: “#/components/parameters/EditId”
responses:
“200”:
description: Edit version
content:
application/json:
schema:
$ref: “#/components/schemas/EditVersion”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/edits/{editId}/applyTo:
post:
tags: [Edits]
summary: Apply an edit version to many assets (batch)
parameters:
– $ref: “#/components/parameters/EditId”
– $ref: “#/components/parameters/IdempotencyKey”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/ApplyEditBatchRequest”
responses:
“200”:
description: Batch queued/applied
content:
application/json:
schema:
$ref: “#/components/schemas/ApplyEditBatchResponse”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
/projects/{projectId}/search:
get:
tags: [Search]
summary: Simple semantic search
parameters:
– $ref: “#/components/parameters/ProjectId”
– name: q
in: query
required: true
schema: { type: string, minLength: 1 }
– $ref: “#/components/parameters/Limit”
responses:
“200”:
description: Search results
content:
application/json:
schema:
$ref: “#/components/schemas/SearchResponse”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
post:
tags: [Search]
summary: Advanced search (semantic + filters)
parameters:
– $ref: “#/components/parameters/ProjectId”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/AdvancedSearchRequest”
responses:
“200”:
description: Search results
content:
application/json:
schema:
$ref: “#/components/schemas/SearchResponse”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
/projects/{projectId}/galleries:
post:
tags: [Galleries]
summary: Create client gallery
parameters:
– $ref: “#/components/parameters/ProjectId”
– $ref: “#/components/parameters/IdempotencyKey”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/CreateGalleryRequest”
responses:
“201”:
description: Gallery created
content:
application/json:
schema:
$ref: “#/components/schemas/Gallery”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
/galleries/{galleryId}:
get:
tags: [Galleries]
summary: Get gallery (owner/admin)
parameters:
– $ref: “#/components/parameters/GalleryId”
responses:
“200”:
description: Gallery
content:
application/json:
schema:
$ref: “#/components/schemas/Gallery”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
patch:
tags: [Galleries]
summary: Update gallery settings
parameters:
– $ref: “#/components/parameters/GalleryId”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/UpdateGalleryRequest”
responses:
“200”:
description: Gallery updated
content:
application/json:
schema:
$ref: “#/components/schemas/Gallery”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
/galleries/{galleryId}/assets:
post:
tags: [Galleries]
summary: Add assets to gallery
parameters:
– $ref: “#/components/parameters/GalleryId”
– $ref: “#/components/parameters/IdempotencyKey”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/AddGalleryAssetsRequest”
responses:
“200”:
description: Assets added
content:
application/json:
schema:
$ref: “#/components/schemas/Gallery”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
delete:
tags: [Galleries]
summary: Remove assets from gallery
parameters:
– $ref: “#/components/parameters/GalleryId”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/RemoveGalleryAssetsRequest”
responses:
“200”:
description: Assets removed
content:
application/json:
schema:
$ref: “#/components/schemas/Gallery”
“401”:
$ref: “#/components/responses/Unauthorized”
/share/{shareSlug}:
get:
tags: [Galleries]
summary: Public gallery view (client)
description: |
Returns gallery + asset previews for clients. If password-protected, returns requiresAuth=true.
security: []
parameters:
– $ref: “#/components/parameters/ShareSlug”
responses:
“200”:
description: Public gallery
content:
application/json:
schema:
$ref: “#/components/schemas/PublicGalleryResponse”
“404”:
$ref: “#/components/responses/NotFound”
“429”:
$ref: “#/components/responses/RateLimited”
/share/{shareSlug}/auth:
post:
tags: [Galleries]
summary: Authenticate into a password-protected public gallery
security: []
parameters:
– $ref: “#/components/parameters/ShareSlug”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/PublicGalleryAuthRequest”
responses:
“200”:
description: Auth ok
content:
application/json:
schema:
$ref: “#/components/schemas/PublicGalleryAuthResponse”
“401”:
$ref: “#/components/responses/Unauthorized”
“429”:
$ref: “#/components/responses/RateLimited”
/share/{shareSlug}/favorite:
post:
tags: [Galleries]
summary: Favorite/unfavorite an asset (client)
security: []
parameters:
– $ref: “#/components/parameters/ShareSlug”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/PublicFavoriteRequest”
responses:
“200”:
description: Updated
content:
application/json:
schema:
$ref: “#/components/schemas/PublicFavoriteResponse”
“400”:
$ref: “#/components/responses/BadRequest”
“404”:
$ref: “#/components/responses/NotFound”
“429”:
$ref: “#/components/responses/RateLimited”
/share/{shareSlug}/comment:
post:
tags: [Galleries]
summary: Comment on an asset (client)
security: []
parameters:
– $ref: “#/components/parameters/ShareSlug”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/PublicCommentRequest”
responses:
“201”:
description: Comment created
content:
application/json:
schema:
$ref: “#/components/schemas/PublicCommentResponse”
“400”:
$ref: “#/components/responses/BadRequest”
“404”:
$ref: “#/components/responses/NotFound”
“429”:
$ref: “#/components/responses/RateLimited”
/projects/{projectId}/exports:
post:
tags: [Exports]
summary: Create export job
parameters:
– $ref: “#/components/parameters/ProjectId”
– $ref: “#/components/parameters/IdempotencyKey”
requestBody:
required: true
content:
application/json:
schema:
$ref: “#/components/schemas/CreateExportRequest”
responses:
“201”:
description: Export created
content:
application/json:
schema:
$ref: “#/components/schemas/Export”
“400”:
$ref: “#/components/responses/BadRequest”
“401”:
$ref: “#/components/responses/Unauthorized”
/exports/{exportId}:
get:
tags: [Exports]
summary: Get export status
parameters:
– $ref: “#/components/parameters/ExportId”
responses:
“200”:
description: Export
content:
application/json:
schema:
$ref: “#/components/schemas/Export”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/exports/{exportId}/download:
get:
tags: [Exports]
summary: Get signed download URL for export artifact
parameters:
– $ref: “#/components/parameters/ExportId”
responses:
“200”:
description: Download info
content:
application/json:
schema:
$ref: “#/components/schemas/ExportDownloadResponse”
“401”:
$ref: “#/components/responses/Unauthorized”
“404”:
$ref: “#/components/responses/NotFound”
/projects/{projectId}/jobs:
get:
tags: [Jobs]
summary: List processing jobs for a project
parameters:
– $ref: “#/components/parameters/ProjectId”
– name: status
in: query
schema: { type: string, enum: [queued, running, done, failed, canceled] }
– name: type
in: query
schema: { type: string }
– $ref: “#/components/parameters/Cursor”
– $ref: “#/components/parameters/Limit”
responses:
“200”:
description: Jobs page
content:
application/json:
schema:
$ref: “#/components/schemas/PagedJobs”
“401”:
$ref: “#/components/responses/Unauthorized”
/realtime:
get:
tags: [Realtime]
summary: Server-Sent Events stream for job progress and project updates
description: |
SSE stream (text/event-stream). Client supplies projectId and optional lastEventId.
Events:
– job.progress
– job.done
– cluster.updated
– asset.updated
– export.updated
parameters:
– name: projectId
in: query
required: true
schema: { type: string, format: uuid }
– name: lastEventId
in: query
required: false
schema: { type: string }
responses:
“200”:
description: SSE stream
content:
text/event-stream:
schema:
type: string
“401”:
$ref: “#/components/responses/Unauthorized”
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
ProjectId:
name: projectId
in: path
required: true
schema: { type: string, format: uuid }
AssetId:
name: assetId
in: path
required: true
schema: { type: string, format: uuid }
ClusterId:
name: clusterId
in: path
required: true
schema: { type: string, format: uuid }
EditId:
name: editId
in: path
required: true
schema: { type: string, format: uuid }
GalleryId:
name: galleryId
in: path
required: true
schema: { type: string, format: uuid }
ExportId:
name: exportId
in: path
required: true
schema: { type: string, format: uuid }
ShareSlug:
name: shareSlug
in: path
required: true
schema: { type: string, minLength: 8, maxLength: 128 }
Cursor:
name: cursor
in: query
required: false
schema: { type: string }
Limit:
name: limit
in: query
required: false
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
IdempotencyKey:
name: Idempotency-Key
in: header
required: false
schema: { type: string, minLength: 8, maxLength: 128 }
responses:
BadRequest:
description: Bad request
content:
application/json:
schema: { $ref: “#/components/schemas/Error” }
Unauthorized:
description: Unauthorized
content:
application/json:
schema: { $ref: “#/components/schemas/Error” }
NotFound:
description: Not found
content:
application/json:
schema: { $ref: “#/components/schemas/Error” }
Conflict:
description: Conflict
content:
application/json:
schema: { $ref: “#/components/schemas/Error” }
RateLimited:
description: Too many requests
content:
application/json:
schema: { $ref: “#/components/schemas/Error” }
schemas:
Error:
type: object
required: [code, message]
properties:
code: { type: string, example: “bad_request” }
message: { type: string, example: “Invalid input.” }
details: { type: object, additionalProperties: true }
requestId: { type: string }
LoginStartRequest:
type: object
required: [email]
properties:
email: { type: string, format: email }
locale: { type: string, example: “en-US” }
LoginStartResponse:
type: object
required: [challengeId]
properties:
challengeId: { type: string }
delivery: { type: string, enum: [email_code, magic_link], example: “email_code” }
LoginVerifyRequest:
type: object
required: [challengeId, code]
properties:
challengeId: { type: string }
code: { type: string, minLength: 4, maxLength: 12 }
LoginVerifyResponse:
type: object
required: [accessToken, refreshToken, user]
properties:
accessToken: { type: string }
refreshToken: { type: string }
user: { $ref: “#/components/schemas/User” }
User:
type: object
required: [id, email, displayName, createdAt]
properties:
id: { type: string, format: uuid }
email: { type: string, format: email }
displayName: { type: string }
createdAt: { type: string, format: date-time }
Project:
type: object
required: [id, title, status, createdAt, updatedAt]
properties:
id: { type: string, format: uuid }
studioId: { type: string, format: uuid, nullable: true }
ownerUserId: { type: string, format: uuid }
title: { type: string }
description: { type: string, nullable: true }
shootDate: { type: string, format: date, nullable: true }
timezone: { type: string, nullable: true }
status: { type: string, enum: [active, archived, deleted] }
stats:
type: object
additionalProperties: true
example:
assetCount: 1243
pickedCount: 312
rejectedCount: 721
clustersReviewed: 85
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
CreateProjectRequest:
type: object
required: [title]
properties:
title: { type: string }
description: { type: string }
shootDate: { type: string, format: date }
timezone: { type: string }
UpdateProjectRequest:
type: object
properties:
title: { type: string }
description: { type: string, nullable: true }
shootDate: { type: string, format: date, nullable: true }
timezone: { type: string, nullable: true }
status: { type: string, enum: [active, archived, deleted] }
Asset:
type: object
required: [id, projectId, ingestedAt]
properties:
id: { type: string, format: uuid }
projectId: { type: string, format: uuid }
capturedAt: { type: string, format: date-time, nullable: true }
ingestedAt: { type: string, format: date-time }
widthPx: { type: integer, nullable: true }
heightPx: { type: integer, nullable: true }
cameraMake: { type: string, nullable: true }
cameraModel: { type: string, nullable: true }
lensModel: { type: string, nullable: true }
focalLengthMm: { type: number, nullable: true }
shutterSpeed: { type: string, nullable: true }
aperture: { type: number, nullable: true }
iso: { type: integer, nullable: true }
exif: { type: object, additionalProperties: true }
iptc: { type: object, additionalProperties: true }
flags:
type: object
additionalProperties: true
example:
isDuplicate: false
hasFace: false
processingReady: true
myRating:
$ref: “#/components/schemas/Rating”
bestPreviewUrl:
type: string
nullable: true
description: Convenience signed URL for UI grid (short-lived).
UpdateAssetRequest:
type: object
properties:
iptc:
type: object
additionalProperties: true
keywords:
type: array
items: { type: string }
notes: { type: string, nullable: true }
flags:
type: object
additionalProperties: true
AssetFile:
type: object
required: [kind, url]
properties:
kind: { type: string, enum: [original, thumbnail, preview, export] }
url: { type: string }
contentType: { type: string, nullable: true }
byteSize: { type: integer, format: int64, nullable: true }
checksumSha256: { type: string, nullable: true }
expiresAt: { type: string, format: date-time, nullable: true }
AssetFilesResponse:
type: object
required: [files]
properties:
files:
type: array
items: { $ref: “#/components/schemas/AssetFile” }
Rating:
type: object
required: [assetId, userId]
properties:
assetId: { type: string, format: uuid }
userId: { type: string, format: uuid }
rating: { type: integer, minimum: 1, maximum: 5, nullable: true }
picked: { type: boolean, nullable: true }
rejected: { type: boolean, nullable: true }
starred: { type: boolean, nullable: true }
notes: { type: string, nullable: true }
updatedAt: { type: string, format: date-time, nullable: true }
UpsertRatingRequest:
type: object
properties:
rating: { type: integer, minimum: 1, maximum: 5, nullable: true }
picked: { type: boolean, nullable: true }
rejected: { type: boolean, nullable: true }
starred: { type: boolean, nullable: true }
notes: { type: string, nullable: true }
Cluster:
type: object
required: [id, projectId, kind, assets]
properties:
id: { type: string, format: uuid }
projectId: { type: string, format: uuid }
kind: { type: string, enum: [moment, burst, duplicate_group] }
title: { type: string, nullable: true }
startTime: { type: string, format: date-time, nullable: true }
endTime: { type: string, format: date-time, nullable: true }
score: { type: number, nullable: true }
reviewed: { type: boolean, nullable: true }
winnerAssetId: { type: string, format: uuid, nullable: true }
whyWinner:
type: array
items: { type: string }
example: [“Sharpest frame”, “Eyes open”, “Cleaner background”]
assets:
type: array
items:
$ref: “#/components/schemas/ClusterAsset”
ClusterAsset:
type: object
required: [assetId, rank]
properties:
assetId: { type: string, format: uuid }
rank: { type: integer, minimum: 1 }
role: { type: string, enum: [candidate, winner, alt], nullable: true }
signals:
type: object
additionalProperties: true
example:
sharpness: 0.91
blur: 0.05
redundancy: 0.72
aesthetic: 0.64
UpdateClusterRequest:
type: object
properties:
title: { type: string, nullable: true }
reviewed: { type: boolean, nullable: true }
manualOverrides:
type: object
additionalProperties: true
description: For future split/merge mechanics.
SetWinnerRequest:
type: object
required: [winnerAssetId]
properties:
winnerAssetId: { type: string, format: uuid }
PrepareUploadRequest:
type: object
required: [files]
properties:
files:
type: array
minItems: 1
items:
$ref: “#/components/schemas/UploadFileDescriptor”
UploadFileDescriptor:
type: object
required: [filename, byteSize]
properties:
clientFileId:
type: string
nullable: true
description: Local identifier for mapping UI rows to responses.
filename: { type: string }
byteSize: { type: integer, format: int64 }
contentType: { type: string, nullable: true }
capturedAt: { type: string, format: date-time, nullable: true }
PrepareUploadResponse:
type: object
required: [uploads]
properties:
uploads:
type: array
items:
$ref: “#/components/schemas/UploadInstruction”
UploadInstruction:
type: object
required: [assetId, uploadUrl]
properties:
clientFileId: { type: string, nullable: true }
assetId: { type: string, format: uuid }
uploadUrl: { type: string }
headers:
type: object
additionalProperties: { type: string }
expiresAt: { type: string, format: date-time, nullable: true }
FinalizeUploadRequest:
type: object
required: [assets]
properties:
assets:
type: array
minItems: 1
items:
type: object
required: [assetId, checksumSha256]
properties:
assetId: { type: string, format: uuid }
checksumSha256: { type: string }
contentType: { type: string, nullable: true }
FinalizeUploadResponse:
type: object
required: [queuedJobs]
properties:
queuedJobs:
type: array
items: { type: string, format: uuid }
BulkCullActionRequest:
type: object
required: [actions]
properties:
actions:
type: array
minItems: 1
items:
type: object
required: [assetId]
properties:
assetId: { type: string, format: uuid }
picked: { type: boolean, nullable: true }
rejected: { type: boolean, nullable: true }
rating: { type: integer, minimum: 1, maximum: 5, nullable: true }
starred: { type: boolean, nullable: true }
BulkCullActionResponse:
type: object
required: [ok]
properties:
ok: { type: boolean }
updatedAssetIds:
type: array
items: { type: string, format: uuid }
EditVersion:
type: object
required: [id, assetId, userId, params, createdAt]
properties:
id: { type: string, format: uuid }
assetId: { type: string, format: uuid }
userId: { type: string, format: uuid }
parentId: { type: string, format: uuid, nullable: true }
name: { type: string, nullable: true }
params:
type: object
additionalProperties: true
description: Non-destructive edit parameter set.
createdAt: { type: string, format: date-time }
EditVersionList:
type: object
required: [items]
properties:
items:
type: array
items: { $ref: “#/components/schemas/EditVersion” }
CreateEditVersionRequest:
type: object
required: [params]
properties:
name: { type: string, nullable: true }
parentId: { type: string, format: uuid, nullable: true }
params:
type: object
additionalProperties: true
ApplyEditBatchRequest:
type: object
required: [assetIds]
properties:
assetIds:
type: array
minItems: 1
items: { type: string, format: uuid }
mode:
type: string
enum: [create_versions, overwrite_latest]
default: create_versions
name:
type: string
nullable: true
ApplyEditBatchResponse:
type: object
required: [jobId]
properties:
jobId: { type: string, format: uuid }
status: { type: string, enum: [queued, running, done] }
SearchResponse:
type: object
required: [results]
properties:
results:
type: array
items:
type: object
required: [assetId, score]
properties:
assetId: { type: string, format: uuid }
score: { type: number }
highlights:
type: array
items: { type: string }
tookMs: { type: integer }
AdvancedSearchRequest:
type: object
required: [q]
properties:
q: { type: string }
filters:
type: object
additionalProperties: true
example:
picked: true
ratingMin: 4
cameraModel: [“Sony A7IV”]
dateRange:
start: “2025-01-01”
end: “2025-12-31”
Gallery:
type: object
required: [id, projectId, title, shareSlug, createdAt]
properties:
id: { type: string, format: uuid }
projectId: { type: string, format: uuid }
title: { type: string }
shareSlug: { type: string }
expiresAt: { type: string, format: date-time, nullable: true }
watermark: { type: boolean }
allowDownloads: { type: boolean }
requiresPassword: { type: boolean }
assetCount: { type: integer }
createdAt: { type: string, format: date-time }
CreateGalleryRequest:
type: object
required: [title, assetIds]
properties:
title: { type: string }
assetIds:
type: array
minItems: 1
items: { type: string, format: uuid }
password:
type: string
nullable: true
description: If set, gallery is password-protected.
expiresAt: { type: string, format: date-time, nullable: true }
watermark: { type: boolean, default: false }
allowDownloads: { type: boolean, default: false }
UpdateGalleryRequest:
type: object
properties:
title: { type: string }
password:
type: string
nullable: true
expiresAt: { type: string, format: date-time, nullable: true }
watermark: { type: boolean }
allowDownloads: { type: boolean }
AddGalleryAssetsRequest:
type: object
required: [assetIds]
properties:
assetIds:
type: array
minItems: 1
items: { type: string, format: uuid }
RemoveGalleryAssetsRequest:
type: object
required: [assetIds]
properties:
assetIds:
type: array
minItems: 1
items: { type: string, format: uuid }
PublicGalleryResponse:
type: object
required: [title, requiresAuth]
properties:
title: { type: string }
requiresAuth: { type: boolean }
token:
type: string
nullable: true
description: Client session token (short-lived) if already authenticated.
assets:
type: array
items:
type: object
required: [assetId, previewUrl]
properties:
assetId: { type: string, format: uuid }
previewUrl: { type: string }
favoriteCount: { type: integer, nullable: true }
commentsCount: { type: integer, nullable: true }
PublicGalleryAuthRequest:
type: object
required: [password]
properties:
password: { type: string, minLength: 1 }
PublicGalleryAuthResponse:
type: object
required: [token]
properties:
token: { type: string }
PublicFavoriteRequest:
type: object
required: [assetId, favorite]
properties:
assetId: { type: string, format: uuid }
favorite: { type: boolean }
PublicFavoriteResponse:
type: object
required: [ok]
properties:
ok: { type: boolean }
PublicCommentRequest:
type: object
required: [assetId, text]
properties:
assetId: { type: string, format: uuid }
text: { type: string, minLength: 1, maxLength: 2000 }
PublicCommentResponse:
type: object
required: [commentId]
properties:
commentId: { type: string, format: uuid }
Export:
type: object
required: [id, projectId, status, createdAt]
properties:
id: { type: string, format: uuid }
projectId: { type: string, format: uuid }
preset: { type: string, example: “instagram_carousel” }
settings:
type: object
additionalProperties: true
status: { type: string, enum: [queued, running, done, failed] }
progress: { type: number, nullable: true }
createdAt: { type: string, format: date-time }
CreateExportRequest:
type: object
required: [preset]
properties:
preset:
type: string
enum: [full_res, web, instagram_carousel, story_9x16, contact_sheet_pdf]
assetIds:
type: array
items: { type: string, format: uuid }
nullable: true
description: If omitted, export uses project picks by default (server policy).
settings:
type: object
additionalProperties: true
example:
jpegQuality: 92
longEdgePx: 3840
watermark: false
ExportDownloadResponse:
type: object
required: [url]
properties:
url: { type: string }
expiresAt: { type: string, format: date-time, nullable: true }
Job:
type: object
required: [id, type, status, createdAt]
properties:
id: { type: string, format: uuid }
projectId: { type: string, format: uuid, nullable: true }
assetId: { type: string, format: uuid, nullable: true }
type: { type: string }
status: { type: string, enum: [queued, running, done, failed, canceled] }
progress: { type: number, nullable: true }
error: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
PageInfo:
type: object
required: [nextCursor]
properties:
nextCursor: { type: string, nullable: true }
PagedProjects:
type: object
required: [items, pageInfo]
properties:
items:
type: array
items: { $ref: “#/components/schemas/Project” }
pageInfo: { $ref: “#/components/schemas/PageInfo” }
PagedAssets:
type: object
required: [items, pageInfo]
properties:
items:
type: array
items: { $ref: “#/components/schemas/Asset” }
pageInfo: { $ref: “#/components/schemas/PageInfo” }
PagedClusters:
type: object
required: [items, pageInfo]
properties:
items:
type: array
items: { $ref: “#/components/schemas/Cluster” }
pageInfo: { $ref: “#/components/schemas/PageInfo” }
PagedJobs:
type: object
required: [items, pageInfo]
properties:
items:
type: array
items: { $ref: “#/components/schemas/Job” }
pageInfo: { $ref: “#/components/schemas/PageInfo” }
SSE event format (recommended):
- event: job.progress
- data: {“jobId”:”…”,”assetId”:”…”,”progress”:0.72,”status”:”running”,”type”:”embedding”}
- id: <monotonic-string>
Figma-ready wireframe checklist
This is the exact build list you hand to design + frontend so nothing gets “interpreted to death.”
Design tokens
- Spacing: 4 / 8 / 12 / 16 / 24 / 32
- Radius: 10 (cards), 8 (buttons), 6 (inputs)
- Typography:
- Display (project titles)
- UI (buttons, labels)
- Mono (metadata like ISO/shutter)
- Elevations: 0 / 1 / 2 / 3 (only for overlays/modals)
- Interaction: 150–220ms transitions for cull navigation + modals
- Theme: dark-first (photo dominates), light optional later
Component inventory (Figma Components)
App shell
- Sidebar (collapsed/expanded)
- Top bar
- Breadcrumb / project switcher
- Processing status pill + queue drawer
- Command palette modal
Project & ingest
- Project card (with progress ring)
- “New project” modal
- Import source picker
- Upload row (filename, size, status, retry)
- Progress bar + throughput indicator
- Error “quarantine” row
Grid & viewing
- Photo grid tile
- thumbnail
- pick/reject badges
- rating overlay
- processing badge (e.g. “embedding…”)
- Filmstrip (scrollable)
- Image canvas viewer
- Zoom toggle + 100% loupe
- Compare viewer (2-up, 4-up)
Culling
- Cluster list item (“moment stack”)
- Cluster header (reviewed state, confidence)
- “Winner suggested” badge
- Key-hint overlay (optional toggle)
- “Why winner” chips (3 max, clickable)
- Cluster split/merge controls (drawer)
Editing
- Slider row
- Curve editor (minimal, V1 can be hidden behind “Advanced”)
- HSL rows
- WB controls
- “Apply Style DNA” CTA + confidence meter
- Version list (timeline)
- Before/After toggle
Search
- Search bar + filter chips
- Filter drawer (camera, date, rating, picked)
- Saved Smart Collection list item
Client gallery
- Gallery builder stepper
- Share link card + copy button
- Settings toggles (watermark/download/password)
- Client grid tile (favorite + comment count)
- Comment drawer
- “Selections summary” panel
Exports
- Export preset card
- Export job row (status, progress, download)
System UI
- Toasts (success/error)
- Skeleton loaders
- Empty states
- Confirmation dialogs
Screen specs (states + interactions)
1) Projects
States
- Empty (first run)
- List of projects
- Loading
- Error
Interactions
- Create project (modal)
- Jump actions: Import / Cull / Gallery
2) Ingest
States
- Source selection
- Upload in progress (resumable)
- Processing pipeline progress
- Partial-ready (thumbnails ready, AI still cooking)
- Errors (corrupted, permission, storage quota)
Must-have interactions
- “Go to Cull” becomes active immediately
- “Resume upload” if interrupted
3) Cull (primary performance screen)
States
- AI not ready (fallback: chronological filmstrip + basic blur score)
- AI ready (clusters + winner suggestions)
- Cluster locked (reviewed)
- Compare mode
- Undo/redo
Keyboard
- F pick winner + advance
- D reject (toggle); Shift+D reject cluster
- 1–5 rating
- S star
- C compare
- Arrow keys navigate
- Z undo
Micro-interactions
- After picking winner: cluster item marks reviewed + auto-advance
- Explainability chips appear under winner (max 3)
4) Edit
States
- No edit selected
- Style DNA suggestion available (confidence)
- Style DNA low confidence (prompts “choose a hero frame”)
- Batch apply in progress
- Version history
Interactions
- Apply to selected
- Sync from hero
- Before/after toggle
5) Library/Search
States
- Query empty: show Recents + Smart Collections
- Results grid
- Filters applied
- Save Smart Collection (name prompt)
Interactions
- Command palette drives everything
6) Client gallery builder
States
- Step 1: choose set
- Step 2: settings
- Step 3: publish
- Step 4: monitor activity
Client view states
- Requires password
- Favoriting
- Commenting
- Compare
7) Exports
States
- Preset selection
- Running
- Completed (download)
- Failed (retry)
Model card templates
Use this structure for every model shipped. It keeps your AI honest and your team sane.
Model Card Template (copy/paste)
1) Model name
- Name:
- Version:
- Owner:
- Date:
2) Overview
- What it does:
- Inputs:
- Outputs:
- Where it runs: (device / server / hybrid)
- Latency target:
- Dependencies:
3) Intended use
- Primary use cases:
- Supported content types:
- User-facing surfaces:
4) Out of scope / prohibited use
- Not designed for:
- Blocked behaviors:
5) Training data
- Source:
- Consent / licensing:
- Time range:
- Representativeness notes:
- Data minimization strategy:
6) Evaluation
- Offline metrics:
- Online metrics:
- Benchmarks:
- Regression gates (ship/no-ship criteria):
7) Limitations
- Known failure modes:
- Worst-case scenarios:
- When to fallback:
8) Safety, privacy, and security
- Sensitive attribute handling:
- Face/person features:
- On-device processing:
- Data retention:
- Audit logging:
9) Monitoring
- Drift signals:
- Performance alerts:
- User feedback capture:
- Rollback plan:
10) Change log
- vX → vY changes:
- Migration notes:
Filled example: Cull Winner Ranker (Personalized)
1) Model name
- Name: CullWinnerRanker
- Version: 1.0.0
- Owner: ML Team
- Date: 2026-01-14
2) Overview
- What it does: Ranks candidates inside a cluster/burst and suggests a winner.
- Inputs: image preview pixels (or embeddings), technical signals (sharpness/blur/exposure), context signals (burst position), user preference profile.
- Outputs: ordered list + winner + “why winner” reasons.
- Where it runs: server for ranking; optional lightweight on-device rescoring.
- Latency target: < 100ms per cluster after signals are computed.
3) Intended use
- Primary use cases:
- Winner preselect in Cull stacks
- “Top 3 candidates” for compare mode
4) Out of scope / prohibited use
- Not designed to judge “beauty” or identity attributes.
- Not used for hiring, surveillance, or sensitive inference.
5) Training data
- Source: opt-in photographer culling logs (picked/rejected) + synthetic negatives (duplicates).
- Consent: explicit opt-in; default is no training.
- Minimization: store only derived features + decisions, not originals (unless explicitly opted in).
6) Evaluation
- Offline:
- Top-1 accuracy (winner matches human)
- Top-3 hit rate
- Regret rate (winner overridden)
- Online:
- Time-to-cull completion
- Override rate trend after 20 picks (personalization gain)
7) Limitations
- Failure modes:
- Weird lighting / heavy motion blur
- Cluttered scenes where “best” is subjective
- Fallback:
- Reduce confidence, suggest top 3 not top 1, show compare immediately
8) Safety, privacy, security
- Face features are optional; if disabled, no face-derived signals used.
- Logs are per-user, access-controlled, encrypted.
9) Monitoring
- Drift: override rate spikes, cluster split/merge frequency spikes.
- Rollback: instant model router revert.
10) Change log
- v1: adds personalization layer and reason chips.
Style DNA training loop pseudocode (privacy-safe)
This is a practical V1 approach:
- learns from your edit deltas
- stores only lightweight stats / weights
- supports context buckets (day/night/indoor)
- produces a confidence score
- never needs to upload raws by default
Data structures
from dataclasses import dataclass
from typing import Dict, List, Tuple, Optional
import numpy as np
@dataclass
class EditEvent:
# Derived features only; no original pixels required for this training loop.
# (You can derive these from previews locally.)
features: np.ndarray # e.g. 256-d image/style features
context: np.ndarray # e.g. 16-d lighting/camera context
params_before: np.ndarray # baseline “Fix” params (objective)
params_after: np.ndarray # final user-approved params
weight: float # e.g. 1.0, or lower for uncertain edits
@dataclass
class RidgeModel:
# W maps [features+context] -> delta_params
W: np.ndarray # shape: (d_in, d_out)
b: np.ndarray # shape: (d_out,)
n: int # number of samples absorbed
# For confidence: track running mean/cov of inputs (or just mean + diag var)
mu: np.ndarray
var: np.ndarray # diagonal variance
@dataclass
class StyleProfile:
# Mixture of context buckets (simple, robust)
buckets: Dict[str, RidgeModel]
version: str
Context bucketing (simple + effective)
def context_bucket(context: np.ndarray) -> str:
“””
Example: context features could include:
– estimated CCT (color temp)
– ISO level bucket
– indoor/outdoor probability
– time-of-day bucket
“””
cct = context[0]
iso = context[1]
indoor_prob = context[2]
if indoor_prob > 0.7:
return “indoor”
if cct < 4200:
return “cool_light”
if iso > 1600:
return “high_iso_night”
return “daylight”
Training/update (incremental ridge regression)
We learn delta params = (user_after – baseline_fix).
That means the system’s “Fix” remains stable and the “Style DNA” is the personal layer.
def init_ridge(d_in: int, d_out: int) -> RidgeModel:
return RidgeModel(
W=np.zeros((d_in, d_out), dtype=np.float32),
b=np.zeros((d_out,), dtype=np.float32),
n=0,
mu=np.zeros((d_in,), dtype=np.float32),
var=np.ones((d_in,), dtype=np.float32),
)
def update_running_stats(model: RidgeModel, x: np.ndarray, alpha: float = 0.02):
# Exponential moving average stats for confidence
model.mu = (1 – alpha) * model.mu + alpha * x
diff = x – model.mu
model.var = (1 – alpha) * model.var + alpha * (diff * diff)
def ridge_update_closed_form(
model: RidgeModel,
x: np.ndarray,
y: np.ndarray,
lr: float = 0.05,
l2: float = 1e-2,
sample_weight: float = 1.0,
):
“””
Lightweight online update (SGD-ish on ridge objective).
Good enough for V1. Replace with true online ridge if needed later.
“””
# Prediction
y_hat = x @ model.W + model.b
err = (y_hat – y) # shape (d_out,)
# Gradients
grad_W = np.outer(x, err) + l2 * model.W
grad_b = err
# Update
model.W -= lr * sample_weight * grad_W
model.b -= lr * sample_weight * grad_b
model.n += 1
def absorb_edit_event(profile: StyleProfile, e: EditEvent):
x = np.concatenate([e.features, e.context]).astype(np.float32)
y = (e.params_after – e.params_before).astype(np.float32) # delta params
b = context_bucket(e.context)
if b not in profile.buckets:
profile.buckets[b] = init_ridge(d_in=x.shape[0], d_out=y.shape[0])
m = profile.buckets[b]
update_running_stats(m, x)
ridge_update_closed_form(m, x, y, lr=0.03, l2=5e-3, sample_weight=e.weight)
Inference (apply Style DNA + confidence gate)
def confidence_score(model: RidgeModel, x: np.ndarray) -> float:
“””
Confidence based on normalized distance from training distribution.
Higher distance = lower confidence.
“””
z = (x – model.mu) / (np.sqrt(model.var) + 1e-6)
dist = float(np.sqrt(np.mean(z * z)))
# Map distance -> confidence in [0,1]
return float(np.clip(np.exp(-0.7 * dist), 0.0, 1.0))
def predict_style_delta(profile: StyleProfile, features: np.ndarray, context: np.ndarray) -> Tuple[np.ndarray, float, str]:
x = np.concatenate([features, context]).astype(np.float32)
b = context_bucket(context)
if b not in profile.buckets or profile.buckets[b].n < 25:
# Not enough data: low confidence fallback
return np.zeros((0,), dtype=np.float32), 0.0, b
m = profile.buckets[b]
conf = confidence_score(m, x)
delta = x @ m.W + m.b
return delta, conf, b
def apply_style_dna(
baseline_fix_params: np.ndarray,
predicted_delta: np.ndarray,
conf: float,
conf_threshold: float = 0.55,
) -> np.ndarray:
if conf < conf_threshold:
# Fallback: apply only baseline fixes (objective)
return baseline_fix_params
# Clamp deltas to sane ranges to avoid wild edits
delta_clamped = np.clip(predicted_delta, -0.5, 0.5) # example
return baseline_fix_params + delta_clamped
Privacy-safe logging (default)
- Store only:
- hashed asset id
- derived feature vectors (optionally quantized)
- context features
- params delta
- Don’t store:
- originals
- previews
- face embeddings (unless opt-in and local)
Optional opt-in “improve global model” path:
- send noised aggregates (DP-ish) like mean deltas per bucket, not raw examples
def export_aggregate_for_opt_in(profile: StyleProfile, noise_std: float = 0.02) -> Dict:
“””
Optional: export coarse aggregates only, with noise.
“””
out = {}
for k, m in profile.buckets.items():
if m.n < 100:
continue
# Export only weights + stats, optionally noise them
W = m.W + np.random.normal(0, noise_std, size=m.W.shape).astype(np.float32)
b = m.b + np.random.normal(0, noise_std, size=m.b.shape).astype(np.float32)
out[k] = {“W”: W.tolist(), “b”: b.tolist(), “n”: m.n}
return out
Extra: “Definition of Done” gates (so this ships clean)
Cull
- Winner suggestion Top-1 accuracy ≥ 60% on beta dataset
- After 20 picks personalization improves Top-1 ≥ +10 pts
- Override rate trending down week-over-week
Style DNA
- “Deliverable as-is” ≥ 65% for selected beta users (human eval)
- Confidence gating prevents “ruined batch” incidents (hard requirement)
Search
- Precision@10 ≥ 0.7 on labeled queries
- Median query latency < 500ms
If you want, I can now generate the frontend contract too (TypeScript types matching the OpenAPI schemas + a client SDK layout), plus a job processor spec (queue, retries, idempotency rules, and how clustering/ranking jobs should be chained).