Skip to main content

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

RouterPrefixAuth
clientnoneNone. Static onboarding page and voice previews.
workflow/v1None, except GET /v1/avatars. Creator actions are additionally role-guarded.
avatars/apiX-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 rolePOST /v1/runs and POST /v1/avatars/{avatar_id}/voice-clone call require_creator_role, which returns 403 for a non-creator.

warning

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.

ConditionStatus
Validation failure, illegal state transition, or ownership violation on a POST422
Missing creator subscription403
Run, job, or avatar not found404
Bad or missing X-Api-Key401
Upload larger than 25 MB413
Missing domain dataset file, unconfigured WS_AGENT_API_KEY, or a workflow core that is not ready503
warning

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

MethodPathDescription
GET/client/onboardingThe bundled single-page onboarding client (onboarding/static/onboarding.html).
GET/client/onboarding.htmlSame file.
GET/static_onboarding.htmlSame file, legacy path.
GET/v1/voice-previewsVoice 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"
}
FieldRules
creator_idRequired, non-empty.
avatar_idRequired UUID. A non-UUID value is a 422.
specialty_textRequired, non-empty; sanitized and capped at DOMAIN_MAX_INPUT_LENGTH (256) characters.
seed_examplesOptional, but when present must hold 20–50 items. Omit to submit them later.
domain_id_overrideOptional. Skips embedding matching; must exist in the catalog and have a dataset on disk.
modeOnly "train" is accepted.
elevenlabs_voice_idOptional; 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.

PartTypeNotes
creator_idform fieldRequired.
filefileRequired.

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": "…" }
FieldBehaviour
nameWritten to user_avatar_name, leaving the domain-derived system_name intact.
is_publishedPublication flag.
character_storyMax 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": "…"
}
KeyContents
next_actionThe client's next step — see Run Lifecycle.
domain_match / domain_confirmationThe embedding decision and the recorded assignment. Also mirrored under domain and domain_assignment.
smartGeneration summary. Also mirrored as personalization_job.
reviewReview status plus the generated samples.
trainingThe training manifest once a train job exists, otherwise a status-only summary from the latest train job, otherwise null.
avatar_profileAdapter publication state: not_started, queued during training, stored_locally after sync.
finalFinal-dataset summary — path, counts, and blend metadata. null before finalization.
request_payload, review_payload, artifact_manifestThe raw JSONB columns, unfiltered.
note

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.