Profiles & config
A profile holds an avatar’s system_prompt, backstory, tools_json and config_json. It is
what the dashboard writes when you edit an avatar’s behaviour, and what every session resolves
against unless you override it.
Profiles are separate from versions on purpose. A version is the sealed identity — face, voice,
bundle — and changing it means re-sealing and re-syncing the GPU fleet. A profile is text: appending
one is cheap, does not re-seal the bundle, does not change its content_hash, and does not make
any box re-download anything. That is why prompt editing is a different operation from publishing a
new version.
Profiles are append-only and hash-deduped: saving identical content returns the existing row
instead of creating a duplicate. An avatar’s default_profile_id is what every mint without a
profile_id resolves to.
All routes exist in both shapes — token-relative (/api/v1/avatars/…) and explicit
(/api/v1/workspaces/{wsId}/avatars/…).
Create a profile
This is what “Save” in the dashboard does.
POST /api/v1/avatars/{avatarId}/profilesRequired scope: avatars:write (or user:write), plus the custom_avatars feature.
{ "system_prompt": "You are Lyra, Acme's product guide. Be concise and concrete.", "backstory": "A former archivist who now helps people find things.", "tools_json": { "inline": [ { "name": "lookup_order", "description": "Look up an order by id", "input_schema": { "type": "object", "properties": { "order_id": { "type": "string" } }, "required": ["order_id"] } } ] }, "config_json": { "greeting_enabled": true, "greeting_hold_ms": 2000 }, "set_default": true}Response 201 (or 200 — see below):
{ "id": "019fe4c1-2b7a-7000-8044-9e1d0f7a5c33", "avatar_id": "019fe463-fbd3-7000-80e8-85dad81d3fee", "hash": "a2271f20509d8c…", "hash_short": "a2271f20509d", "created_at": 1786249001, "created_by": "usr_3f21…", "is_default": true, "created": true}| Detail | Behaviour |
|---|---|
set_default: true | Makes it the avatar’s default — the dashboard sends this by default |
created: true + 201 | A genuinely new profile |
created: false + 200 | Identical content already existed; you got that row back |
| Omitted field | Stored as null, meaning “fall through to the version” — not “empty string” |
The 200-vs-201 distinction lets an editor tell the user “no changes — this is already version
a2271f20509d” instead of appearing to save nothing.
List profiles
GET /api/v1/avatars/{avatarId}/profilesRequired scope: avatars:read (or user:read)
{ "data": [ { "id": "019fe4c1-2b7a-7000-8044-9e1d0f7a5c33", "hash": "a2271f20509d8c…", "hash_short": "a2271f20509d", "created_at": 1786249001, "created_by": "usr_3f21…", "is_default": true, "sizes": { "system_prompt": 812, "backstory": 240, "tools_json": 318, "config_json": 46 } } ], "default_profile_id": "019fe4c1-2b7a-7000-8044-9e1d0f7a5c33"}The list returns sizes, not bodies — a list of forty 40 KB prompts is not a list.
Get one profile
GET /api/v1/avatars/{avatarId}/profiles/{profileId}Same fields plus the text itself — this is what prefills an editor:
{ "id": "019fe4c1-…", "avatar_id": "019fe463-…", "hash_short": "a2271f20509d", "is_default": true, "sizes": { "system_prompt": 812, "backstory": 240, "tools_json": 318, "config_json": 46 }, "system_prompt": "You are Lyra, Acme's product guide. Be concise and concrete.", "backstory": "A former archivist who now helps people find things.", "tools_json": { "inline": [ … ] }, "config_json": { "greeting_enabled": true }}Switch the default
POST /api/v1/avatars/{avatarId}/profiles/{profileId}/defaultThis is also how you roll back a prompt change: profiles are immutable, so the previous one is still there.
{ "ok": true, "avatar_id": "019…", "default_profile_id": "019…", "hash_short": "a2271f20509d" }Delete a profile
DELETE /api/v1/avatars/{avatarId}/profiles/{profileId}Deleting the current default is refused with 409 profile_is_default — switch first, then delete.
Otherwise the avatar would silently fall back to its sealed version’s text, which looks like data
loss to whoever is talking to it.
Stock avatars are read-only
Profile mutations on a ws_stock avatar are refused with 403 stock_read_only. Reads stay open —
seeing what a stock avatar says is how you decide whether to clone it. To customize one, clone it
into your own workspace; per-session overrides also work against stock avatars,
since they change nothing stored.
Avatars created before profiles existed
Their profile #1 is synthesized lazily from the published version’s text on first read or mint. You never see an avatar with no profile; you may see one whose profile is newer than the avatar.
The config group
Structured options that shape behaviour without being prompt text. Stored as config_json on the
profile, overridable per session with the mint’s config parameter.
The namespace is curated: unknown keys are rejected, both when authoring and at mint.
| Key | Type | Default | Range | What it does |
|---|---|---|---|---|
greeting_enabled | boolean | true | — | Whether the avatar speaks a proactive opener when the session starts |
greeting_hold_ms | integer | 2000 | 0–5000 | How long the box waits for your session context to arrive before the first generated turn. 0 disables the hold |
render | boolean | true | — | Session-only. false is brain-only mode: the box runs the conversation brain but skips speech and video, and turns come back as text plus a brain payload |
The defaults are descriptive — they document what the GPU fleet does when a key is unset. Setting a key is what overrides the fleet-wide behaviour.
greeting_hold_ms in practice. When a session carries overrides, that text reaches the box a
moment after the connection opens. The hold makes the box wait — up to this many milliseconds —
before generating its first spoken turn, so the opener reflects your context instead of the
baseline persona. Raise it when the opener must always reflect per-session context; lower it (or
0) for the fastest possible first word. The scripted intro clip plays immediately either way.
Session-only keys. A key marked session-only may be sent in a mint’s config, but a profile
may not store it. render is one: a stored render: false would turn off video for every session
on that avatar, including the ones that expected a talking head, so authoring rejects it with
400 invalid_config_json and detail: "config_key_session_only:render". Ask for brain-only mode
one session at a time.
Overriding config for one session
{ "avatar_id": "019…", "config": { "greeting_enabled": false } }Config merges per key, not wholesale:
session config[key] ?? profile config_json[key] ?? GPU fleet defaultA profile with {greeting_enabled: false, greeting_hold_ms: 3000} plus a session
{greeting_hold_ms: 0} runs with greeting_enabled: false and greeting_hold_ms: 0. Sending
one key never clears the others.
null for a key means “unset” and is dropped, so {}, {"greeting_enabled": null} and an absent
config are identical and hash the same.
Config errors
| Response | Cause |
|---|---|
400 invalid_config + detail: "unknown_config_key:foo" | Key not in the catalog |
400 invalid_config + detail: "config_not_boolean:greeting_enabled" | Wrong type |
400 invalid_config + detail: "config_out_of_range:greeting_hold_ms" | Outside the documented range |
400 invalid_config + detail: "config_too_large" | Over 1,024 serialized bytes |
400 invalid_config_json + detail: "config_key_session_only:render" | A session-only key sent to profile authoring |
400 invalid_config_json + same detail vocabulary | The profile-authoring equivalent |
Tools
Tools let the avatar call back into your systems mid-conversation. They resolve on the same ladder as the prompt text:
1. mint `tools` — replaces every lower layer wholesale2. profile `tools_json` — the avatar's stored default3. version `tools_json` — the sealed baseline+ mint `extra_tools` — merged in on top of whatever wonConfig shape
{ "inline": [ { "name": "lookup_order", "description": "Look up an order by its id and return status and ETA.", "input_schema": { "type": "object", "properties": { "order_id": { "type": "string" } }, "required": ["order_id"] } } ], "servers": ["mcp_billing"], "allow": ["lookup_order"], "max_calls": 20, "history": true}| Field | Meaning |
|---|---|
inline | Tool definitions declared here. Max 16 tools; description ≤ 300 chars; names unique |
servers | References to MCP servers registered in your workspace. Max 4 |
allow | Allowlist filter over everything resolved — useful to narrow a stored set for one session |
max_calls | Tool calls per session. Default 20, max 100 |
history | Whether conversation history is sent to your tool endpoint |
Any other key is 400 unknown_tools_field:<key>.
Narrowing or replacing for one session
// Replace: only this tool, whatever the profile says{ "avatar_id": "019…", "tools": { "allow": ["lookup_order"], "inline": [ … ] } }
// Add: the avatar's stored tools PLUS one more for this session{ "avatar_id": "019…", "extra_tools": { "inline": [ { "name": "escalate_ticket", "description": "…", "input_schema": { … } } ] } }The mint response echoes what resolved as tools_resolved: ["lookup_order", "escalate_ticket"]. If
that field is absent, the session has no tools.
Stored vs. sent
A malformed stored tools_json is never fatal: the mint runs with no tools rather than failing
a live call. A malformed sent tools is a 400 — it is your input, this request. Same
asymmetry as the prompt caps, for the same reason: authoring time is where you should learn about
bad configuration, not connect time.
Related
- Prompt & overrides — how these stored values compose with per-session parameters
- Avatars — the avatar and version resources these profiles hang off