AI agents
The agent that talks on the workspace's behalf: its draft and live config, versions, tools and skills, the voice channel, and the change requests a customer's ask turns into.
Download the full API pública projection: openapi.json.
Configure the agent that talks explains this resource in prose, with runnable examples.
| Method | Path | What it does |
|---|---|---|
GET | /ai-agent-graphs | List the graphs in the workspace |
POST | /ai-agent-graphs | Create a graph |
DELETE | /ai-agent-graphs/{id} | Delete a graph |
GET | /ai-agent-graphs/{id} | Fetch one graph with its nodes |
PATCH | /ai-agent-graphs/{id} | Update a graph |
DELETE | /ai-agent-graphs/{id}/draft | Discard the draft |
GET | /ai-agent-graphs/{id}/draft | Fetch the pending draft |
PUT | /ai-agent-graphs/{id}/draft | Save the draft |
POST | /ai-agent-graphs/{id}/nodes | Add a node to a graph |
DELETE | /ai-agent-graphs/{id}/nodes/{nodeId} | Delete one node |
PATCH | /ai-agent-graphs/{id}/nodes/{nodeId} | Update one node |
POST | /ai-agent-graphs/{id}/publish | Publish the graph |
GET | /ai-agent-graphs/{id}/versions | List the published versions |
POST | /ai-agent-graphs/{id}/versions/{version}/restore | Restore a past version |
GET | /ai-agents | List AI agents |
POST | /ai-agents | Create AI agent |
DELETE | /ai-agents/{id} | Delete AI agent |
GET | /ai-agents/{id} | Get AI agent |
PUT | /ai-agents/{id} | Update AI agent |
GET | /ai-agents/{id}/change-request-stats | One agent's change-request ledger |
GET | /ai-agents/{id}/change-requests | List an agent's change requests |
POST | /ai-agents/{id}/change-requests | File a change request (text or audio) |
GET | /ai-agents/{id}/change-requests/{crId} | Get one change request |
POST | /ai-agents/{id}/change-requests/{crId}/ground | «Buscar dónde pasó» — find the conversations a complaint is about |
POST | /ai-agents/{id}/change-requests/{crId}/ground/confirm | Confirm (or reject) what the grounding run proposed |
POST | /ai-agents/{id}/change-requests/{crId}/propose | «Proponer arreglo» — the investigator writes the fix from the red run |
POST | /ai-agents/{id}/change-requests/{crId}/scenario | «Crear escenario» — author the eval from the request and run it |
POST | /ai-agents/{id}/change-requests/{crId}/scenario/evaluate | Re-read the evidence run and re-apply the red-gate rule |
POST | /ai-agents/{id}/change-requests/{crId}/scenario/refine | «Refinar» — rewrite the scenario that did not reproduce and re-run it |
POST | /ai-agents/{id}/change-requests/{crId}/transition | Move a change request along its lifecycle |
POST | /ai-agents/{id}/clone | Clone an AI agent (copies tool links, drops is_default) |
DELETE | /ai-agents/{id}/draft | Discard the pending draft |
GET | /ai-agents/{id}/draft | Read the pending draft (system_prompt + knowledge_tags) |
PUT | /ai-agents/{id}/draft | Edit the pending draft (does not affect live runtime) |
GET | /ai-agents/{id}/export | Export the agent’s full configuration |
GET | /ai-agents/{id}/knowledge | List the files attached to an agent |
POST | /ai-agents/{id}/knowledge | Upload a new file, or attach an existing one |
DELETE | /ai-agents/{id}/knowledge/{fileId} | Detach a file from an agent |
GET | /ai-agents/{id}/metrics | Rolling performance metrics for one agent |
POST | /ai-agents/{id}/publish | Atomically promote the draft onto the live runtime + snapshot a version |
GET | /ai-agents/{id}/publish-gate | Would publishing this agent be refused by its evals? |
GET | /ai-agents/{id}/skills | List the skills attached to one agent |
POST | /ai-agents/{id}/skills | Attach an existing skill to an agent |
DELETE | /ai-agents/{id}/skills/{skillId} | Detach a skill from an agent (does not delete the skill) |
GET | /ai-agents/{id}/system-prompt | Get system prompt |
PUT | /ai-agents/{id}/system-prompt | Update system prompt |
GET | /ai-agents/{id}/tools | List tools registered to this AI agent |
PUT | /ai-agents/{id}/tools | Set the agent’s tools |
GET | /ai-agents/{id}/versions | List version history for an AI agent |
GET | /ai-agents/{id}/versions/{version} | Get a specific stored version |
POST | /ai-agents/{id}/versions/{version}/restore | Restore an AI agent to a stored version |
POST | /ai-agents/{id}/versions/{version}/rollback | Make a past version live again |
GET | /ai-agents/{id}/voice-channel | The agent's voice channel config: line status, greeting (locked disclosure + brand), curated voices, handoff, propagation state |
PUT | /ai-agents/{id}/voice-channel | Update greeting brand segment / curated voice / handoff number (persists Vitrina-side, then best-effort speech-layer propagation) |
POST | /ai-agents/{id}/voice-channel/propagate | Retry pushing the persisted voice config onto the speech layer (when propagation is pending) |
GET | /ai-agents/{id}/voice-lines | The agent's voice lines (ADR 0053): the PSTN number callers dial and the WhatsApp in-chat call button, each with its own state (none/activating/active/in_call/desync) |
POST | /ai-agents/{id}/voice-lines/{kind}/activate | Start the activation saga for one line. Async and idempotent; a second call while one is running returns refused_reason=already_running rather than starting a second saga |
POST | /ai-agents/{id}/voice-lines/{kind}/deactivate | Take one line off the air. REFUSES with 200 + refused_reason=call_in_progress while a customer is on the line — the call is never dropped |
GET | /ai-agents/tools-catalog | Tools this workspace may wire to an agent |
GET | /change-requests/stats | The workspace's change-request ledger |
GET | /skills | List the tenant skill library (with per-agent attachment counts) |
POST | /skills | Create a skill (optional channel_overrides, v1 key: voice) |
DELETE | /skills/{id} | Delete a skill (SOFT; detaches it from every agent) |
GET | /skills/{id} | Read one skill (base content + per-channel adjustments map) |
PUT | /skills/{id} | Update a skill; channel_overrides replaces the whole map when present, omit to leave adjustments untouched |
GET /ai-agent-graphs
List the graphs in the workspace
Graph rows only — no nodes. Fetch one graph to get its nodes. meta.total is the length of the list, not a page count: this endpoint is unpaginated.
curl https://api.vitrinadev.com/api/v1/ai-agent-graphs \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "b4b4b4b4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Ventas + postventa",
"description": "Deriva entre el agente de ventas y el de postventa según la intención del cliente.",
"topology": "supervisor",
"status": "active",
"is_default": false,
"config": {
"default_node_key": "triage"
},
"published_version_id": "b4b4b4b4-2000-4000-8000-000000000000",
"draft": null,
"created_at": "2026-09-05T12:00:00.000Z",
"updated_at": "2026-09-18T09:00:00.000Z"
}
],
"meta": {
"total": 1
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agent-graphs
Create a graph
Creates the graph and, if nodes is supplied, its nodes in one call. Each node references exactly one of ai_agent_id / subgraph_id — supplying both, or neither, is a 400. The response is \{ graph, nodes \} — not a bare graph — because the nodes come back with the ids the caller needs to address them. is_default: true makes this the graph conversations run against and clears the flag on whichever graph held it. Created graphs are unpublished: they have no version and nothing runs them until POST /ai-agent-graphs/{id}/publish. The parallel and evaluator_loop topologies are gated to internal tenants: publishing one without settings.topology_flags.<topology> answers 409. Creating and drafting them is not gated — only publish is, so a graph can be built long before it is allowed to go live.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | mín. 1, máx. 200 |
description | string | null | — | máx. 2000 |
topology | single \ | sequential \ | routing \ |
config | object | — | |
nodes | object[] | — | |
is_default | boolean | — |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agent-graphs \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Ventas + postventa",
"description": "Deriva entre el agente de ventas y el de postventa según la intención del cliente.",
"topology": "supervisor",
"nodes": [
{
"kind": "supervisor",
"node_key": "triage",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001"
},
{
"kind": "leaf",
"node_key": "postventa",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000002"
}
]
}'Example response (201)
{
"data": {
"graph": {
"id": "b4b4b4b4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Ventas + postventa",
"description": "Deriva entre el agente de ventas y el de postventa según la intención del cliente.",
"topology": "supervisor",
"status": "active",
"is_default": false,
"config": {
"default_node_key": "triage"
},
"published_version_id": "b4b4b4b4-2000-4000-8000-000000000000",
"draft": null,
"created_at": "2026-09-05T12:00:00.000Z",
"updated_at": "2026-09-18T09:00:00.000Z"
},
"nodes": [
{
"id": "b4b4b4b4-1000-4000-8000-000000000001",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"ai_agent_id": null,
"subgraph_id": null,
"kind": "supervisor",
"node_key": "triage",
"position": 0,
"config": {
"branch_label": "Triage"
}
},
{
"id": "b4b4b4b4-1000-4000-8000-000000000002",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"subgraph_id": null,
"kind": "leaf",
"node_key": "ventas",
"position": 1,
"config": {}
}
]
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /ai-agent-graphs/{id}
Delete a graph
Deletes the graph and its nodes. The ai_agent rows the nodes pointed at are untouched — a graph owns its wiring, never its agents.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agent-graphs/{id}
Fetch one graph with its nodes
The graph row with a nodes array spliced in. These are the LIVE nodes; if the graph carries a draft, what will run after the next publish is in draft, which this returns untouched.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "b4b4b4b4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Ventas + postventa",
"description": "Deriva entre el agente de ventas y el de postventa según la intención del cliente.",
"topology": "supervisor",
"status": "active",
"is_default": false,
"config": {
"default_node_key": "triage"
},
"published_version_id": "b4b4b4b4-2000-4000-8000-000000000000",
"draft": null,
"created_at": "2026-09-05T12:00:00.000Z",
"updated_at": "2026-09-18T09:00:00.000Z",
"nodes": [
{
"id": "b4b4b4b4-1000-4000-8000-000000000001",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"ai_agent_id": null,
"subgraph_id": null,
"kind": "supervisor",
"node_key": "triage",
"position": 0,
"config": {
"branch_label": "Triage"
}
},
{
"id": "b4b4b4b4-1000-4000-8000-000000000002",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"subgraph_id": null,
"kind": "leaf",
"node_key": "ventas",
"position": 1,
"config": {}
}
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PATCH /ai-agent-graphs/{id}
Update a graph
Metadata and shape only — nodes have their own endpoints. At least one field is required. Changing topology re-validates the graph against its existing nodes and fails 400 if they no longer satisfy it, so reshaping usually means editing the nodes in the same session. is_default: true clears the flag on the previous default.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | — | mín. 1, máx. 200 |
description | string | null | — | máx. 2000 |
topology | single \ | sequential \ | routing \ |
config | object | — | |
status | active \ | testing \ | inactive |
is_default | boolean | — |
curl -X PATCH https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Ventas + postventa (CL)"
}'Example response (200)
{
"data": {
"id": "b4b4b4b4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Ventas + postventa (CL)",
"description": "Deriva entre el agente de ventas y el de postventa según la intención del cliente.",
"topology": "supervisor",
"status": "active",
"is_default": false,
"config": {
"default_node_key": "triage"
},
"published_version_id": "b4b4b4b4-2000-4000-8000-000000000000",
"draft": null,
"created_at": "2026-09-05T12:00:00.000Z",
"updated_at": "2026-09-18T09:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /ai-agent-graphs/{id}/draft
Discard the draft
Drops the pending edit; the live graph is untouched. Discarding when there is no draft is a no-op, not a 404.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id>/draft \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agent-graphs/{id}/draft
Fetch the pending draft
Answers 200 with data: null when there is no draft — absence of a draft is not a 404.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id>/draft \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"topology": "supervisor",
"config": {
"default_node_key": "triage"
},
"nodes": [
{
"id": "b4b4b4b4-1000-4000-8000-000000000001",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"ai_agent_id": null,
"subgraph_id": null,
"kind": "supervisor",
"node_key": "triage",
"position": 0,
"config": {
"branch_label": "Triage"
}
},
{
"id": "b4b4b4b4-1000-4000-8000-000000000002",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"subgraph_id": null,
"kind": "leaf",
"node_key": "ventas",
"position": 1,
"config": {}
}
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /ai-agent-graphs/{id}/draft
Save the draft
Replaces the whole draft — this is a PUT, so omitting a key drops it rather than leaving the previous value. The draft is intentionally NOT validated: a half-edited graph is a legal thing to save, and validation is deferred to publish. Returns the graph, with the draft on it.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
topology | single \ | sequential \ | routing \ |
config | object | — | |
nodes | object[] | — |
curl -X PUT https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id>/draft \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"topology": "supervisor",
"nodes": [
{
"kind": "supervisor",
"node_key": "triage",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001"
},
{
"kind": "leaf",
"node_key": "ventas",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001"
},
{
"kind": "leaf",
"node_key": "postventa",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000002"
}
]
}'Example response (200)
{
"data": {
"id": "b4b4b4b4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Ventas + postventa",
"description": "Deriva entre el agente de ventas y el de postventa según la intención del cliente.",
"topology": "supervisor",
"status": "active",
"is_default": false,
"config": {
"default_node_key": "triage"
},
"published_version_id": "b4b4b4b4-2000-4000-8000-000000000000",
"draft": {
"topology": "supervisor",
"nodes": [
{
"kind": "supervisor",
"node_key": "triage",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001"
},
{
"kind": "leaf",
"node_key": "ventas",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001"
},
{
"kind": "leaf",
"node_key": "postventa",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000002"
}
]
},
"created_at": "2026-09-05T12:00:00.000Z",
"updated_at": "2026-09-18T09:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agent-graphs/{id}/nodes
Add a node to a graph
Each node references exactly one of ai_agent_id / subgraph_id — supplying both, or neither, is a 400. The whole graph is re-validated with the new node included, so a node that breaks the topology is refused with 400 and nothing is written. Node writes hit the LIVE graph directly — they do not go through the draft.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
ai_agent_id | string | null | — | |
subgraph_id | string | null | — | |
kind | root \ | leaf \ | supervisor \ |
node_key | string | yes | mín. 1, máx. 80 |
position | integer | — | ≥ 0, ≤ 99 |
config | object | — |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id>/nodes \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "leaf",
"node_key": "finanzas",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000002"
}'Example response (201)
{
"data": {
"id": "b4b4b4b4-1000-4000-8000-000000000002",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000002",
"subgraph_id": null,
"kind": "leaf",
"node_key": "finanzas",
"position": 1,
"config": {}
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /ai-agent-graphs/{id}/nodes/{nodeId}
Delete one node
Unlike create and update, deletion is NOT re-validated: the graph is allowed to be left in a shape that could not be published. That is deliberate — removing a node is usually the first step of a rewire — but it means the next publish is where the breakage surfaces.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
nodeId | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id>/nodes/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
PATCH /ai-agent-graphs/{id}/nodes/{nodeId}
Update one node
At least one field is required. As with create, the graph is re-validated with the patch applied before anything is written.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
nodeId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
ai_agent_id | string | null | — | |
subgraph_id | string | null | — | |
kind | root \ | leaf \ | supervisor \ |
node_key | string | — | mín. 1, máx. 80 |
position | integer | — | ≥ 0, ≤ 99 |
config | object | — |
curl -X PATCH https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id>/nodes/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"position": 2
}'Example response (200)
{
"data": {
"id": "b4b4b4b4-1000-4000-8000-000000000002",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"subgraph_id": null,
"kind": "leaf",
"node_key": "ventas",
"position": 2,
"config": {}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agent-graphs/{id}/publish
Publish the graph
The only validating write on this resource, and the one with real consequences. In order: enforce the tenant editable_window (409 outside it); promote the draft onto the live shape, replacing the node set wholesale if the draft carries nodes; hard-validate (400 on a bad graph — and the draft has already been promoted by then); snapshot into a new ai_agent_graph_version; move published_version_id; dispatch the ai_agent_graph.publish webhook; and, unless skip_eval is set, start the graph eval suite. The parallel and evaluator_loop topologies are gated to internal tenants: publishing one without settings.topology_flags.<topology> answers 409. Creating and drafting them is not gated — only publish is, so a graph can be built long before it is allowed to go live. The eval run is fire-and-forget: it is NOT awaited and its failure is logged, never surfaced, so a 200 here says the graph published, not that its tests passed. Poll GET /ai-agent-graphs/{id}/tests/runs for that. Returns \{ graph, version \}.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
label | string | null | — | máx. 200 |
skip_eval | boolean | — |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id>/publish \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "Agrega derivación a postventa"
}'Example response (200)
{
"data": {
"graph": {
"id": "b4b4b4b4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Ventas + postventa",
"description": "Deriva entre el agente de ventas y el de postventa según la intención del cliente.",
"topology": "supervisor",
"status": "active",
"is_default": false,
"config": {
"default_node_key": "triage"
},
"published_version_id": "b4b4b4b4-2000-4000-8000-000000000001",
"draft": null,
"created_at": "2026-09-05T12:00:00.000Z",
"updated_at": "2026-09-18T09:00:00.000Z"
},
"version": {
"id": "b4b4b4b4-2000-4000-8000-000000000001",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"version_number": 3,
"label": "Agrega derivación a postventa",
"topology": "supervisor",
"created_at": "2026-09-22T09:05:00.000Z"
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agent-graphs/{id}/versions
List the published versions
The snapshot timeline, newest first, capped at the 50 most recent — the limit is fixed and not a query parameter. Each row carries the full node_snapshot that was live at publish time, so a version is enough to reconstruct the graph without any other read.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id>/versions \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "b4b4b4b4-2000-4000-8000-000000000001",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"version_number": 3,
"label": "Agrega derivación a postventa",
"topology": "supervisor",
"created_at": "2026-09-22T09:05:00.000Z"
},
{
"id": "b4b4b4b4-2000-4000-8000-000000000000",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"version_number": 2,
"label": null,
"topology": "supervisor",
"created_at": "2026-09-18T09:00:00.000Z"
}
],
"meta": {
"total": 2
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agent-graphs/{id}/versions/{version}/restore
Restore a past version
Writes the snapshot back as the live shape and then re-publishes, so a restore is itself a NEW version rather than a pointer move — the timeline only ever grows. Two consequences worth planning for: the new version's label is always Restored from v\{n\} (any label you send is ignored), and because the tail of this is a real publish it inherits every publish gate — the editable window, topology flags, and validation of the snapshot against TODAY's rules. A version published under a since-revoked topology flag can therefore refuse to restore. \{version\} is the version_number from the versions list, not the version row's uuid.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
version | path | integer | yes | ≥ 1 |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agent-graphs/<id>/versions/<id>/restore \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"graph": {
"id": "b4b4b4b4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Ventas + postventa",
"description": "Deriva entre el agente de ventas y el de postventa según la intención del cliente.",
"topology": "supervisor",
"status": "active",
"is_default": false,
"config": {
"default_node_key": "triage"
},
"published_version_id": "b4b4b4b4-2000-4000-8000-000000000002",
"draft": null,
"created_at": "2026-09-05T12:00:00.000Z",
"updated_at": "2026-09-18T09:00:00.000Z"
},
"version": {
"id": "b4b4b4b4-2000-4000-8000-000000000002",
"graph_id": "b4b4b4b4-0000-4000-8000-000000000001",
"version_number": 4,
"label": "Restored from v2",
"topology": "supervisor",
"created_at": "2026-09-22T09:10:00.000Z"
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents
List AI agents
Every agent in the workspace with the stages wired to it, plus the summary the list cards render: live_version (the highest published ai_agent_version, null when never published), has_draft (any staged change waiting to be published), and counts { tools, kb_files, skills } — enabled tool wirings and NON-DELETED attachments. live_version: null with has_draft: true is an agent that has been configured but never went live.
curl https://api.vitrinadev.com/api/v1/ai-agents \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "a3a3a3a3-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Asistente de ventas",
"description": "Responde preguntas de stock y agenda visitas a sucursal.",
"status": "active",
"is_default": true,
"model": "xiaomi/mimo-v2.6-flash",
"temperature": null,
"default_max_steps": 5,
"reasoning_effort": "high",
"autonomy_level": "supervised",
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana.",
"knowledge_tags": [
"horarios",
"garantia"
],
"tags": [],
"followups_enabled": true,
"allowed_url_prefixes": [],
"published_version_id": "a3a3a3a3-1000-4000-8000-000000000007",
"draft_system_prompt": null,
"draft_model": null,
"draft_temperature": null,
"draft_max_steps": null,
"draft_tool_wiring": null,
"draft_skill_ids": null,
"draft_kb_file_ids": null,
"draft_knowledge_tags": null,
"draft_updated_at": null,
"created_at": "2026-08-04T19:07:05.226Z",
"updated_at": "2026-09-17T14:49:02.893Z",
"live_version": 7,
"has_draft": false,
"counts": {
"tools": 15,
"kb_files": 2,
"skills": 1
}
},
{
"id": "a3a3a3a3-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Asistente de postventa",
"description": "Responde preguntas de stock y agenda visitas a sucursal.",
"status": "active",
"is_default": false,
"model": "xiaomi/mimo-v2.6-flash",
"temperature": null,
"default_max_steps": 5,
"reasoning_effort": "high",
"autonomy_level": "supervised",
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana.",
"knowledge_tags": [
"horarios",
"garantia"
],
"tags": [],
"followups_enabled": true,
"allowed_url_prefixes": [],
"published_version_id": "a3a3a3a3-1000-4000-8000-000000000007",
"draft_system_prompt": null,
"draft_model": null,
"draft_temperature": null,
"draft_max_steps": null,
"draft_tool_wiring": null,
"draft_skill_ids": null,
"draft_kb_file_ids": null,
"draft_knowledge_tags": null,
"draft_updated_at": null,
"created_at": "2026-08-04T19:07:05.226Z",
"updated_at": "2026-09-17T14:49:02.893Z",
"live_version": null,
"has_draft": true,
"counts": {
"tools": 3,
"kb_files": 0,
"skills": 0
}
}
]
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents
Create AI agent
Only name is required. model is an escape hatch and almost always omitted — Vitrina picks and manages the runtime model (ai-agent.service DEFAULT_AGENT_MODEL, currently a flash-tier model; the product never runs a "pro"/reasoning-max tier here) and the UI never asks for one. The agent is born with an empty system_prompt and no published_version_id — it does nothing until a draft is saved and published. Answers 201.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | mín. 1, máx. 120 |
description | string | — | máx. 4000, por defecto "" |
model | string | — | mín. 1, máx. 120 |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Asistente de postventa",
"description": "Coordina servicio técnico y repuestos."
}'Example response (201)
{
"data": {
"id": "a3a3a3a3-0000-4000-8000-000000000002",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Asistente de postventa",
"description": "Coordina servicio técnico y repuestos.",
"status": "active",
"is_default": false,
"model": "xiaomi/mimo-v2.6-flash",
"temperature": null,
"default_max_steps": 5,
"reasoning_effort": "high",
"autonomy_level": "supervised",
"system_prompt": "",
"knowledge_tags": [],
"tags": [],
"followups_enabled": true,
"allowed_url_prefixes": [],
"published_version_id": null,
"draft_system_prompt": null,
"draft_model": null,
"draft_temperature": null,
"draft_max_steps": null,
"draft_tool_wiring": null,
"draft_skill_ids": null,
"draft_kb_file_ids": null,
"draft_knowledge_tags": null,
"draft_updated_at": null,
"created_at": "2026-08-04T19:07:05.226Z",
"updated_at": "2026-09-17T14:49:02.893Z"
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /ai-agents/{id}
Delete AI agent
Removes the agent. Its published versions, skills and KB attachments do not follow it — skills and KB files are tenant-shared resources that survive independently. There is no undelete; recreate and reconfigure from scratch. 204.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X DELETE https://api.vitrinadev.com/api/v1/ai-agents/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}
Get AI agent
The full row: live config plus every staged draft_* field.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "a3a3a3a3-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Asistente de ventas",
"description": "Responde preguntas de stock y agenda visitas a sucursal.",
"status": "active",
"is_default": true,
"model": "xiaomi/mimo-v2.6-flash",
"temperature": null,
"default_max_steps": 5,
"reasoning_effort": "high",
"autonomy_level": "supervised",
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana.",
"knowledge_tags": [
"horarios",
"garantia"
],
"tags": [],
"followups_enabled": true,
"allowed_url_prefixes": [],
"published_version_id": "a3a3a3a3-1000-4000-8000-000000000007",
"draft_system_prompt": null,
"draft_model": null,
"draft_temperature": null,
"draft_max_steps": null,
"draft_tool_wiring": null,
"draft_skill_ids": null,
"draft_kb_file_ids": null,
"draft_knowledge_tags": null,
"draft_updated_at": null,
"created_at": "2026-08-04T19:07:05.226Z",
"updated_at": "2026-09-17T14:49:02.893Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /ai-agents/{id}
Update AI agent
Every field optional. Unlike the system prompt and the tool/skill/KB wiring, name/description/model/followups_enabled/allowed_url_prefixes are LIVE IMMEDIATELY — they do not stage on the draft and do not need a publish. A model sent here must stay inside the platform’s flash-tier catalogue; it is not a free-form passthrough to whatever OpenRouter exposes.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | — | mín. 1, máx. 120 |
description | string | — | máx. 4000 |
model | string | — | mín. 1, máx. 120 |
followups_enabled | boolean | — | |
allowed_url_prefixes | string[] | — |
curl -X PUT https://api.vitrinadev.com/api/v1/ai-agents/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Asistente de postventa (CL)"
}'Example response (200)
{
"data": {
"id": "a3a3a3a3-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Asistente de postventa (CL)",
"description": "Responde preguntas de stock y agenda visitas a sucursal.",
"status": "active",
"is_default": true,
"model": "xiaomi/mimo-v2.6-flash",
"temperature": null,
"default_max_steps": 5,
"reasoning_effort": "high",
"autonomy_level": "supervised",
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana.",
"knowledge_tags": [
"horarios",
"garantia"
],
"tags": [],
"followups_enabled": true,
"allowed_url_prefixes": [],
"published_version_id": "a3a3a3a3-1000-4000-8000-000000000007",
"draft_system_prompt": null,
"draft_model": null,
"draft_temperature": null,
"draft_max_steps": null,
"draft_tool_wiring": null,
"draft_skill_ids": null,
"draft_kb_file_ids": null,
"draft_knowledge_tags": null,
"draft_updated_at": null,
"created_at": "2026-08-04T19:07:05.226Z",
"updated_at": "2026-09-17T14:49:02.893Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/change-request-stats
One agent's change-request ledger
ADR 0103 §4.5's two questions, over one agent. Neither existed before this endpoint, and the ADR says why each is worth counting:
harness.ratio— requests that terminated atharness(the turn loop, tool execution, retries, handoff mechanics and channel behaviour Vitrina owns, §1.6) over all requests filed in the window. It is the measure of where the product is weak: a workspace whose complaints keep landing there is reporting a defect in the harness, not asking for prompt edits.appliedBySurface/appliedBySurfaceOverTime— the fixes that were APPLIED in the window, by surface (prompt,skill,knowledge,tool,model,handoff,routing,integration,template,harness,feature), as a total and bucketed by ISO week. A month of fixes that are allpromptis a knowledge base nobody is filling.
TWO CLOCKS, deliberately: a request is counted by when it was FILED, an applied fix by when it was APPLIED — a fix landed this week on a complaint from last quarter belongs to this week. byStatus always carries all eight states, zero-filled, so «harness: 0» is visible rather than absent. days defaults to 90 (max 365): the quantity is a trend, and a workspace files a handful of requests a month.
Requires ai_agents:read AND corrections:read.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
days | query | integer | no | ≥ 1, ≤ 365 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-request-stats \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"days": 90,
"total": 14,
"byStatus": {
"received": 2,
"grounded": 1,
"reproduced": 0,
"proposed": 1,
"applied": 3,
"verified": 5,
"ya_cumple": 1,
"harness": 1
},
"harness": {
"terminated": 1,
"ratio": 0.0714
},
"appliedBySurface": [
{
"surface": "prompt",
"count": 5
},
{
"surface": "skill",
"count": 2
},
{
"surface": "knowledge",
"count": 1
}
],
"appliedBySurfaceOverTime": [
{
"bucket": "2026-W37",
"surface": "prompt",
"count": 2
},
{
"bucket": "2026-W37",
"surface": "skill",
"count": 1
},
{
"bucket": "2026-W38",
"surface": "prompt",
"count": 3
},
{
"bucket": "2026-W38",
"surface": "skill",
"count": 1
},
{
"bucket": "2026-W38",
"surface": "knowledge",
"count": 1
}
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/change-requests
List an agent's change requests
Newest first — the «Solicitudes» list on the agent page, which is what answers "¿arreglaron lo de los guiones?" without asking Vitrina. Each row carries its display_id (SR-n, per workspace — a label; every path here addresses a request by uuid), the verbatim the client said, the reporter (reporter_kind plus reporter_name when the filer is still a member with a display name), the linked scenario and evidence run once they exist, and the status.
Lifecycle: received → grounded → reproduced → proposed → applied → verified, plus two terminals that are outcomes rather than failures — ya_cumple (the scenario went green on its first run and the complaint was about an older version, so the request closes with that green run attached) and harness (the fix belongs to Vitrina's harness — turn loop, retries, handoff mechanics, channel behaviour — so the tenant reads "esto lo arregla Vitrina" with a reference instead of being sent to edit a skill that cannot fix it). harness is reachable from every non-terminal state; ya_cumple from grounded and reproduced. Everything else is a 400 naming both ends of the refused edge.
Requires ai_agents:read AND corrections:write.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
status | query | received \ | grounded \ | reproduced \ |
limit | query | integer | no | ≥ 1, ≤ 200, por defecto 100 |
conversation_id | query | uuid | no |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-requests \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "b5b5b5b5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"display_id": "SR-14",
"status": "grounded",
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_member_id": "11111111-0000-4000-8000-000000000001",
"reporter_kind": "client_via_member",
"reporter_name": "Camila (vendedora)",
"conversation_refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001"
}
],
"scenario_id": null,
"evidence_run_id": null,
"verification_run_id": null,
"proposal_ids": [],
"grounding_attempts": [
{
"outcome": "confirmed",
"path": "reviews",
"candidates": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001",
"quote": "listo, sin problema - te confirmo por aquí",
"why": "Coincide con el reclamo."
}
]
}
],
"no_repro_note": null,
"received_against_version": 6,
"override": null,
"harness_reference": null,
"tenant_message": null,
"created_at": "2026-09-20T14:00:00.000Z",
"updated_at": "2026-09-20T14:05:00.000Z",
"scenario": null,
"evidence_run": null,
"verification_run": null,
"override_by_name": null
}
],
"meta": {
"total": 1
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/change-requests
File a change request (text or audio)
The «Nueva solicitud» form and the inbox turn action. Two content types, one behaviour:
application/jsonwith\{ verbatim \}— what the client said, typed.multipart/form-datawith the recording in a part namedfile— webm / ogg / mp4 / m4a / aac / mp3 / wav, up to 20 MB (thevideo/*spelling of those containers is accepted too: a browser types a.webmpicked from diskvideo/webmand its own MediaRecorder blobaudio/webm, and only the audio track is read either way). The audio is normalised to OGG/Opus and transcribed, and THE TRANSCRIPT IS THEverbatim: nothing is stored that could not be turned into text. Send one or the other, never both.
reporter_kind is member (the member's own observation) or client_via_member (the member relaying what a customer said — there is no feedback inbox and no channel, so a client's words always arrive through a member). conversation_refs is [\{ conversationId, messageId? \}]; in the multipart form it is a JSON-encoded string, because a form cannot carry an array.
The new request is born at received and stamps received_against_version — the ai_agent_version.version_number that was live at that moment, i.e. the version the client complained about, keyed exactly like agent_scenario_run.agent_version_number so the red gate can compare the two. Null when the agent has never been published.
Requires ai_agents:read AND corrections:write — filing what a customer told you is a front-line act, and it does not grant anybody the agent's configuration.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
verbatim | string | — | mín. 1, máx. 5000 |
reporter_kind | member \ | client_via_member | — |
conversation_refs | object[] | — |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-requests \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_kind": "client_via_member",
"conversation_refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001"
}
]
}'Example response (201)
{
"data": {
"id": "b5b5b5b5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"display_id": "SR-14",
"status": "received",
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_member_id": "11111111-0000-4000-8000-000000000001",
"reporter_kind": "client_via_member",
"reporter_name": "Camila (vendedora)",
"conversation_refs": [],
"scenario_id": null,
"evidence_run_id": null,
"verification_run_id": null,
"proposal_ids": [],
"grounding_attempts": [],
"no_repro_note": null,
"received_against_version": 6,
"override": null,
"harness_reference": null,
"tenant_message": null,
"created_at": "2026-09-20T14:00:00.000Z",
"updated_at": "2026-09-20T14:00:00.000Z",
"scenario": null,
"evidence_run": null,
"verification_run": null,
"override_by_name": null
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/change-requests/{crId}
Get one change request
The whole row: verbatim, reporter, confirmed conversation refs, the linked scenario and the failed run that is its evidence, the proposals it produced, the grounding attempts, any override recorded when somebody published past a red gate, and — for a harness terminal — the tenant-facing message and its reference.
Requires ai_agents:read AND corrections:write.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
crId | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-requests/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "b5b5b5b5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"display_id": "SR-14",
"status": "grounded",
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_member_id": "11111111-0000-4000-8000-000000000001",
"reporter_kind": "client_via_member",
"reporter_name": "Camila (vendedora)",
"conversation_refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001"
}
],
"scenario_id": null,
"evidence_run_id": null,
"verification_run_id": null,
"proposal_ids": [],
"grounding_attempts": [
{
"outcome": "confirmed",
"path": "reviews",
"candidates": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001",
"quote": "listo, sin problema - te confirmo por aquí",
"why": "Coincide con el reclamo."
}
]
}
],
"no_repro_note": null,
"received_against_version": 6,
"override": null,
"harness_reference": null,
"tenant_message": null,
"created_at": "2026-09-20T14:00:00.000Z",
"updated_at": "2026-09-20T14:05:00.000Z",
"scenario": null,
"evidence_run": null,
"verification_run": null,
"override_by_name": null
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/change-requests/{crId}/ground
«Buscar dónde pasó» — find the conversations a complaint is about
Runs the change-request-grounder read-only tool loop over this tenant's own conversation_review summaries and typed events for this agent, ranked against the verbatim, opens the likely threads and answers with up to five candidates — each a conversation, the agent turn that matches (by message id) and the quote, plus one sentence saying why (ADR 0103 §4.3).
WHEN THE TENANT HAS NO REVIEWS in the window, the loop falls back to reading that agent's most recent conversations directly. Which corpus answered is recorded on the attempt as path (reviews | recent_conversations | none).
FULL-TEXT SEARCH OVER MESSAGE BODIES WAS REJECTED (§4.3): it finds words, and a complaint describes behaviour. The verbatim is treated as DATA throughout — it is the query, never an instruction — and every read is bounded before the first tool call: 30 days by default, at most 12 conversations opened, at most 60 messages of each. The run is metered as a platform agent like every other.
This call CHANGES NOTHING but the ledger: it appends one entry to grounding_attempts at outcome: "pending". No ref is written and the status does not move until a human answers at /ground/confirm.
A RUN THE MODEL GATEWAY REFUSED (exhausted credits, a rate limit, a dead provider) is NOT an empty answer: the attempt is recorded with outcome: "error" carrying the provider's own sentence, and the call answers 502. A 200 with no candidates would say "we looked and it is not there", which is a different and untrue statement.
REFUSED WITH 400 when the request already names its conversations (a pasted ref, or the inbox action, skips grounding — there is nothing to search for) or when it has left received. Requires ai_agents:read AND corrections:write — the same pair as filing, because this asks "which conversation did you mean", not "change the agent".
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
crId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
window_days | integer | — | ≥ 1, ≤ 365 |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-requests/<id>/ground \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{}'Example response (200)
{
"data": {
"request": {
"id": "b5b5b5b5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"display_id": "SR-14",
"status": "received",
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_member_id": "11111111-0000-4000-8000-000000000001",
"reporter_kind": "client_via_member",
"reporter_name": "Camila (vendedora)",
"conversation_refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001"
}
],
"scenario_id": null,
"evidence_run_id": null,
"verification_run_id": null,
"proposal_ids": [],
"grounding_attempts": [
{
"outcome": "confirmed",
"path": "reviews",
"candidates": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001",
"quote": "listo, sin problema - te confirmo por aquí",
"why": "Coincide con el reclamo."
}
]
}
],
"no_repro_note": null,
"received_against_version": 6,
"override": null,
"harness_reference": null,
"tenant_message": null,
"created_at": "2026-09-20T14:00:00.000Z",
"updated_at": "2026-09-20T14:05:00.000Z",
"scenario": null,
"evidence_run": null,
"verification_run": null,
"override_by_name": null
},
"attempt": {
"outcome": "pending",
"path": "reviews",
"candidates": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001",
"quote": "listo, sin problema - te confirmo por aquí",
"why": "El agente usó un guión largo en una respuesta de confirmación."
}
]
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/change-requests/{crId}/ground/confirm
Confirm (or reject) what the grounding run proposed
The human's answer to the last grounding run, and the only thing that turns it into a fact.
\{ refs: [\{ conversationId, messageId \}] \}— the candidates they recognised. They are written toconversation_refs, the attempt is markedconfirmed, and the request walksreceived → groundedthrough the ordinary machine. Every ref must be one of the LAST run's candidates, message id included: confirming is answering a question that was asked, not a second way to attach an arbitrary conversation (that is the create path, where a pasted ref skips grounding).\{ refs: [] \}— «Ninguna coincide». The attempt is markedrejectedand the request STAYS atreceivedwith the run on record. Nothing is written toconversation_refs: a rejection is not the absence of refs, it is the statement that the loop did not find them.
meta.outcome echoes which of the two happened. Requires ai_agents:read AND corrections:write.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
crId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
refs | object[] | yes |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-requests/<id>/ground/confirm \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001"
}
]
}'Example response (200)
{
"data": {
"id": "b5b5b5b5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"display_id": "SR-14",
"status": "grounded",
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_member_id": "11111111-0000-4000-8000-000000000001",
"reporter_kind": "client_via_member",
"reporter_name": "Camila (vendedora)",
"conversation_refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001"
}
],
"scenario_id": null,
"evidence_run_id": null,
"verification_run_id": null,
"proposal_ids": [],
"grounding_attempts": [
{
"outcome": "confirmed",
"path": "reviews",
"candidates": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001",
"quote": "listo, sin problema - te confirmo por aquí",
"why": "Coincide con el reclamo."
}
]
}
],
"no_repro_note": null,
"received_against_version": 6,
"override": null,
"harness_reference": null,
"tenant_message": null,
"created_at": "2026-09-20T14:00:00.000Z",
"updated_at": "2026-09-20T14:05:00.000Z",
"scenario": null,
"evidence_run": null,
"verification_run": null,
"override_by_name": null
},
"meta": {
"outcome": "confirmed"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/change-requests/{crId}/propose
«Proponer arreglo» — the investigator writes the fix from the red run
Runs the finding-investigator's READ-ONLY tool loop over this request's evidence and stores what it proposes, then walks the request reproduced → proposed (ADR 0103 §4.5).
The evidence set is the REQUEST, not a finding: the verbatim (fenced and declared untrusted — it is a customer’s words reaching a tool-using model), the conversations a human confirmed, the RED RUN’s transcript and judge verdict — the controlled reproduction §4.2 spent a model call and a run to produce — and the agent’s draft-aware configuration with what is available but not attached.
The proposals go through the Coach’s own validateProposals / persistProposalsDeduped: an unknown action is dropped, applyable is forced from the canonical set (the model never decides it), and a repeat of an already-open proposal for this agent bumps it instead of inserting a twin. They land with change_request_id set and their ids appended to the request’s proposal_ids.
Every proposal carries a surface derived from its action — prompt, skill, knowledge, tool, model, handoff, routing, integration, template, harness, feature — which is the vocabulary §4.5 makes the ledger countable in: how often the answer is "this one is Vitrina’s", and whether the prompt is doing work the knowledge base should be doing.
REFUSED WITH 400 unless the request is reproduced AND its scenario run went red against the version complained about — the same assertRedRunEvidence the transition surface asks, called BEFORE the model runs so a request without evidence costs nothing. A run the model gateway REFUSED (exhausted credits, a rate limit, a dead provider) is a 502 carrying the provider’s own sentence and moves nothing — "we looked and there is no fix" is a different and untrue statement. Synchronous: somebody pressed a button on their own request. Requires ai_agents:read AND corrections:write AND ai_agents:write.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
crId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-requests/<id>/propose \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{}'Example response (201)
{
"data": {
"request": {
"id": "b5b5b5b5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"display_id": "SR-14",
"status": "proposed",
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_member_id": "11111111-0000-4000-8000-000000000001",
"reporter_kind": "client_via_member",
"reporter_name": "Camila (vendedora)",
"conversation_refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001"
}
],
"scenario_id": "b5b5b5b5-1000-4000-8000-000000000001",
"evidence_run_id": "b5b5b5b5-2000-4000-8000-000000000001",
"verification_run_id": null,
"proposal_ids": [
"b5b5b5b5-3000-4000-8000-000000000001"
],
"grounding_attempts": [
{
"outcome": "confirmed",
"path": "reviews",
"candidates": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001",
"quote": "listo, sin problema - te confirmo por aquí",
"why": "Coincide con el reclamo."
}
]
}
],
"no_repro_note": null,
"received_against_version": 6,
"override": null,
"harness_reference": null,
"tenant_message": null,
"created_at": "2026-09-20T14:00:00.000Z",
"updated_at": "2026-09-20T14:05:00.000Z",
"scenario": null,
"evidence_run": null,
"verification_run": null,
"override_by_name": null
},
"proposals": [
{
"id": "b5b5b5b5-3000-4000-8000-000000000001",
"surface": "prompt",
"action": "edit_system_prompt",
"summary": "Agrega una instrucción explícita: nunca usar guiones largos en las respuestas.",
"applyable": true
}
],
"investigation": {
"evidence": "red_run",
"model": "xiaomi/mimo-v2.6-flash"
}
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/change-requests/{crId}/scenario
«Crear escenario» — author the eval from the request and run it
ONE call does four things, because all four are the same act and half of it is worse than none (ADR 0103 §4.2):
- The
scenario-builderwrites the scenario —from: "conversation"over the FIRST confirmed ref (the builder masks phones, e-mails and RUTs out of the transcript before the model sees it), orfrom: "description"with the verbatim when the request names no conversation. EITHER WAY the verbatim is the operatornote, so the client's own words end up inscenario.source.note; when the behaviour was confirmed in more than one thread the note says so, because a complaint seen four times is a class and deserves a check that guards the class. - The scenario is saved ACTIVE and TAGGED with the request
SR-n, which is how the Evals tab and the «Solicitudes» list find each other. - It joins the agent's golden suite — by construction when the suite has the usual empty filter (every ACTIVE scenario), by appending to
scenario_idswhen the suite names an explicit list. - ONE run is enqueued — never the scenario's own
repeats, because this is evidence and not a flake measurement — against the version the client complained about:livewhenreceived_against_versionis a number,draftwhen it is null (the agent was never published, so the draft IS the thing under repair).scenario_idandevidence_run_idland on the request.
The run is ASYNC: the answer carries it queued, and the verdict is applied by the scenario-run worker when it finishes (or on demand at …/scenario/evaluate).
REFUSED WITH 400 when the request is not grounded (nobody has settled which conversations it is about) or already links a scenario (that is «Refinar»). A body the builder could not make valid twice is a 422 with code: "builder_invalid" and the zod issues, exactly as POST /ai-agents/\{id\}/scenarios/build answers it. Requires ai_agents:read AND corrections:write AND ai_agents:write — it authors and saves an eval, which is what the scenario routes take.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
crId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-requests/<id>/scenario \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{}'Example response (201)
{
"data": {
"request": {
"id": "b5b5b5b5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"display_id": "SR-14",
"status": "reproduced",
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_member_id": "11111111-0000-4000-8000-000000000001",
"reporter_kind": "client_via_member",
"reporter_name": "Camila (vendedora)",
"conversation_refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001"
}
],
"scenario_id": "b5b5b5b5-1000-4000-8000-000000000001",
"evidence_run_id": "b5b5b5b5-2000-4000-8000-000000000001",
"verification_run_id": null,
"proposal_ids": [],
"grounding_attempts": [
{
"outcome": "confirmed",
"path": "reviews",
"candidates": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001",
"quote": "listo, sin problema - te confirmo por aquí",
"why": "Coincide con el reclamo."
}
]
}
],
"no_repro_note": null,
"received_against_version": 6,
"override": null,
"harness_reference": null,
"tenant_message": null,
"created_at": "2026-09-20T14:00:00.000Z",
"updated_at": "2026-09-20T14:05:00.000Z",
"scenario": null,
"evidence_run": null,
"verification_run": null,
"override_by_name": null
},
"scenario": {
"id": "b5b5b5b5-1000-4000-8000-000000000001",
"name": "SR-14 — sin guiones largos",
"tags": [
"SR-14"
]
},
"run": {
"id": "b5b5b5b5-2000-4000-8000-000000000001",
"status": "queued"
},
"ran_against": "live"
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/change-requests/{crId}/scenario/evaluate
Re-read the evidence run and re-apply the red-gate rule
§4.2's rule, applied on demand and IDEMPOTENTLY:
- run
failed— the complaint reproduces:grounded → reproduced, and «no reprodujo» is cleared. - run
passedorpartialand the complaint PREDATES the live version — the agent already complies: the request closes asya_cumplewith the green run still attached, because a verdict nobody can open is not a verdict. - run
passedorpartialotherwise — the request STAYS atgrounded,no_repro_noterecords «no reprodujo» with a timestamp, and «Refinar» is what the surface offers next. A scenario authored from a complaint that passes on its first run has captured something else. - run
error— nothing was learned. The request stays exactly where it was and the note says the gateway refused, because "we ran it and it did not reproduce" is a different and untrue statement. - run
queued/running—meta.outcome: "pending"and nothing changes.
The scenario-run worker applies the same rule through the same function the moment a run finishes; this exists for when that hook did not run (the evals pool was down, the process was killed between the finish and the hook). 400 when the request has no run to evaluate. Same scopes as authoring: the rule MOVES the request, and every target past grounded takes ai_agents:write.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
crId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-requests/<id>/scenario/evaluate \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{}'Example response (200)
{
"data": {
"request": {
"id": "b5b5b5b5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"display_id": "SR-14",
"status": "reproduced",
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_member_id": "11111111-0000-4000-8000-000000000001",
"reporter_kind": "client_via_member",
"reporter_name": "Camila (vendedora)",
"conversation_refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001"
}
],
"scenario_id": "b5b5b5b5-1000-4000-8000-000000000001",
"evidence_run_id": "b5b5b5b5-2000-4000-8000-000000000001",
"verification_run_id": null,
"proposal_ids": [],
"grounding_attempts": [
{
"outcome": "confirmed",
"path": "reviews",
"candidates": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001",
"quote": "listo, sin problema - te confirmo por aquí",
"why": "Coincide con el reclamo."
}
]
}
],
"no_repro_note": null,
"received_against_version": 6,
"override": null,
"harness_reference": null,
"tenant_message": null,
"created_at": "2026-09-20T14:00:00.000Z",
"updated_at": "2026-09-20T14:05:00.000Z",
"scenario": null,
"evidence_run": null,
"verification_run": null,
"override_by_name": null
},
"run": {
"id": "b5b5b5b5-2000-4000-8000-000000000001",
"status": "failed"
},
"meta": {
"outcome": "reproduced"
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/change-requests/{crId}/scenario/refine
«Refinar» — rewrite the scenario that did not reproduce and re-run it
The builder's refine step over the request's current scenario, then a fresh run (ADR 0103 §4.2: a first-run pass "leaves the request at grounded with a note and offers the builder's refine step").
note is what the builder is told to change and DEFAULTS TO THE VERBATIM, which is the correct first refine: a scenario that went green on a real complaint usually missed the point of what the client said. The stored body is replaced with the refined one (the SR-n tag is re-applied rather than trusted), evidence_run_id points at the new run and no_repro_note is CLEARED — the old verdict was about a scenario that no longer exists in that shape.
Offered while the request is still grounded and it links a scenario; anything else is a 400. Past grounded there is a red run somebody is acting on, and replacing the scenario under it would move the evidence out from under a proposal. Same scopes as authoring; the same 422 contract when the builder cannot produce a valid body twice.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
crId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
note | string | — | mín. 1, máx. 5000 |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-requests/<id>/scenario/refine \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{}'Example response (200)
{
"data": {
"request": {
"id": "b5b5b5b5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"display_id": "SR-14",
"status": "grounded",
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_member_id": "11111111-0000-4000-8000-000000000001",
"reporter_kind": "client_via_member",
"reporter_name": "Camila (vendedora)",
"conversation_refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001"
}
],
"scenario_id": "b5b5b5b5-1000-4000-8000-000000000001",
"evidence_run_id": "b5b5b5b5-2000-4000-8000-000000000002",
"verification_run_id": null,
"proposal_ids": [],
"grounding_attempts": [
{
"outcome": "confirmed",
"path": "reviews",
"candidates": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001",
"quote": "listo, sin problema - te confirmo por aquí",
"why": "Coincide con el reclamo."
}
]
}
],
"no_repro_note": null,
"received_against_version": 6,
"override": null,
"harness_reference": null,
"tenant_message": null,
"created_at": "2026-09-20T14:00:00.000Z",
"updated_at": "2026-09-20T14:05:00.000Z",
"scenario": null,
"evidence_run": null,
"verification_run": null,
"override_by_name": null
},
"scenario": {
"id": "b5b5b5b5-1000-4000-8000-000000000001",
"name": "SR-14 — sin guiones largos (refinado)",
"tags": [
"SR-14"
]
},
"run": {
"id": "b5b5b5b5-2000-4000-8000-000000000002",
"status": "queued"
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/change-requests/{crId}/transition
Move a change request along its lifecycle
Lifecycle: received → grounded → reproduced → proposed → applied → verified, plus two terminals that are outcomes rather than failures — ya_cumple (the scenario went green on its first run and the complaint was about an older version, so the request closes with that green run attached) and harness (the fix belongs to Vitrina's harness — turn loop, retries, handoff mechanics, channel behaviour — so the tenant reads "esto lo arregla Vitrina" with a reference instead of being sent to edit a skill that cannot fix it). harness is reachable from every non-terminal state; ya_cumple from grounded and reproduced. Everything else is a 400 naming both ends of the refused edge.
THE SCOPE SPLIT. received → grounded takes the same pair as filing (ai_agents:read AND corrections:write): it says "this is the conversation I meant", not "change the agent". EVERY other target additionally requires ai_agents:write, and a caller without it gets a 403 naming the scope.
The write is a compare-and-set against the status that was read, so two people pressing at once cannot both win — the loser gets a 400 saying the request is no longer where they thought it was.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
crId | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
to | received \ | grounded \ | reproduced \ |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/change-requests/<id>/transition \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "reproduced"
}'Example response (200)
{
"data": {
"id": "b5b5b5b5-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"ai_agent_id": "a3a3a3a3-0000-4000-8000-000000000001",
"display_id": "SR-14",
"status": "reproduced",
"verbatim": "No uses guiones largos, suena robótico.",
"reporter_member_id": "11111111-0000-4000-8000-000000000001",
"reporter_kind": "client_via_member",
"reporter_name": "Camila (vendedora)",
"conversation_refs": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001"
}
],
"scenario_id": null,
"evidence_run_id": null,
"verification_run_id": null,
"proposal_ids": [],
"grounding_attempts": [
{
"outcome": "confirmed",
"path": "reviews",
"candidates": [
{
"conversationId": "bbbbbbbb-0000-4000-8000-000000000001",
"messageId": "bbbbbbbb-1000-4000-8000-000000000001",
"quote": "listo, sin problema - te confirmo por aquí",
"why": "Coincide con el reclamo."
}
]
}
],
"no_repro_note": null,
"received_against_version": 6,
"override": null,
"harness_reference": null,
"tenant_message": null,
"created_at": "2026-09-20T14:00:00.000Z",
"updated_at": "2026-09-20T14:05:00.000Z",
"scenario": null,
"evidence_run": null,
"verification_run": null,
"override_by_name": null
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/clone
Clone an AI agent (copies tool links, drops is_default)
Copies the SOURCE agent’s live config plus its tool wirings into a brand-new agent — skills and KB attachments included, each by reference (the shared row, not a duplicate). The clone is never is_default and starts with no published_version_id: publish it explicitly once you are happy with it. Answers 201.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | — | mín. 1, máx. 120 |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/clone \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Asistente de ventas (copia)"
}'Example response (201)
{
"data": {
"id": "a3a3a3a3-0000-4000-8000-000000000003",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Asistente de ventas (copia)",
"description": "Responde preguntas de stock y agenda visitas a sucursal.",
"status": "active",
"is_default": false,
"model": "xiaomi/mimo-v2.6-flash",
"temperature": null,
"default_max_steps": 5,
"reasoning_effort": "high",
"autonomy_level": "supervised",
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana.",
"knowledge_tags": [
"horarios",
"garantia"
],
"tags": [],
"followups_enabled": true,
"allowed_url_prefixes": [],
"published_version_id": "a3a3a3a3-1000-4000-8000-000000000007",
"draft_system_prompt": null,
"draft_model": null,
"draft_temperature": null,
"draft_max_steps": null,
"draft_tool_wiring": null,
"draft_skill_ids": null,
"draft_kb_file_ids": null,
"draft_knowledge_tags": null,
"draft_updated_at": null,
"created_at": "2026-08-04T19:07:05.226Z",
"updated_at": "2026-09-17T14:49:02.893Z"
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /ai-agents/{id}/draft
Discard the pending draft
Clears every staged draft_* field. The live runtime is untouched — this only throws away work in progress. A no-op (still 204) when there is no pending draft.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X DELETE https://api.vitrinadev.com/api/v1/ai-agents/<id>/draft \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/draft
Read the pending draft (system_prompt + knowledge_tags)
Only the staged draft_* fields, not the whole agent — GET /ai-agents/\{id\} already carries them alongside the live ones. draft_updated_at: null means there is no pending draft.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/draft \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"draft_system_prompt": "Eres un asistente de ventas. Ahora también ofreces financiamiento.",
"draft_knowledge_tags": [
"horarios",
"garantia",
"financiamiento"
],
"draft_updated_at": "2026-09-21T10:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /ai-agents/{id}/draft
Edit the pending draft (does not affect live runtime)
The full-config draft (ADR 0010/0012): system_prompt, voice_instructions, model/temperature/max_steps, tool_wiring, skill_ids, kb_file_ids, knowledge_tags. Every field optional and merged — an omitted field leaves the previous draft value untouched (an empty string IS a value: it stages a clear). Nothing here reaches the runtime until POST /ai-agents/\{id\}/publish.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
system_prompt | string | — | |
voice_instructions | string | — | máx. 40000 |
comment_dm_criteria | string | — | máx. 4000 |
knowledge_tags | string[] | — | |
model | string | — | mín. 1, máx. 120 |
temperature | number | null | — | ≥ 0, ≤ 2 |
reasoning_effort | max \ | xhigh \ | high \ |
max_steps | integer | — | ≥ 1, ≤ 20 |
tool_wiring | object[] | — | |
skill_ids | uuid[] | — | |
kb_file_ids | uuid[] | — |
curl -X PUT https://api.vitrinadev.com/api/v1/ai-agents/<id>/draft \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"system_prompt": "Eres un asistente de ventas. Ahora también ofreces financiamiento.",
"knowledge_tags": [
"horarios",
"garantia",
"financiamiento"
]
}'Example response (200)
{
"data": {
"draft_system_prompt": "Eres un asistente de ventas. Ahora también ofreces financiamiento.",
"draft_knowledge_tags": [
"horarios",
"garantia",
"financiamiento"
],
"draft_updated_at": "2026-09-21T10:00:00.000Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/export
Export the agent’s full configuration
Everything the runtime assembles for this agent, built through the same assembler the runtime uses: the rendered system prompt, the gated tool catalogue with resolved "when to use" text, skills, KB files, workspace/business-hours/branch/branding/pipeline context, every parameter, and the staged draft. The best answer available to "why did the agent do that".
Neither format is the \{ data \} envelope. The default is the bundle as a JSON document served as an attachment; ?format=zip returns a readable folder tree (instructions.md, skills/, knowledge/ with the real file bytes, tools.json). Available in all environments, tenant-scoped.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
format | query | zip | no |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/export \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"agent": {
"id": "a3a3a3a3-0000-4000-8000-000000000001",
"name": "Asistente de ventas"
},
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana.",
"tools": [
{
"name": "search_vehicle_stock",
"when_to_use": "Cuando el cliente pregunte por disponibilidad."
}
],
"skills": [
{
"id": "a4a4a4a4-0000-4000-8000-000000000001",
"name": "Agendar hora"
}
],
"knowledge": [
{
"id": "a6a6a6a6-0000-4000-8000-000000000001",
"name": "Garantías 2026.pdf"
}
]
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/knowledge
List the files attached to an agent
The agent’s slice of the tenant knowledge library. A file lives in /kb-files and is ATTACHED here — the same file can back several agents.
Each row carries snippet: the first 400 characters of the file’s first INGESTED chunk. null means the file has no chunks — still ingesting, or ingestion failed — which also means the agent cannot retrieve it, so it is a status signal and not just a missing preview.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/knowledge \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"kb_file_id": "a6a6a6a6-0000-4000-8000-000000000001",
"name": "Garantías 2026.pdf",
"status": "ingested",
"snippet": "Todos los vehículos nuevos incluyen 3 años o 100.000 km de garantía de fábrica…"
}
],
"meta": {
"total": 1
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/knowledge
Upload a new file, or attach an existing one
One URL, two operations, chosen by content-type. Send multipart with a file field to upload a new file and auto-attach it (25 MB cap); send JSON \{ kb_file_id \} to attach a file already in the library. Neither is a fallback for the other — a JSON body that is not a valid \{ kb_file_id \} is a 400 telling you to pick one.
The two also answer differently: the upload branch returns the created file row, the attach branch returns \{ attached: true, kb_file_id \}. Both are 201.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
kb_file_id | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/knowledge \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"kb_file_id": "a6a6a6a6-0000-4000-8000-000000000001"
}'Example response (201)
{
"data": {
"attached": true,
"kb_file_id": "a6a6a6a6-0000-4000-8000-000000000001"
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /ai-agents/{id}/knowledge/{fileId}
Detach a file from an agent
Detaches, it does not delete. The file stays in the tenant library and keeps backing every other agent it is attached to. To delete the file itself — which cascades to its attachments and its embedded chunks — use DELETE /kb-files/\{id\}.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
fileId | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/ai-agents/<id>/knowledge/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/metrics
Rolling performance metrics for one agent
The detail screen’s header strip. A ROLLING window ending now: days back from the request, default 7, max 90.
conversations, resolved_without_human and handoffs are the same numbers the Insights tab shows, read from the insights_ai_agents RPC — a conversation counts for an agent when that agent posted a REAL turn in it inside the window (template sends, tool-call rows and reasoning traces are not turns). resolved_without_human = no human wrote AND the agent never called handoff; handoffs = the agent called handoff, with handoffs_answered the subset a human then replied to. pct is a FRACTION of conversations, and is null — not 0 — when there were none.
first_response_seconds is a MEDIAN, not a mean, over conversations STARTED in the window, pairing each one’s first inbound message with this agent’s first later reply; null when no conversation has both. appointments_booked counts completed booking-tool executions.
An agent with no traffic answers 200 with zeros and nulls, never 404.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
days | query | integer | no | ≥ 1, ≤ 90, por defecto 7 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/metrics \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"days": 7,
"from": "2026-09-15T16:38:21.402Z",
"to": "2026-09-22T16:38:21.402Z",
"conversations": 18,
"resolved_without_human": {
"count": 4,
"pct": 0.22
},
"handoffs": {
"count": 2,
"pct": 0.11
},
"handoffs_answered": 2,
"appointments_booked": 1,
"first_response_seconds": 388.36
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/publish
Atomically promote the draft onto the live runtime + snapshot a version
Gated by Agent Evals (ADR 0098): when the agent has an ENABLED GOLDEN suite whose gate is blocked or stale, this answers 409 \{ error, code: 'evals_blocked' | 'evals_stale', gate \} — see GET /ai-agents/\{id\}/publish-gate for the semantics. force: true publishes anyway and is recorded in the audit log with the actor. skip_eval: true skips the post-publish suite/scenario run; it does NOT bypass the gate.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
skip_eval | boolean | — | |
force | boolean | — |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/publish \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{}'Example response (200)
{
"data": {
"id": "a3a3a3a3-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Asistente de ventas",
"description": "Responde preguntas de stock y agenda visitas a sucursal.",
"status": "active",
"is_default": true,
"model": "xiaomi/mimo-v2.6-flash",
"temperature": null,
"default_max_steps": 5,
"reasoning_effort": "high",
"autonomy_level": "supervised",
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana.",
"knowledge_tags": [
"horarios",
"garantia"
],
"tags": [],
"followups_enabled": true,
"allowed_url_prefixes": [],
"published_version_id": "a3a3a3a3-1000-4000-8000-000000000008",
"draft_system_prompt": null,
"draft_model": null,
"draft_temperature": null,
"draft_max_steps": null,
"draft_tool_wiring": null,
"draft_skill_ids": null,
"draft_kb_file_ids": null,
"draft_knowledge_tags": null,
"draft_updated_at": null,
"created_at": "2026-08-04T19:07:05.226Z",
"updated_at": "2026-09-17T14:49:02.893Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/publish-gate
Would publishing this agent be refused by its evals?
Reads the agent's ENABLED GOLDEN suite and nothing else.
none— no enabled golden suite. Never blocks: a workspace that has not opted into gating is not gated.pending— a golden suite run is queued or running. Warn only; blocking here would mean "press publish again in four minutes".stale— the newest COMPLETED golden run predates the agent'sdraft_updated_at(or there is none). Checked BEFORE the policy: a red verdict about a draft that no longer exists is not evidence about the one being published.blocked— that run has hard fails (whenpolicy.block_publish_on_hard_fail) or apass_rateunderpolicy.min_pass_rate.ready— otherwise.
reasons are stable snake_case codes (no_golden_suite, suite_run_in_flight, no_completed_run, stale_draft, hard_fails, pass_rate_below_min) so the dialog can localize them. failing lists the red scenarios of the last completed run with their run_ids.
blocked and stale make POST /ai-agents/\{id\}/publish answer 409 \{ error, code: 'evals_blocked' | 'evals_stale', gate \} unless the body carries force: true.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/publish-gate \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"gate": "clear",
"suite": {
"id": "b6b6b6b6-0000-4000-8000-000000000001",
"name": "Golden",
"kind": "golden"
},
"last_run": {
"pass_rate": 91.2,
"hard_fails": 0
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/skills
List the skills attached to one agent
The LIVE attachment set for this agent — a subset of the tenant skill library (GET /skills).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/skills \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "a4a4a4a4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Agendar hora",
"description": "Cómo ofrecer y confirmar horarios disponibles.",
"content": "Cuando el cliente quiera agendar, ofrece los horarios disponibles y confirma antes de reservar.",
"status": "active",
"channel_overrides": {},
"created_by": "11111111-0000-4000-8000-000000000001",
"created_at": "2026-09-04T19:10:00.000Z",
"updated_at": "2026-09-04T19:10:00.000Z",
"deleted_at": null
}
],
"meta": {
"total": 1
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/skills
Attach an existing skill to an agent
Links a skill already in the library to this agent — it does not create one. The skill content itself is shared: editing it (PUT /skills/\{id\}) changes what every attached agent sees. Answers 201; attaching an already-attached skill is idempotent.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
skill_id | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/skills \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"skill_id": "a4a4a4a4-0000-4000-8000-000000000001"
}'Example response (201)
{
"data": {
"attached": true,
"skill_id": "a4a4a4a4-0000-4000-8000-000000000001"
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /ai-agents/{id}/skills/{skillId}
Detach a skill from an agent (does not delete the skill)
Removes the attachment only — the skill row survives in the library and stays attached to any other agent. To remove the skill entirely, use DELETE /skills/\{id\}.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes | |
skillId | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/ai-agents/<id>/skills/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/system-prompt
Get system prompt
The LIVE system_prompt — what the runtime reads on the next turn. The staged edit, if any, is on draft_system_prompt (GET /ai-agents/\{id\}/draft).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/system-prompt \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana."
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /ai-agents/{id}/system-prompt
Update system prompt
A convenience shortcut for editing ONLY the prompt: identical in effect to PUT /ai-agents/\{id\}/draft with just system_prompt set — it STAGES the change onto draft_system_prompt and does not touch the live runtime until POST /ai-agents/\{id\}/publish.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
system_prompt | string | yes | |
knowledge_tags | string[] | — |
curl -X PUT https://api.vitrinadev.com/api/v1/ai-agents/<id>/system-prompt \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"system_prompt": "Eres un asistente de ventas. Responde de forma breve, cercana y en español."
}'Example response (200)
{
"data": {
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana."
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/tools
List tools registered to this AI agent
The LIVE tool wiring — platform tools and custom tools currently enabled for this agent. PUT /ai-agents/\{id\}/tools replaces this set wholesale.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/tools \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "a3a3a3a3-2000-4000-8000-000000000001",
"name": "search_vehicle_stock",
"kind": "builtin",
"external_name": "Search vehicle stock",
"description": "Search the dealer's current vehicle stock."
}
]
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /ai-agents/{id}/tools
Set the agent’s tools
Replaces the whole wiring with tool_names — this is a set, not a patch, so omitting a currently-wired tool unwires it.
Applies LIVE, not to the draft. That is deliberate and was a bug fix: staging tool selection meant Save wrote a draft the picker never read back, so the selection appeared to revert on refetch. The runtime and the picker now read the same live set, and a save here changes what the agent can do on its very next turn without a publish.
Names come from /ai-agents/tools-catalog. A name the workspace is not entitled to is DROPPED rather than rejected, so compare meta.total against what you sent — a 200 does not mean everything was wired. Removal is narrower than that on purpose: only a tool you left OUT of tool_names is unwired. One you still selected that happens to be gated right now (a calendar that momentarily looks disconnected) keeps its link, because the runtime already hides it and deleting the wiring would lose it permanently once the integration came back.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
tool_names | string[] | yes |
curl -X PUT https://api.vitrinadev.com/api/v1/ai-agents/<id>/tools \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"tool_names": [
"search_vehicle_stock",
"book_appointment"
]
}'Example response (200)
{
"data": [
{
"name": "search_vehicle_stock",
"wired": true
},
{
"name": "book_appointment",
"wired": true
}
],
"meta": {
"total": 2
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/versions
List version history for an AI agent
Newest first. Each row is the FULL stored version row EXCEPT that skill_snapshot[].content is omitted — the timeline never renders a skill body and it is the only unbounded field on the row. Open a version to get the bodies. The per-version "+N skills / +N archivos / +N herramientas" chips are a client-side diff of the id arrays against the previous row (ADR 0093).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
limit | query | integer | no | ≥ 1, ≤ 200, por defecto 50 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/versions \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "a3a3a3a3-1000-4000-8000-000000000007",
"version_number": 7,
"label": "Published draft",
"created_by": "11111111-0000-4000-8000-000000000001",
"created_at": "2026-09-17T14:49:02.601Z"
},
{
"id": "a3a3a3a3-1000-4000-8000-000000000006",
"version_number": 6,
"label": "Rollback to v4",
"created_by": "11111111-0000-4000-8000-000000000001",
"created_at": "2026-09-10T11:02:00.000Z"
}
]
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/versions/{version}
Get a specific stored version
The full row (skill bodies included) plus resolved — ADR 0093’s answer to "what became of the things this version referenced". Skills and KB files are tenant-shared resources referenced BY ID, so each one reports a state: current, modified (the skill exists but its content or channel_overrides drifted since this version), deleted (soft-deleted — a restore brings it back) or missing (no row at all; a restore drops it). Names for deleted/missing items come from the version’s own snapshot.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
version | path | integer | yes | ≥ 1 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/versions/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "a3a3a3a3-1000-4000-8000-000000000007",
"version_number": 7,
"label": "Published draft",
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana.",
"skill_snapshot": [
{
"id": "a4a4a4a4-0000-4000-8000-000000000001",
"name": "Agendar hora",
"state": "current"
}
],
"kb_file_snapshot": [
{
"id": "a6a6a6a6-0000-4000-8000-000000000001",
"name": "Garantías 2026.pdf",
"state": "current"
}
],
"resolved": {
"skills": [
{
"id": "a4a4a4a4-0000-4000-8000-000000000001",
"state": "current"
}
],
"kb_files": [
{
"id": "a6a6a6a6-0000-4000-8000-000000000001",
"state": "current"
}
]
},
"created_by": "11111111-0000-4000-8000-000000000001",
"created_at": "2026-09-17T14:49:02.601Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/versions/{version}/restore
Restore an AI agent to a stored version
Loads the version into the DRAFT (nothing goes live until Publish). ADR 0093 — restore also REPAIRS the references first: a soft-deleted skill is un-deleted, a soft-deleted KB file is un-deleted and re-ingested from S3, and a reference with no row left is dropped from the staged draft rather than staged into a publish that would FK-fail. restore.recovered lists what came back, restore.warnings what was dropped.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
version | path | integer | yes | ≥ 1 |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/versions/<id>/restore \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"restored": {
"version_number": 4
},
"restore": {
"recovered": [
{
"kind": "skill",
"id": "a4a4a4a4-0000-4000-8000-000000000001"
}
],
"warnings": []
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/versions/{version}/rollback
Make a past version live again
Three steps in one call: load the version into the draft, PUBLISH it, and snapshot the result labelled Rollback to v\{n\} so history stays linear rather than branching. It also fires ai_agent.publish, so downstream consumers see a rollback as the publish it is.
Guarded like a publish: the tenant editable_window applies and a rollback outside it is refused. Contrast the sibling /versions/\{version\}/restore, which only loads the version into the draft and leaves publishing to you — use that one if you want to look before going live.
\{version\} is the version NUMBER, not the version row’s id. Anything in the draft at the time is overwritten.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
version | path | integer | yes | ≥ 1 |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/versions/<id>/rollback \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "a3a3a3a3-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Asistente de ventas",
"description": "Responde preguntas de stock y agenda visitas a sucursal.",
"status": "active",
"is_default": true,
"model": "xiaomi/mimo-v2.6-flash",
"temperature": null,
"default_max_steps": 5,
"reasoning_effort": "high",
"autonomy_level": "supervised",
"system_prompt": "Eres un asistente de ventas. Responde de forma breve y cercana.",
"knowledge_tags": [
"horarios",
"garantia"
],
"tags": [],
"followups_enabled": true,
"allowed_url_prefixes": [],
"published_version_id": "a3a3a3a3-1000-4000-8000-000000000008",
"draft_system_prompt": null,
"draft_model": null,
"draft_temperature": null,
"draft_max_steps": null,
"draft_tool_wiring": null,
"draft_skill_ids": null,
"draft_kb_file_ids": null,
"draft_knowledge_tags": null,
"draft_updated_at": null,
"created_at": "2026-08-04T19:07:05.226Z",
"updated_at": "2026-09-17T14:49:02.893Z"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/voice-channel
The agent's voice channel config: line status, greeting (locked disclosure + brand), curated voices, handoff, propagation state
One combined read of everything the Voz tab needs: the PSTN line state, the composed greeting (greeting_brand is the only editable segment — the AI-disclosure + recording notice is fixed and composed server-side, ADR 0039), the curated voice catalogue voice_id picks from, handoff_destination (E.164, null when unset), and propagation (whether the persisted config has reached the speech layer yet).
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/voice-channel \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"configured": true,
"account_id": "a3a3a3a3-3000-4000-8000-000000000001",
"kind": "elevenlabs_voice",
"phone_number": "+56229381400",
"enabled": true,
"test_calls": {
"limit": 5,
"used_today": 1,
"remaining_today": 4
},
"greeting": {
"disclosure": "Hola, le atiende un asistente virtual. Le comento que esta llamada puede ser grabada.",
"brand": "Bienvenido a Mi Empresa",
"composed": "Hola, le atiende un asistente virtual de Mi Empresa. Le comento que esta llamada puede ser grabada. ¿En qué le puedo ayudar hoy?"
},
"voice_id": "EXAVITQu4vr4xnSDxMaL",
"voices": [
{
"voice_id": "EXAVITQu4vr4xnSDxMaL",
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.8
},
"name": "Sarah",
"region": "US",
"language": "en",
"description_es": "Suave y profesional. Voz femenina americana."
},
{
"voice_id": "6Gr4AVmTax1pMJO0lHRK",
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.8
},
"name": "Catalina",
"region": "CL",
"language": "es",
"description_es": "Chilena, cálida y profesional. Suena como una persona real."
}
],
"handoff_destination": "+56912345678",
"propagation": {
"status": "synced",
"error": null,
"attempted_at": "2026-09-21T10:00:00.000Z"
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /ai-agents/{id}/voice-channel
Update greeting brand segment / curated voice / handoff number (persists Vitrina-side, then best-effort speech-layer propagation)
Every field optional (at least one required). voice_id must be one of the ids GET /ai-agents/\{id\}/voice-channel’s curated_voices lists — a curated catalogue key, not a per-tenant resource, so it is validated server-side rather than typed as a uuid. Persists Vitrina-side FIRST, then best-effort pushes the change to the speech layer; a push failure leaves propagation.state non-synced rather than failing the request — retry with POST /ai-agents/\{id\}/voice-channel/propagate.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
greeting_brand | string | — | máx. 350 |
voice_id | string | — | mín. 1, máx. 120 |
handoff_destination | string | null | — | máx. 40 |
curl -X PUT https://api.vitrinadev.com/api/v1/ai-agents/<id>/voice-channel \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"greeting_brand": "Bienvenido a Mi Empresa",
"voice_id": "EXAVITQu4vr4xnSDxMaL",
"handoff_destination": "+56912345678"
}'Example response (200)
{
"data": {
"configured": true,
"account_id": "a3a3a3a3-3000-4000-8000-000000000001",
"kind": "elevenlabs_voice",
"phone_number": "+56229381400",
"enabled": true,
"test_calls": {
"limit": 5,
"used_today": 1,
"remaining_today": 4
},
"greeting": {
"disclosure": "Hola, le atiende un asistente virtual. Le comento que esta llamada puede ser grabada.",
"brand": "Bienvenido a Mi Empresa",
"composed": "Hola, le atiende un asistente virtual de Mi Empresa. Le comento que esta llamada puede ser grabada. ¿En qué le puedo ayudar hoy?"
},
"voice_id": "EXAVITQu4vr4xnSDxMaL",
"voices": [
{
"voice_id": "EXAVITQu4vr4xnSDxMaL",
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.8
},
"name": "Sarah",
"region": "US",
"language": "en",
"description_es": "Suave y profesional. Voz femenina americana."
},
{
"voice_id": "6Gr4AVmTax1pMJO0lHRK",
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.8
},
"name": "Catalina",
"region": "CL",
"language": "es",
"description_es": "Chilena, cálida y profesional. Suena como una persona real."
}
],
"handoff_destination": "+56912345678",
"propagation": {
"status": "synced",
"error": null,
"attempted_at": "2026-09-21T10:00:00.000Z"
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/voice-channel/propagate
Retry pushing the persisted voice config onto the speech layer (when propagation is pending)
Re-attempts the push a PUT already tried best-effort. Idempotent — calling it while already synced is a no-op that just returns the current state.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/voice-channel/propagate \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"configured": true,
"account_id": "a3a3a3a3-3000-4000-8000-000000000001",
"kind": "elevenlabs_voice",
"phone_number": "+56229381400",
"enabled": true,
"test_calls": {
"limit": 5,
"used_today": 1,
"remaining_today": 4
},
"greeting": {
"disclosure": "Hola, le atiende un asistente virtual. Le comento que esta llamada puede ser grabada.",
"brand": "Bienvenido a Mi Empresa",
"composed": "Hola, le atiende un asistente virtual de Mi Empresa. Le comento que esta llamada puede ser grabada. ¿En qué le puedo ayudar hoy?"
},
"voice_id": "EXAVITQu4vr4xnSDxMaL",
"voices": [
{
"voice_id": "EXAVITQu4vr4xnSDxMaL",
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.8
},
"name": "Sarah",
"region": "US",
"language": "en",
"description_es": "Suave y profesional. Voz femenina americana."
},
{
"voice_id": "6Gr4AVmTax1pMJO0lHRK",
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.8
},
"name": "Catalina",
"region": "CL",
"language": "es",
"description_es": "Chilena, cálida y profesional. Suena como una persona real."
}
],
"handoff_destination": "+56912345678",
"propagation": {
"status": "synced",
"error": null,
"attempted_at": "2026-09-22T09:00:00.000Z"
}
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/{id}/voice-lines
The agent's voice lines (ADR 0053): the PSTN number callers dial and the WhatsApp in-chat call button, each with its own state (none/activating/active/in_call/desync)
Two lines, always both present in the response even when never activated: pstn (a dedicated phone number) and whatsapp (the in-chat call button on the agent’s WhatsApp number). Each carries state (none | activating | active | in_call | desync) and, once provisioned, number.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
curl https://api.vitrinadev.com/api/v1/ai-agents/<id>/voice-lines \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"lines": [
{
"kind": "pstn",
"state": "active",
"e164": "+56229381400",
"account_id": "a3a3a3a3-3000-4000-8000-000000000001",
"activation": null,
"desync": null,
"call": null,
"handoff_e164": "+56912345678",
"extra_line_count": 0,
"eligibility": {
"can_activate": true,
"blocked_reason": null,
"remedies": []
}
},
{
"kind": "whatsapp",
"state": "none",
"e164": null,
"account_id": null,
"activation": null,
"desync": null,
"call": null,
"handoff_e164": null,
"extra_line_count": 0,
"eligibility": {
"can_activate": true,
"blocked_reason": null,
"remedies": []
}
}
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/voice-lines/{kind}/activate
Start the activation saga for one line. Async and idempotent; a second call while one is running returns refused_reason=already_running rather than starting a second saga
kind is pstn or whatsapp. Provisioning runs on the speech layer in the background, so this answers 200 with state: "activating" immediately, not once the line is live — poll GET /ai-agents/\{id\}/voice-lines for the eventual active. refused_reason is non-null and state unchanged when a saga is already running for this line.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
kind | path | pstn \ | whatsapp | yes |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/voice-lines/<id>/activate \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"kind": "pstn",
"state": "activating"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /ai-agents/{id}/voice-lines/{kind}/deactivate
Take one line off the air. REFUSES with 200 + refused_reason=call_in_progress while a customer is on the line — the call is never dropped
A REFUSAL here is a 200, not a 4xx — refused_reason: "call_in_progress" with state unchanged means exactly that: a live call is not interrupted by this call. Retry once the call ends.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | mín. 1 |
kind | path | pstn \ | whatsapp | yes |
curl -X POST https://api.vitrinadev.com/api/v1/ai-agents/<id>/voice-lines/<id>/deactivate \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"kind": "pstn",
"state": "in_call",
"refused_reason": "call_in_progress"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /ai-agents/tools-catalog
Tools this workspace may wire to an agent
The catalogue is TENANT-RESOLVED, not static: tools gated on a vertical or an unconnected integration are filtered out, so what comes back is what this workspace can actually use today. Connecting an integration or changing the vertical changes this list.
Names from here are what PUT /ai-agents/\{id\}/tools accepts.
curl https://api.vitrinadev.com/api/v1/ai-agents/tools-catalog \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"platform": [
{
"key": "search_vehicle_stock",
"name": "search_vehicle_stock",
"description": "Search the dealer's current vehicle stock to answer availability/price questions.",
"kind": "platform",
"available": true
}
],
"custom": [
{
"key": "consultar_clima",
"name": "consultar_clima",
"description": "Consulta el clima actual de una ciudad",
"kind": "custom",
"available": true
}
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /change-requests/stats
The workspace's change-request ledger
The same two aggregates as GET /ai-agents/\{id\}/change-request-stats, across EVERY agent of the workspace. "Where is the product weak here" is a question about the workspace: one agent's harness ratio over a quarter is a handful of rows, and a tenant running three agents wants the number across them.
There is no platform-wide variant on purpose. ADR 0103 §1.6: memberships are owner | admin | supervisor | agent, there is no staff role and no superadmin, and everything in this product is tenant-facing by construction. Vitrina's cross-tenant read of the same two numbers is the service-role, read-only script scripts/ops/change-request-stats.ts.
Requires ai_agents:read AND corrections:read.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
days | query | integer | no | ≥ 1, ≤ 365 |
curl https://api.vitrinadev.com/api/v1/change-requests/stats \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"days": 90,
"total": 14,
"byStatus": {
"received": 2,
"grounded": 1,
"reproduced": 0,
"proposed": 1,
"applied": 3,
"verified": 5,
"ya_cumple": 1,
"harness": 1
},
"harness": {
"terminated": 1,
"ratio": 0.0714
},
"appliedBySurface": [
{
"surface": "prompt",
"count": 5
},
{
"surface": "skill",
"count": 2
},
{
"surface": "knowledge",
"count": 1
}
],
"appliedBySurfaceOverTime": [
{
"bucket": "2026-W37",
"surface": "prompt",
"count": 2
},
{
"bucket": "2026-W37",
"surface": "skill",
"count": 1
},
{
"bucket": "2026-W38",
"surface": "prompt",
"count": 3
},
{
"bucket": "2026-W38",
"surface": "skill",
"count": 1
},
{
"bucket": "2026-W38",
"surface": "knowledge",
"count": 1
}
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /skills
List the tenant skill library (with per-agent attachment counts)
Every skill in the workspace — the library is shared, not owned by one agent — with agent_count (how many agents currently have it attached). channel_overrides carries per-channel adjustments to the base content (today: voice, ADR 0039).
curl https://api.vitrinadev.com/api/v1/skills \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": [
{
"id": "a4a4a4a4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Agendar hora",
"description": "Cómo ofrecer y confirmar horarios disponibles.",
"content": "Cuando el cliente quiera agendar, ofrece los horarios disponibles y confirma antes de reservar.",
"status": "active",
"channel_overrides": {},
"created_by": "11111111-0000-4000-8000-000000000001",
"created_at": "2026-09-04T19:10:00.000Z",
"updated_at": "2026-09-04T19:10:00.000Z",
"deleted_at": null,
"agent_count": 2
}
],
"meta": {
"total": 1
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /skills
Create a skill (optional channel_overrides, v1 key: voice)
Adds a new playbook to the library. It is not attached to any agent yet — attach it with POST /ai-agents/\{id\}/skills, or reach it through POST /ai-agents/\{id\}/onboarding/materialise-skills. Answers 201. Fires skill.created.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | mín. 1, máx. 120 |
description | string | null | — | máx. 2000 |
content | string | — | máx. 20000, por defecto "" |
channel_overrides | object | — | |
status | active \ | inactive | — |
curl -X POST https://api.vitrinadev.com/api/v1/skills \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Agendar hora",
"content": "Cuando el cliente quiera agendar, ofrece los horarios disponibles y confirma antes de reservar."
}'Example response (201)
{
"data": {
"id": "a4a4a4a4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Agendar hora",
"description": "Cómo ofrecer y confirmar horarios disponibles.",
"content": "Cuando el cliente quiera agendar, ofrece los horarios disponibles y confirma antes de reservar.",
"status": "active",
"channel_overrides": {},
"created_by": "11111111-0000-4000-8000-000000000001",
"created_at": "2026-09-04T19:10:00.000Z",
"updated_at": "2026-09-04T19:10:00.000Z",
"deleted_at": null
}
}Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /skills/{id}
Delete a skill (SOFT; detaches it from every agent)
ADR 0093 — a soft delete. The skill leaves the library and every agent it was attached to (its skill_agent rows are removed, so the runtime stops injecting it immediately), but the row survives with status: "deleted" and a deleted_at stamp.
It survives because every ai_agent_version references its skills by id: hard-deleting used to leave the version viewer unable to name what the agent knew, and made the next publish of a draft restored from that version fail on the foreign key. Restoring such a version now un-deletes the skill instead.
204 on success, 404 for an id that does not exist in this workspace. Deleting an already-deleted skill is a 204 no-op. Fires skill.deleted.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X DELETE https://api.vitrinadev.com/api/v1/skills/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /skills/{id}
Read one skill (base content + per-channel adjustments map)
The full row: base content and the channel_overrides map.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/skills/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"id": "a4a4a4a4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Agendar hora",
"description": "Cómo ofrecer y confirmar horarios disponibles.",
"content": "Cuando el cliente quiera agendar, ofrece los horarios disponibles y confirma antes de reservar.",
"status": "active",
"channel_overrides": {},
"created_by": "11111111-0000-4000-8000-000000000001",
"created_at": "2026-09-04T19:10:00.000Z",
"updated_at": "2026-09-04T19:10:00.000Z",
"deleted_at": null
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /skills/{id}
Update a skill; channel_overrides replaces the whole map when present, omit to leave adjustments untouched
Content-only edit — applies instantly to every agent the skill is attached to; there is no draft/publish step for a skill the way there is for an agent. channel_overrides, when present, REPLACES the whole map rather than merging into it. Fires skill.updated.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | — | mín. 1, máx. 120 |
description | string | null | — | máx. 2000 |
content | string | — | máx. 20000 |
channel_overrides | object | — | |
status | active \ | inactive | — |
curl -X PUT https://api.vitrinadev.com/api/v1/skills/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "Cuando el cliente quiera agendar, ofrece SOLO horarios de la sucursal más cercana y confirma antes de reservar."
}'Example response (200)
{
"data": {
"id": "a4a4a4a4-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"name": "Agendar hora",
"description": "Cómo ofrecer y confirmar horarios disponibles.",
"content": "Cuando el cliente quiera agendar, ofrece SOLO horarios de la sucursal más cercana y confirma antes de reservar.",
"status": "active",
"channel_overrides": {},
"created_by": "11111111-0000-4000-8000-000000000001",
"created_at": "2026-09-04T19:10:00.000Z",
"updated_at": "2026-09-04T19:10:00.000Z",
"deleted_at": null
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
Reference
Every endpoint this API publishes, generated from the same OpenAPI document the backend serves.
Ads
Spend, ROAS and attributed revenue by campaign, creative ranking and attribution health, plus the single tag: the snippet you paste (or its Google Tag Manager installer) and whether it is already sending visits. Reads from Atribu with the workspace's delegated key.