API Reference
HTTP reference for the Onboarding V4 Workflow API (app.py, FastAPI title Onboarding V4 Workflow API, version 4.0.0). Routes are split across three routers with different authentication rules.
Conventions
| Router | Prefix | Auth |
|---|---|---|
| client | none | None. Static onboarding page and voice previews. |
| workflow | /v1 | None, except GET /v1/avatars. Creator actions are additionally role-guarded. |
| avatars | /api | X-Api-Key on every route. |
GET /health sits at the root and returns {"status": "ok", "service": "workflow"} without touching the database or any upstream.
CORS is driven by ONBOARDING_CORS_ORIGINS (comma-separated). It is empty by default, so no cross-origin browser access is allowed; credentials are never accepted.
Authentication
X-Api-Key — compared against WS_AGENT_API_KEY with secrets.compare_digest. A missing or wrong key returns 401; an unset WS_AGENT_API_KEY on the server returns 503. It guards GET /v1/avatars and every /api route.
Creator role — POST /v1/runs and POST /v1/avatars/{avatar_id}/voice-clone call require_creator_role, which returns 403 for a non-creator.
Role resolution is a stub. common/auth._StubUserRepository returns creator for any non-empty id and user for an empty one, so the role check currently only rejects a blank creator_id. The UserRepository protocol is the documented injection point for the real subscription lookup.
Ownership — run-scoped endpoints accept a user_id and compare it against the run's creator_id. Read endpoints take it as a query parameter; the three creator actions read it from the query string too, since their request bodies have no user_id field. When it is omitted, no ownership check runs at all.
Error mapping
There is no custom exception handler; each route maps domain exceptions to status codes itself.
| Condition | Status |
|---|---|
Validation failure, illegal state transition, or ownership violation on a POST | 422 |
| Missing creator subscription | 403 |
| Run, job, or avatar not found | 404 |
Bad or missing X-Api-Key | 401 |
| Upload larger than 25 MB | 413 |
Missing domain dataset file, unconfigured WS_AGENT_API_KEY, or a workflow core that is not ready | 503 |
The two run read endpoints catch only FileNotFoundError. An ownership violation raises ValueError there, which is unhandled and surfaces as 500 instead of 403/404. Treat 500 from GET /v1/runs/{run_id} with a user_id as a possible ownership mismatch.
Client routes
| Method | Path | Description |
|---|---|---|
GET | /client/onboarding | The bundled single-page onboarding client (onboarding/static/onboarding.html). |
GET | /client/onboarding.html | Same file. |
GET | /static_onboarding.html | Same file, legacy path. |
GET | /v1/voice-previews | Voice roster with preview availability. |
GET | /v1/voice-previews/audio/{filename} | One preview audio file. |
See Voice for the roster and preview payloads.
Workflow routes
GET /v1/users/{user_id}/role
Returns {"user_id": "...", "role": "creator" | "user"}.
POST /v1/runs
Creates a run and starts domain matching. Requires the creator role.
{
"creator_id": "creator-42",
"avatar_id": "0b8e9a34-8f6d-4a3d-9c11-7a51e2f0c1d2",
"specialty_text": "I teach school algebra with step-by-step Socratic questions.",
"seed_examples": ["…", "…"],
"domain_id_override": null,
"mode": "train",
"elevenlabs_voice_id": "JBFqnCBsd6RMkjVDRZzb"
}
| Field | Rules |
|---|---|
creator_id | Required, non-empty. |
avatar_id | Required UUID. A non-UUID value is a 422. |
specialty_text | Required, non-empty; sanitized and capped at DOMAIN_MAX_INPUT_LENGTH (256) characters. |
seed_examples | Optional, but when present must hold 20–50 items. Omit to submit them later. |
domain_id_override | Optional. Skips embedding matching; must exist in the catalog and have a dataset on disk. |
mode | Only "train" is accepted. |
elevenlabs_voice_id | Optional; must match ^[A-Za-z0-9_-]{1,64}$. |
Returns a run response. next_action will be confirm_domain, submit_seed_examples, or — when the domain matched confidently and enough seeds were supplied — none with the run already at smart_queued.
Errors: 403 non-creator · 422 validation, bad voice id, or empty specialty · 404 unknown domain_id_override · 503 domain dataset file missing.
POST /v1/runs/{run_id}/domain-confirm
{ "selected_domain_id": "math", "source": "manual" }
source must be auto or manual. The run must be in domain_confirmation_required. On success the domain is persisted, the avatar's catalog fields are synced, and the run advances to smart_queued or seed_examples_pending.
Errors: 422 wrong status, empty id, or ownership mismatch · 404 unknown domain · 503 dataset file missing.
POST /v1/runs/{run_id}/seed-examples
{ "seed_examples": ["…", "…"] }
Requires 20–50 items and a run in seed_examples_pending with a confirmed domain. Resubmitting the identical list after a job already exists is idempotent; a different list is rejected.
Errors: 422 wrong status, count out of range, no confirmed domain, conflicting resubmission, or ownership mismatch · 404 run not found.
POST /v1/runs/{run_id}/review
{ "action": "approve", "note": "looks good" }
action is approve, edit_request, or reject. Requires a run in review_pending with a current_job_id. See Run Lifecycle for what each action triggers.
Errors: 422 wrong status, no associated job, or ownership mismatch · 404 run not found.
GET /v1/runs/{run_id}
Optional ?user_id= for the ownership check. Returns the full run response.
GET /v1/runs/{run_id}/generated-samples
Optional ?user_id=. Returns the samples awaiting review:
{
"run_id": "run_…",
"status": "review_pending",
"current_stage": "workflow",
"review_status": "pending",
"generated_at": "2026-06-29T11:45:23+00:00",
"generated_examples": [ … ],
"samples": [ … ]
}
generated_examples and samples are the same list under two names. A 404 means either that the run does not exist or that generation has not produced samples yet.
GET /v1/runs
Optional ?user_id= and ?avatar_id= (UUID) filters. Returns {"runs": [...]} — full run responses, newest first, capped at 200 rows.
GET /v1/avatars
Requires X-Api-Key. Optional ?trained_only=true restricts the result to avatars whose lora_quality_passed is true. Returns {"avatars": [...]} with the raw avatar rows, active only.
POST /v1/avatars/{avatar_id}/voice-clone
multipart/form-data. Requires the creator role.
| Part | Type | Notes |
|---|---|---|
creator_id | form field | Required. |
file | file | Required. |
The extension is taken from the filename, or inferred from Content-Type: .wav, .mp3, .m4a, .ogg, .webm. Anything else is a 422. An empty body is a 422; over 25 MB is a 413.
The file is stored at state/voice/clones/{creator_id}/{avatar_id}/voice_clone_{UTC timestamp}_{random hex}{ext}, and the avatar row records voice_clone_path plus voice_clone_text (the standard preview sentence). The avatar row is created if it does not exist yet, so a clone can be uploaded before any run.
Returns a UserAvatarRecord.
Avatar routes
All four require X-Api-Key and operate only on active avatars owned by {user_id}.
GET /api/users/{user_id}/avatars
{ "user_id": "creator-42", "avatars": [ { "avatar_id": "…", "…": "…" } ] }
GET /api/users/{user_id}/avatars/{avatar_id}
Returns one UserAvatarRecord, or 404 when the avatar does not exist, is not owned by that user, or has been soft-deleted.
PATCH /api/users/{user_id}/avatars/{avatar_id}
{ "name": "Ms. Algebra", "is_published": true, "character_story": "…" }
| Field | Behaviour |
|---|---|
name | Written to user_avatar_name, leaving the domain-derived system_name intact. |
is_published | Publication flag. |
character_story | Max 6000 characters. Stored under customization.character_story. |
character_story distinguishes three cases through Pydantic's model_fields_set: omitted leaves the stored value alone, an explicit null or empty string removes the key from customization, and a non-empty string sets it. Fields left out entirely are never written.
DELETE /api/users/{user_id}/avatars/{avatar_id}
Soft delete. Returns {"avatar_id": "…", "status": "deleted"}.
The row is retained for audit and recovery with status='deleted', and is then excluded from creator lists, detail lookups, updates, training lookups, the public catalog, and catalog counts. 404 when the avatar is not found or not owned by that user.
Run response shape
Run endpoints return one wide object. Several keys are duplicated under legacy names so older clients keep working.
{
"run_id": "run_a1b2c3d4e5f6",
"status": "review_pending",
"next_action": "submit_review",
"job_id": "job_…",
"training_job_id": null,
"domain_match": { "top_matches": [ … ], "auto_assigned": true, "threshold_used": 0.85, "margin": 0.07, "detected_domain": { … } },
"domain_confirmation": { "avatar_id": "…", "selected_domain_id": "math", "source": "auto", "dataset_file": "…", "sample_count": 41 },
"run": { "run_id": "…", "status": "…", "creator_id": "…", "avatar_id": "…", "domain_id": "math", "request": { … } },
"smart": { "pipeline_status": "personal_dataset_pending_review", "generated_count": 100, "generated_at": "…" },
"review": { "status": "pending", "action": null, "note": null, "generated_examples": [ … ] },
"training": null,
"avatar_profile": { "status": "not_started", "published_to_remote": false },
"final": null,
"artifacts": { "smart": { … }, "review": { … }, "artifact_paths": { … } },
"request_payload": { … },
"review_payload": { … },
"artifact_manifest": { … },
"created_at": "…",
"updated_at": "…"
}
| Key | Contents |
|---|---|
next_action | The client's next step — see Run Lifecycle. |
domain_match / domain_confirmation | The embedding decision and the recorded assignment. Also mirrored under domain and domain_assignment. |
smart | Generation summary. Also mirrored as personalization_job. |
review | Review status plus the generated samples. |
training | The training manifest once a train job exists, otherwise a status-only summary from the latest train job, otherwise null. |
avatar_profile | Adapter publication state: not_started, queued during training, stored_locally after sync. |
final | Final-dataset summary — path, counts, and blend metadata. null before finalization. |
request_payload, review_payload, artifact_manifest | The raw JSONB columns, unfiltered. |
review_payload and artifact_manifest are returned verbatim, so a run response grows with the number of generated samples and can reach several hundred kilobytes. For polling, prefer status and next_action, and fetch samples from GET /v1/runs/{run_id}/generated-samples only when a review is actually pending.