Documentation

Interactions

When the turn stops to ask for permission: approving tools, answering plans and questions.

With permission_mode: "manual", a turn stops before running tools and asks you. It is the mode for integrations where a model action has consequences outside: sending an email, creating an event, writing into someone else's system.

Heads up

It only works over the stream: the synchronous send has no channel to answer, which is why it rejects manual with 409 instead of leaving you waiting for something that never arrives.

The cycle

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

Step 4 goes over a separate HTTP connection, in parallel with the still-open stream. Do not close the stream to answer: the turn would keep running anyway, but you would miss the remaining events and would have to reconnect with the same Idempotency-Key to see the terminal.

Three rules

  1. An interaction is resolved once. Its state moves to “resolving” before leaving, so two simultaneous requests do not send two decisions. The second gets 409 api_v1_action_not_pending.
  2. The `action_id` belongs to your credential and that chat. All three conditions are checked together and the failure is always the same 404: you cannot infer what exists from the answer.
  3. If the send fails with no answer, the interaction lands in an unknown state and cannot be retried. Applying the same approval twice is worse than not knowing whether it was applied.

Note

Interactions expire: 110 seconds for a tool approval, 290 for the ones waiting on a person (plan, question, connection). Once expired, answering returns 404 and the turn moves on without that decision.

Approving tools

The payload carries the list of tools the model wants to run, with their arguments. You send one decision per tool: rejected ones are simply not executed and the turn continues without them.

The event
{
  "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" }
      }
    ]
  }
}
The answer
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 }
    ]
  }'

Tip

Arguments arrive already de-anonymized when the company has incognito mode enabled: what you see in input is what the tool will actually receive. See Incognito mode.

Approving a plan

The model proposes a sequence of steps before running it. approve lets it proceed; reject discards it and the turn answers without executing it. No special mode is needed: it arrives in manual or auto when the model decides to plan before acting. Asking for it in the message makes it likelier but does not guarantee it — the decision is the model's —, so handle this interaction when it arrives instead of counting on triggering it.

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" }'

Answering a question

This is MCP protocol elicitation: the turn stops because something needs a value to continue. It is raised by a connector, or by the native ask_user_choice tool, which the model uses to make you pick between options. payload.requested_schema is a JSON Schema and content must be a flat object satisfying it. decline proceeds without an answer and cancel aborts the question.

Triggering it on purpose needs a condition that is not obvious: `ask_user_choice` is only offered to the model when the turn carries at least one connector in `mcp_server_ids` (connectors:use scope). Without a connector the tool does not exist for the model and no instruction will produce it — the turn ends in message.completed with the question written as text. With a connector attached and a request that demands a choice ("decide between A and B and ask me which"), the model also gets the directive to use the tool instead of asking inline. It is still its call, so it is likely, not guaranteed.

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" }
  }'

Unconnected connector

The model tried to use a tool from a connector the user has not authorized yet. From the API you can only cancel: completing an OAuth flow requires a browser and a person.

It is the only interaction you can trigger on purpose, which makes it the right one for testing your client's action handling: attach with mcp_server_ids a connector the user has not connected in the application (the connectors:use scope is required) and ask the model for something only a tool from that connector can resolve — "search my Gmail for the latest billing email". The turn stops with this event instead of answering.

JSON
{
  "kind": "auth_required",
  "action_id": "act_7b1e5f30ca84",
  "resolution": "connect_in_ui_or_cancel",
  "payload": { "provider": "gmail", "display_name": "Gmail" }
}

Cancelling unblocks the turn: the model continues without that tool. The alternative is telling the user to connect the service in the application, and retrying the turn afterwards.

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" }'

Abandoning the turn

If instead of answering you want to stop everything, POST /chats/{chat_id}/actions/cancel with no body cancels the chat's live turn. It is idempotent: cancelling one that already finished also answers 200.

Closing the stream cancels nothing: the turn runs to its terminal and you will find it by reconnecting with the same Idempotency-Key. Cancelling is the only way to abort it.

A complete handler

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