Documentación
Interacciones
Cuando el turno se detiene a pedir permiso: aprobar herramientas, responder planes y preguntas.
Con permission_mode: "manual", un turno se detiene antes de ejecutar herramientas y te pregunta. Es el modo para integraciones donde una acción del modelo tiene consecuencias afuera: mandar un correo, crear un evento, escribir en un sistema ajeno.
Atención
Solo funciona por el stream: el envío sincrónico no tiene canal para contestar, y por eso rechaza manual con 409 en vez de dejarte esperando algo que nunca llega.
El ciclo
1. Enviás el turno con permission_mode: "manual"
2. Llega event: message.action_required { kind, action_id, payload }
3. Decidís (vos, o una persona a la que le mostrás el payload)
4. POST /chats/{chat_id}/actions/<kind> { action_id, … }
5. El stream sigue: más deltas, quizá otra interacción, y al final el terminalEl paso 4 va por otra conexión HTTP, en paralelo al stream que sigue abierto. No cierres el stream para responder: el turno seguiría corriendo igual, pero te perderías los eventos que faltan y tendrías que reconectar con la misma Idempotency-Key para ver el terminal.
Tres reglas
- Una interacción se resuelve una sola vez. El estado pasa a «resolviendo» antes de salir, así que dos requests simultáneos no mandan dos decisiones. El segundo recibe
409 api_v1_action_not_pending. - El `action_id` es de tu credencial y de ese chat. Las tres condiciones se comprueban juntas y el fallo es siempre el mismo
404: no se puede deducir qué existe a partir de la respuesta. - Si el envío falla sin respuesta, la interacción queda en estado desconocido y no se puede reintentar. Aplicar dos veces la misma aprobación es peor que no saber si se aplicó.
Nota
Las interacciones vencen: 110 segundos para una aprobación de herramienta, 290 para las que esperan a una persona (plan, pregunta, conexión). Vencida, responder devuelve 404 y el turno sigue su curso sin esa decisión.
Aprobar herramientas
El payload trae la lista de herramientas que el modelo quiere ejecutar, con sus argumentos. Mandás una decisión por cada una: las rechazadas simplemente no se ejecutan y el turno sigue sin ellas.
{
"kind": "tool_approval",
"action_id": "act_9c2f1e7a4b6d",
"payload": {
"tools": [
{
"tool_use_id": "toolu_01A9f",
"name": "gmail_send",
"server": "gmail",
"input": { "to": "cliente@example.com", "subject": "Pedido 8842" }
},
{
"tool_use_id": "toolu_01B3k",
"name": "calendar_create_event",
"server": "google-calendar",
"input": { "summary": "Llamada de seguimiento" }
}
]
}
}curl -sS -X POST "https://api.niucore.com/api/v1/chats/$CHAT_ID/actions/approve-tool" \
-H "Authorization: Bearer $NIUCORE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"action_id": "act_9c2f1e7a4b6d",
"decisions": [
{ "tool_use_id": "toolu_01A9f", "approved": true },
{ "tool_use_id": "toolu_01B3k", "approved": false }
]
}'Sugerencia
Los argumentos vienen ya desanonimizados cuando la empresa tiene el modo incógnito activo: lo que ves en input es lo que la herramienta va a recibir de verdad. Ver Modo incógnito.
Aprobar un plan
El modelo propone una secuencia de pasos antes de ejecutarla. approve la deja seguir; reject la descarta y el turno responde sin ejecutarla. No hace falta un modo especial: llega en manual o en auto cuando el modelo decide planificar antes de actuar. Pedirlo en el mensaje lo hace más probable pero no lo garantiza — la decisión es del modelo —, así que manejá esta interacción cuando llegue en vez de contar con provocarla.
{
"kind": "plan_approval",
"action_id": "act_4d8b2c0f9e11",
"payload": {
"plan": {
"title": "Responder el reclamo del pedido 8842",
"context": "El cliente pregunta por una demora y pide compensación.",
"steps": [
{ "step": 1, "action": "Buscar el pedido 8842", "tool": "search_documents" },
{ "step": 2, "action": "Redactar la respuesta al cliente" },
{ "step": 3, "action": "Enviarla por correo", "tool": "gmail_send" }
],
"notes": "Confirmar la dirección antes de enviar."
}
}
}curl -sS -X POST "https://api.niucore.com/api/v1/chats/$CHAT_ID/actions/respond-plan" \
-H "Authorization: Bearer $NIUCORE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "action_id": "act_4d8b2c0f9e11", "action": "approve" }'Responder una pregunta
Es la elicitation del protocolo MCP: el turno se detiene porque alguien necesita un dato para seguir. La levanta un conector, o la herramienta nativa ask_user_choice, que el modelo usa para hacerte elegir entre opciones. payload.requested_schema es un JSON Schema y content tiene que ser un objeto plano que lo cumpla. decline sigue sin la respuesta y cancel aborta la pregunta.
Para provocarla a propósito hace falta una condición que no es obvia: `ask_user_choice` solo se le ofrece al modelo cuando el turno lleva al menos un conector en `mcp_server_ids` (scope connectors:use). Sin conector, la herramienta no existe para el modelo y ninguna instrucción la va a producir — el turno termina en message.completed con la pregunta escrita en texto. Con un conector adjunto y un pedido que exija elegir («decidí vos entre A y B y preguntame cuál»), el modelo recibe además la directiva de usar la herramienta en vez de preguntar inline. Sigue siendo su decisión, así que es probable, no garantizado.
{
"kind": "elicitation",
"action_id": "act_11ff03ac7d52",
"payload": {
"message": "¿A qué dirección mando la compensación?",
"mode": "form",
"requested_schema": {
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"compensacion": { "type": "string", "enum": ["reembolso_total", "cupon"] }
},
"required": ["email", "compensacion"]
}
}
}curl -sS -X POST "https://api.niucore.com/api/v1/chats/$CHAT_ID/actions/respond-elicitation" \
-H "Authorization: Bearer $NIUCORE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"action_id": "act_11ff03ac7d52",
"action": "accept",
"content": { "email": "cliente@example.com", "compensacion": "reembolso_total" }
}'Conector sin conectar
El modelo quiso usar una herramienta de un conector que el usuario todavía no autorizó. Desde la API solo se puede cancelar: completar un OAuth exige un navegador y una persona.
Es la única interacción que se puede provocar a propósito, y por eso sirve para probar el manejo de acciones de tu cliente: adjuntá con mcp_server_ids un conector que el usuario no haya conectado en la aplicación (hace falta el scope connectors:use) y pedile al modelo algo que solo se resuelva con una herramienta de ese conector — «buscá en mi Gmail el último correo de facturación». El turno se detiene con este evento en vez de responder.
{
"kind": "auth_required",
"action_id": "act_7b1e5f30ca84",
"resolution": "connect_in_ui_or_cancel",
"payload": { "provider": "gmail", "display_name": "Gmail" }
}Cancelar desbloquea el turno: el modelo sigue sin esa herramienta. La alternativa es avisarle al usuario para que conecte el servicio en la aplicación, y reintentar el turno después.
curl -sS -X POST "https://api.niucore.com/api/v1/chats/$CHAT_ID/actions/cancel-auth" \
-H "Authorization: Bearer $NIUCORE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "action_id": "act_7b1e5f30ca84" }'Abandonar el turno
Si en vez de responder querés cortar todo, POST /chats/{chat_id}/actions/cancel sin cuerpo cancela el turno vivo del chat. Es idempotente: cancelar uno que ya terminó también responde 200.
Cerrar el stream no cancela nada: el turno sigue hasta su terminal y lo vas a encontrar reconectando con la misma Idempotency-Key. Cancelar es la única forma de abortarlo.
Un manejador completo
import requests
ROUTES = {
"tool_approval": "approve-tool",
"plan_approval": "respond-plan",
"elicitation": "respond-elicitation",
"auth_required": "cancel-auth",
}
def resolve(chat_id: str, payload: dict, token: str, *, decide) -> None:
"""Contesta una interacción. `decide` implementa tu política."""
kind = payload["kind"]
body = {"action_id": payload["action_id"]}
if kind == "tool_approval":
body["decisions"] = [
{"tool_use_id": tool["tool_use_id"], "approved": decide(tool)}
for tool in payload["payload"]["tools"]
]
elif kind == "plan_approval":
body["action"] = "approve" if decide(payload) else "reject"
elif kind == "elicitation":
body["action"] = "accept"
body["content"] = decide(payload)
# auth_required solo admite cancelar: el cuerpo ya está completo.
response = requests.post(
f"https://api.niucore.com/api/v1/chats/{chat_id}/actions/{ROUTES[kind]}",
headers={"Authorization": f"Bearer {token}"},
json=body,
timeout=30,
)
if response.status_code == 409:
# Ya la resolvió otro worker, o venció. No se reintenta nunca.
return
response.raise_for_status()