Character Generation from an Image
Generate a complete character (persona, voice, appearance) from a single image, then optionally generate a 3D model from the resulting appearance description.
Base path: /api/v1/characters
The generation pipeline has two steps:
- Image-to-Character -- Upload an image, receive a fully populated character with persona, voice description, and appearance description. Persona only -- no 3D model is generated.
- Generate Model (optional) -- Trigger 3D model generation for an existing character, then poll for status.
:::info Migrating from /api/generate
The older /api/generate/... routes still work, but they are deprecated. Their responses carry Deprecation and Link headers naming the replacement route, plus a Sunset header once a removal date is set. Field names and status codes differ between the two generations of routes, so read the legacy migration guide before you port a client.
:::
Authentication & Player Scoping
Same authentication as the Characters API.
| Header | Required | Description |
|---|---|---|
X-API-Key | Yes | Developer API key (est_...) |
X-Player-Id | No | If present, the created character is scoped to that player. |
X-Org-Id | No | If present, the created character is owned by that organization. |
Idempotency-Key | No | Any string up to 255 chars. On Image to Character, the same key replays the character it already created for 24 hours instead of generating a new one. A second request sent while the first is still in flight returns 409. |
Image to Character
POST /api/v1/characters/from-image
Generate a character persona from an uploaded image. The response is a fully populated character with persona, voice description, and appearance description. model_status is null, because no 3D model is generated by this endpoint.
Content Types
This endpoint accepts two payload formats:
multipart/form-data (preferred)
Send the image as a file upload. Use this whenever the client can send multipart/form-data.
Field names on this endpoint are camelCase.
| Field | Type | Required | Description |
|---|---|---|---|
image | file | Yes | Image to analyze (jpeg/png/webp). The file part carries its own content type, so mimeType does not exist on this variant. |
ttsProvider | string | No | "inworld" (default) or "elevenlabs" |
useDefaultVoice | boolean | No | Default false. When true, the character uses the default voice for the chosen TTS provider. |
appearancePrompt | string | No | Replaces the default instruction used to generate the character's appearance text (max 500 chars). Output is still capped at 750 chars. |
voicePrompt | string | No | Replaces the default instruction for the voice description (max 300 chars). Output is still capped at 200 chars. |
personaPrompt | string | No | Replaces the default instruction for the personality text (max 500 chars). Output is still capped at 400 chars. |
application/json
Send the image inline as a base64-encoded string. Useful for clients that cannot send multipart bodies (e.g., Snap Spectacles).
{
"image": "<base64-encoded image>",
"mimeType": "image/jpeg",
"appearancePrompt": "Optional override (max 500 chars)",
"voicePrompt": "Optional override (max 300 chars)",
"personaPrompt": "Optional override (max 500 chars)"
}
| Field | Type | Required | Description |
|---|---|---|---|
image | string | Yes | Base64-encoded image data (minimum length 1) |
mimeType | string | No | "image/jpeg" (default), "image/png" or "image/webp". JSON variant only. |
ttsProvider | string | No | Same as multipart variant |
useDefaultVoice | boolean | No | Same as multipart variant |
appearancePrompt | string | No | Same as multipart variant |
voicePrompt | string | No | Same as multipart variant |
personaPrompt | string | No | Same as multipart variant |
:::info JSON+base64 size limit
Maximum base64 payload is approximately 7.5 MB, which encodes to roughly a 10 MB image. Prefer multipart/form-data whenever your client supports it.
:::
Response
Status: 201 Created
Returns a Character object, the same shape and the same snake_case field names that GET /api/v1/characters/{character_id} returns:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Generated name",
"tagline": "Short tagline",
"personality": "Generated personality...",
"background": null,
"avatar": "https://...",
"model_status": null,
"tts_provider": "inworld",
"tts_voice": "MF3mGyEYCl7XYWbV9V6O",
"llm_provider": "openai",
"llm_model": "gpt-5-mini",
"created_at": "2026-01-15T10:30:00"
}
The response carries every field of the Character object, not only the ones shown above.
model_status is null because no 3D model is generated. To generate a 3D model after creation, call POST /api/v1/characters/{character_id}/model.
Errors
| Status | Cause |
|---|---|
400 | The base64 image could not be decoded |
409 | A request with the same Idempotency-Key is already in flight |
415 | The request was neither multipart/form-data nor application/json |
Examples
# multipart
curl -X POST https://api.estuary-ai.com/api/v1/characters/from-image \
-H "X-API-Key: est_your_api_key" \
-F "image=@./photo.jpg" \
-F "personaPrompt=Generate a witty, mischievous personality."
# JSON+base64 (Spectacles-style)
B64=$(base64 -i ./photo.jpg)
curl -X POST https://api.estuary-ai.com/api/v1/characters/from-image \
-H "X-API-Key: est_your_api_key" \
-H "Content-Type: application/json" \
-d "{\"image\":\"$B64\",\"mimeType\":\"image/jpeg\"}"
Generate 3D Model
POST /api/v1/characters/{character_id}/model
Trigger 3D model generation for an existing character. The character must already have an appearance description (either from Image to Character or set manually).
This endpoint also serves as the retry path for failed generation jobs.
Request Body
The body is optional. Sending no body at all is valid and takes the static path.
{ "rigged": false }
| Field | Type | Default | Description |
|---|---|---|---|
rigged | boolean | false | When true, generate a rigged model that carries animation clips instead of a static one. |
Response
Status: 202 Accepted
{
"characterId": "550e8400-e29b-41d4-a716-446655440000",
"modelStatus": "generating",
"rigged": false
}
| Field | Type | Description |
|---|---|---|
characterId | string | Character ID |
modelStatus | string | "generating" on the static path, "posing" on the rigged path |
rigged | boolean | Echoes the path that was taken |
:::info No task IDs in the response
External provider task IDs are deliberately not returned here. The legacy POST /api/generate/{agent_id}/generate-model route answered with agentId plus provider task IDs such as meshyPreviewTaskId; those fields are legacy only. Poll GET /api/v1/characters/{character_id}/model for progress instead.
:::
Errors
| Status | Cause |
|---|---|
400 | Character has no appearance description |
404 | Character not found |
409 | Generation already in progress (status is "generating" or "preview_ready") |
429 | The model generation quota is exhausted |
502 | 3D model service failed |
503 | 3D model service not configured |
Rate Limiting
POST /api/v1/characters/{character_id}/model shares a 50-request-per-hour bucket with POST /api/v1/characters/from-image. Excess requests return 429 Too Many Requests.
Example
curl -X POST https://api.estuary-ai.com/api/v1/characters/550e8400-e29b-41d4-a716-446655440000/model \
-H "X-API-Key: est_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "rigged": false }'
After calling this endpoint, poll GET /api/v1/characters/{character_id}/model for progress.
Get Model Status
GET /api/v1/characters/{character_id}/model
Poll the current state of 3D model generation. Polling is what advances the generation pipeline, so keep calling this endpoint until modelStatus reaches "ready" or "failed".
When X-Player-Id is sent and does not match the character's owning player, the response is 404.
Response
{
"modelStatus": "preview_ready",
"modelPreviewUrl": "https://.../preview.glb",
"modelUrl": "https://.../final.glb",
"thumbnailUrl": "https://.../thumb.png",
"progress": 60,
"rigged": false,
"animations": []
}
No field is required, so treat every key as possibly absent.
| Field | Type | Description |
|---|---|---|
modelStatus | string | null | One of: "generating", "preview_ready", "refining", "ready", "failed", or null if no job has run. A rigged job reports its own intermediate values (starting at "posing"), so treat any unrecognized value as still in progress. |
modelPreviewUrl | string | null | Low-poly preview GLB URL (available once status reaches "preview_ready") |
modelUrl | string | null | Final refined GLB URL (available once status reaches "ready") |
thumbnailUrl | string | null | Preview thumbnail image |
progress | integer | Progress in percent (0-100) |
rigged | boolean | true when modelUrl points at a skinned model carrying animation clips |
animations | array | Animation channel names inside the model. Empty for static models. |
Example
curl https://api.estuary-ai.com/api/v1/characters/550e8400-e29b-41d4-a716-446655440000/model \
-H "X-API-Key: est_your_api_key"
async function pollModel(characterId: string, apiKey: string) {
while (true) {
const res = await fetch(
`https://api.estuary-ai.com/api/v1/characters/${characterId}/model`,
{ headers: { "X-API-Key": apiKey } },
);
const data = await res.json();
if (data.modelStatus === "ready" || data.modelStatus === "failed") {
return data;
}
await new Promise((r) => setTimeout(r, 5000));
}
}