Skip to main content

Agent Profiles

A profile is a saved, versioned bundle of agent configuration — system prompt, model, sampling parameters, MCP tools, prompt variables — that a conversation points at by id instead of re-sending the same generation config on every call.

Two things follow from that. Editing a profile changes every conversation using it, with no client release. And because each edit appends an immutable version, you can read back exactly what a turn ran against months later.

This guide is the write side: creating, editing, versioning and deleting profiles, plus the prompt fragments they compose. Pointing a conversation at a profile lives on the LLM APIs and is covered in Configuration.

These endpoints authenticate differently

Profiles are tenant configuration, not per-user data, so they take a secret key on its own — no X-On-Behalf-Of header, no end-user JWT. That is the opposite of the LLM, storage and end-user APIs, where a bare sk_… key returns 401 authenticated user_id is required.

Every profile also lives in a project, addressed with the optional X-Project-Id header. Until Projects ships, default is the only valid value and is what you get when the header is omitted.

Create a Profile

curl -X POST https://api.travila.ai/api/v1/agent-profiles/create \
-H "X-API-Key: sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"profile": {
"profileId": "nutrition_coach",
"name": "Nutrition Coach",
"description": "Food, meals and macros.",
"whenToUse": "Use when the user asks about food, meals, or macros.",
"keywords": ["nutrition", "food", "macros"],
"enabled": true,
"generationConfig": {
"model": "google/gemini-3.6-flash",
"systemPrompt": "You are a nutrition coach. Be concise and practical.",
"temperature": 0.4
}
}
}'

Response: the stored record, echoed back with version: 1.

{
"profile": {
"profileId": "nutrition_coach",
"name": "Nutrition Coach",
"description": "Food, meals and macros.",
"whenToUse": "Use when the user asks about food, meals, or macros.",
"keywords": ["nutrition", "food", "macros"],
"enabled": true,
"generationConfig": {
"model": "google/gemini-3.6-flash",
"systemPrompt": "You are a nutrition coach. Be concise and practical.",
"temperature": 0.4
},
"version": 1
}
}

profileId must be unique in the project — a duplicate fails ALREADY_EXISTS rather than overwriting. Any version you send is ignored; the store assigns 1.

Writing whenToUse

whenToUse is read by the router model, not by a human browsing a list. Write it as an instruction to a reader deciding whether this profile fits the next turn:

  • Good: Use when the user asks about food, meals, macros, or logging what they ate.
  • Poor: The nutrition profile.

description and keywords are the human-facing and lexical surfaces. Together with profileId, name and enabled, these are the only fields the router ever sees — see the library.

Read a Profile

curl -X POST https://api.travila.ai/api/v1/agent-profiles/get \
-H "X-API-Key: sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{"profileId": "nutrition_coach"}'

Response: {"profile": {...}} — the full record.

Pass version to read a specific immutable version instead of the latest:

{
"profileId": "nutrition_coach",
"version": 3
}

Omitting version (or sending 0) gives you the latest. A version that never existed returns NOT_FOUND.

List Profiles

curl -X POST https://api.travila.ai/api/v1/agent-profiles/list \
-H "X-API-Key: sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{"page": 1, "pageSize": 25}'

Response:

{
"profiles": [{"profileId": "nutrition_coach", "name": "Nutrition Coach", "version": 3}],
"totalCount": 1,
"hasMore": false
}

Disabled profiles are excluded unless you send includeDisabled: true. This returns full records — system prompts, tool config, the lot — so for anything that just needs to know what exists, use the library instead.

Read the Selection Library

The library is the project's selection surface: id, name, description, whenToUse, keywords and enabled flag, plus the project's defaultProfileId. No system prompts, no generation config, no tools.

curl -X POST https://api.travila.ai/api/v1/agent-profiles/library \
-H "X-API-Key: sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{}'

Response:

{
"library": {
"profiles": [
{
"profileId": "nutrition_coach",
"name": "Nutrition Coach",
"description": "Food, meals and macros.",
"whenToUse": "Use when the user asks about food, meals, or macros.",
"keywords": ["nutrition", "food", "macros"],
"enabled": true
}
],
"defaultProfileId": "generalist"
}
}

This is exactly what the router model sees when choosing a profile for a turn, and the cheap way to enumerate what exists — use it to populate a picker rather than paging list.

defaultProfileId is the generalist fallback used when no profile is active and the router picks none. An empty value means fall back to the conversation's own generation config.

Update a Profile

Updates are partial: only the fields named in updateMask are written, and each successful update appends a new immutable version.

curl -X POST https://api.travila.ai/api/v1/agent-profiles/update \
-H "X-API-Key: sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"profileId": "nutrition_coach",
"profile": {
"generationConfig": {
"model": "anthropic/claude-sonnet-4",
"temperature": 0.2
}
},
"updateMask": "generationConfig"
}'

Response: {"profile": {...}} with the new version.

Omitting updateMask replaces the whole record

Without a mask the supplied profile becomes the stored record wholesale — every field you did not send is cleared. Send the mask unless a full replace is what you mean.

Disabling is an ordinary update, and is the reversible alternative to deleting:

{
"profileId": "nutrition_coach",
"profile": {"enabled": false},
"updateMask": "enabled"
}

Version History

curl -X POST https://api.travila.ai/api/v1/agent-profiles/versions \
-H "X-API-Key: sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{"profileId": "nutrition_coach"}'

Response: newest first, one entry per successful mutation.

{
"versions": [
{"version": 3, "createdAt": "2026-08-14T10:22:41Z"},
{"version": 2, "createdAt": "2026-07-02T16:04:09Z"},
{"version": 1, "createdAt": "2026-06-19T09:11:55Z"}
]
}

Pair a version number with get to read exactly what a past turn ran against — the reason to record the version alongside a trace. A profile that never existed returns an empty list rather than an error.

There is no rollback endpoint. To revert, get the old version and update with its contents; that appends a new version rather than rewriting history.

Delete a Profile

curl -X POST https://api.travila.ai/api/v1/agent-profiles/delete \
-H "X-API-Key: sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{"profileId": "nutrition_coach"}'

Response: empty body on success.

Deleting is not scoped to unused profiles, and there is no reference check:

  • If the profile was the project's defaultProfileId, that pointer is cleared.
  • Conversations still naming it in activeProfileId fall back to their own generation config on subsequent turns — they do not error.
  • Version history goes with it.

Prefer enabled: false when you only want it out of the router's reach.

Import an Existing Prompt Library

If your prompts already live in a file-based library — personas plus shared blocks they @include — convert the whole thing in one call rather than hand-porting each one.

curl -X POST https://api.travila.ai/api/v1/agent-profiles/import \
-H "X-API-Key: sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"templates": [
{
"profileId": "nutrition_coach",
"name": "Nutrition Coach",
"whenToUse": "Use when the user asks about food, meals, or macros.",
"keywords": ["nutrition"],
"sourceDsl": "@include _shared/safety.txt\n\nYou are a nutrition coach for {{user_name}}.",
"generationConfig": {"model": "google/gemini-3.6-flash"}
}
],
"fragments": [
{
"fragmentId": "safety",
"name": "Safety rules",
"path": "_shared/safety.txt",
"sourceDsl": "Never give medical advice."
}
],
"emitFragments": true
}'

Response:

{
"profiles": [{"profileId": "nutrition_coach", "version": 1}],
"fragments": [{"fragmentId": "safety", "name": "Safety rules"}]
}

Conversion happens at import time — the platform never interprets your source DSL when serving a turn. The importer rewrites prompts to the platform template syntax and derives variableSpecs from how each variable is used; variableTypes overrides a type it would otherwise infer.

Flatten or fragments

emitFragmentsWhat happensChoose it when
false (default)Each @include is resolved inline into that profile's system prompt. No fragment rows are written.You want profiles self-contained and are happy editing shared text in several places.
trueShared blocks become fragment rows, referenced with {{template "id" .}}.You want one edit to reach every profile. Every block referenced by an @include must be supplied in the same call.

Rows that already exist cause ALREADY_EXISTS unless you send overwrite: true.