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 terminal

El 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

  1. 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.
  2. 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.
  3. 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.

El evento
{
  "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" }
      }
    ]
  }
}
La respuesta
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.

JSON
{
  "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."
    }
  }
}
Terminal
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.

JSON
{
  "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"]
    }
  }
}
Terminal
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.

JSON
{
  "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.

Terminal
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()