Ctrl K
ComunidadGuías…

docs/channels.es.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
---
source_sha: "026fa67fb1f0"
---

# Poner un agent donde la gente ya está { #putting-an-agent-where-people-already-are }

Un agent que solo responde dentro de este dashboard es una demo. El mismo agent
publicado responde en ocho sitios, y todos ellos ejecutan la *misma versión
congelada* con el mismo budget, la misma puerta de aprobación y las mismas
comprobaciones de tenant — cambia la superficie, no el agent.

| Dónde | Qué necesita | Quién es el visitante |
|---|---|---|
| **Dashboard** | nada | un miembro con la sesión iniciada |
| **Widget de sitio web** | una etiqueta `<script>` | anónimo, o un usuario por el que responde tu backend |
| **WebSocket en crudo** | una clave de embed | lo que diga tu integración |
| **Una página alojada** | un enlace | anónimo, y nada más |
| **La API** | una sesión | quien tenga la credencial |
| **Slack** | un token de bot | una cuenta de Slack, opcionalmente vinculada a un miembro |
| **Telegram** | un token de bot | una cuenta de Telegram, opcionalmente vinculada |
| **Mattermost** | un token de bot y la URL de tu servidor | una cuenta de Mattermost, opcionalmente vinculada |

El dashboard también se abre como [aplicación de escritorio](desktop.md): una
ventana alrededor de la misma consola, cargada del mismo servidor, sin nada
empaquetado.

!!! abstract "Tres reglas valen en toda superficie, aplicadas en el runner"

    - **Un run pertenece siempre a exactamente una organización.**
    - **Un límite de gasto se comprueba antes de cada petición al modelo, nunca
      después.**
    - **Un run que falló sigue en el historial con lo que gastó** — los tokens se
      gastaron antes de que se rompiera, y un budget que ignora eso no es un budget.

!!! info "Tres de esas ocho son una sola tabla, y sus filas se distinguen por un `kind`"

    Un widget, un socket en crudo y una página alojada son cada uno un *embed* — y
    el kind queda fijado al crearlo, porque una etiqueta ya pegada, un cliente ya
    escrito y un enlace ya enviado nombran todos la misma fila.

Una clave pública, un cubo de rate limit, un budget, un interruptor de pausa y un
mismo conjunto de rechazos. Lo que cambia es qué hay que configurar y qué admite a
un visitante — por eso el Builder pregunta cuál quieres antes que ninguna otra
cosa, y por eso una página no tiene una lista de orígenes permitidos en lugar de
tener una que se ignora.

Cada run registra la superficie que lo admitió — `web`, `embed`, `api`, `slack`,
`telegram` o `mattermost` — que es lo que agrega el gráfico por superficie del
dashboard. Los tres kinds de embed registran `embed`. Dos arrugas históricas: los
runs de widget registrados antes de que existiera el valor `embed` están guardados
como `web`, y los de Mattermost de esa misma época como `api`. Ninguno se rellena
hacia atrás — reescribir la historia sería adivinar — así que los gráficos de
periodos antiguos pliegan esos runs en la superficie bajo la que se registraron.

**Lo que un desconocido puede hacer, lo puede hacer a un ritmo.**

Las superficies alcanzables sin sesión llevan un límite contado en el Redis del
despliegue, así que vale para todos los workers:

- la API de run, **por llamante**;
- el script del widget y su admisión, **por dirección**, cada uno en su contador;
- la config de una página alojada, **por página** — esa la pide el servidor del
  frontend y no el navegador, así que una dirección ahí nombra un contenedor y
  metería a todos los visitantes del despliegue en un solo cubo.

El script se cuenta aparte de la admisión que lo precede porque una carga de
página gasta ambos, y un único cubo para los dos hacía que el número que fija un
operador significara un tercio de sí mismo.

`RATE_LIMIT_RUN_PER_MINUTE`, `RATE_LIMIT_EMBED_PER_MINUTE` y
`RATE_LIMIT_HOSTED_PAGE_PER_MINUTE` los fijan, y
[configuración](configuration.md#rate-limiting) tiene la única advertencia que
merece leerse antes de producción — detrás de un proxy, todos los visitantes
llegan como el proxy salvo que digas lo contrario.

Lo que raciona el *gasto* en una página alojada es el socket que abre la página, y
ese se cuenta por dirección como el del widget.

---


!!! tip "¿Integrar en lugar de configurar?"

    [La API HTTP](api.md) cubre la autenticación, la cabecera de organización,
    ejecutar un agent por HTTP, los dos endpoints WebSocket y el sobre de
    errores.

## El widget de sitio web { #the-website-widget }

El camino más corto. Publica el agent, crea un embed, pega dos líneas.

### 1. Crear el embed { #1-create-the-embed }

En el Builder, abre el agent → **Availability** → *Website widget*. Eliges:

- **Allowed sites** — los sitios desde los que se puede abrir este widget.
  **Una lista vacía no permite nada**, así que publicar sin ella se rechaza en vez
  de producir un widget que no responde en ninguna parte. La clave de la etiqueta
  de script es pública por construcción, así que la lista de orígenes es lo que
  realmente impide que otro ejecute tu agent a tu cuenta. La misma regla vale para
  un socket, cuyo handshake se comprueba contra esa misma lista.
- **Auth** — `public` (visitantes anónimos) o `jwt` (tu backend responde por cada
  visitante; ver más abajo).
- **Aspecto** — la cabecera y la línea de debajo, el saludo, lo que dice la caja
  vacía, lo que dice el botón lanzador, el color de acento y en qué esquina se
  coloca. Los siete, y el saludo lo dibuja el widget en vez de enviarlo al modelo:
  un saludo en el historial del modelo es un turno que el agent cree haber dado.
- **Mensajes sugeridos** — hasta cuatro primeros mensajes como botones bajo el
  saludo; un clic envía uno como el visitante.
- **Fuentes** — desactivadas por defecto. Activadas, una respuesta apoyada en la
  base de conocimiento nombra esos documentos debajo: solo los nombres de archivo,
  nunca los pasajes, en un frame `sources` propio.
- **Contexto** — una nota añadida al primer mensaje del visitante: *«Estás en la
  página de precios»*, *«Responde en alemán»*. Nunca sustituye a las instrucciones
  propias del agent, que pertenecen a la versión publicada.
- **Rate limit** — mensajes por visitante y minuto.
- **Entrada de voz** — `off`, `browser` o `server`, que decide adónde va la voz
  de un visitante. Ver
  [dictado en un widget o en una página alojada](#dictation-on-a-widget-or-a-hosted-page).

!!! danger "Una lista de orígenes vacía no permite nada, y es a propósito"

    La clave de la etiqueta de script es pública por construcción, así que la
    lista de orígenes es lo único que impide que otro ejecute tu agent a tu
    cuenta. Publicar sin ella se rechaza en vez de producir un widget que no
    responde en ninguna parte.

### 2. Pegar el snippet { #2-paste-the-snippet }

```html
<script src="https://your-api.example.com/api/v1/embed/PUBLIC_KEY/widget.js" async></script>
```

Esa es toda la integración. El script no tiene dependencias, ni paso de build, ni
framework — funciona en una página que ya carga React, jQuery o nada en absoluto.

### 3. (Opcional) cuéntale cosas del visitante { #3-optional-tell-it-about-the-visitor }

Un widget puede **declarar variables** - un nombre, si es obligatoria y una línea
que diga para qué sirve - y la página las suministra:

```html
<script>window.AgenticOSContext = { plan: "pro", locale: "pl" };</script>
<script src="https://your-api.example.com/api/v1/embed/PUBLIC_KEY/widget.js" async></script>
```

El snippet que te da el Builder ya lleva esa línea, con tus propias claves
dentro, en cuanto hayas declarado alguna.

Se añaden a las instrucciones del agent como un bloque de datos marcado, bajo una
línea que dice que son información sobre el visitante y no instrucciones, y que no
se pueden verificar. Esto último vale para todas ellas, incluso en un widget
`jwt`: el widget lee `window.AgenticOSContext`, y un token autentica *quién es el
visitante*, no *qué dijo la página sobre él*. Así que nada de aquí puede decidir
qué le está permitido hacer al agent.

De ahí salen tres reglas:

- **Una clave que nadie declaró se descarta.** La página es algo que un visitante
  puede editar; sin una declaración, cualquier clave que se inventara acabaría
  siendo una línea dentro de las instrucciones de un agent.
- **Un valor obligatorio que falta omite su línea y se registra.** `required` es
  una promesa de un integrador consigo mismo - imponerla le costaría a un
  visitante su respuesta por un error de despliegue de otra persona.
- **Se envían una vez por conversación**, antes de la primera pregunta, y se leen
  de cada frame en lugar de en el momento de conectar: una aplicación de una sola
  página se entera de quién es alguien sin reconectar.

### 4. (Opcional) dile quién es el visitante { #4-optional-tell-it-who-the-visitor-is }

Para un widget dentro de tu propio producto con sesión iniciada, fija un token
**antes** de que cargue el script. Tu backend lo firma; nosotros lo verificamos y
nunca vemos tu base de datos de usuarios:

```html
<script>window.AgenticOSToken = "<%= agenticos_token_for(current_user) %>";</script>
<script src="https://your-api.example.com/api/v1/embed/PUBLIC_KEY/widget.js" async></script>
```

Acuñar uno, en cualquier lenguaje capaz de firmar un JWT:

```python
import time, jwt   # PyJWT

token = jwt.encode(
    {"sub": str(user.id), "iat": int(time.time())},
    EMBED_SIGNING_SECRET,          # the secret you set on the embed
    algorithm="HS256",
)
```

- `sub` es obligatorio. Identifica al visitante a efectos de rate limiting, y un
  token sin él se rechaza — si no, un único token filtrado se convierte en el
  budget entero del widget.
- **`iat` es obligatorio y tiene que caer dentro de las últimas 12 horas** — no se
  comprueba solo cuando está presente. Un token sin `iat`, o con uno caducado, se
  rechaza, de modo que uno que se escape de un navegador no puede funcionar para
  siempre. Un `exp` que tú fijes también se respeta, pero solo para **acortar** esa
  ventana (un token expirado se rechaza); no puede prolongar un token más allá del
  techo de 12 horas.
- Acúñalo por carga de página, en el servidor.

!!! danger "Nunca entregues el secreto de firma a un navegador"

    Firma a *cualquier* visitante. El token que acuña es lo que el navegador tiene
    permitido guardar, y solo durante las doce horas que le da `iat`.

---

## El WebSocket en crudo { #the-raw-websocket }

El widget es un cliente de un protocolo documentado, no una caja negra. Si quieres
tu propia interfaz — una aplicación móvil, un kiosco, un componente de tu sistema
de diseño — habla con el mismo socket:

```
wss://your-api.example.com/api/v1/embed/PUBLIC_KEY/ws[?token=SIGNED_JWT]
```

**No tienes que montar eso tú mismo.** Publica uno desde el Builder — el agent →
**Availability** → *Raw WebSocket* — y su fila imprime la URL, construida a partir
de la URL base del propio despliegue, con un botón de copiar. Un **widget** imprime
lo mismo junto a su etiqueta de script, porque un widget es un cliente de este
protocolo: pasar a una interfaz propia es un paso, no una reescritura. El
`?token=` no se imprime ahí: en modo `jwt` el token lo acuña tu backend para cada
visitante, y uno real en una pantalla del dashboard es una credencial que funciona
y que alguien puede leer por encima del hombro.

!!! bug "Lo primero que sale mal: un `Origin` que falta"

    El handshake tiene que llevar uno que esté en la lista de permitidos del embed.
    **Un navegador lo envía por ti; un cliente propio no envía nada salvo que lo
    fijes** - una aplicación móvil, un kiosco, un relé del lado del servidor. Lo
    que se ve cuando no lo hace es `4003` en la tabla de abajo, no un mensaje de
    error.

```mermaid
sequenceDiagram
    participant C as Your client
    participant E as /embed/{key}/ws
    participant R as run_stream
    C->>E: handshake (Origin, optional ?token=)
    alt origin not allowed, token bad, embed paused
        E-->>C: close 4003 - do not retry
    else admitted
        E-->>C: ready { visitor }
        C->>E: message { text }
        E->>R: the same loop /chat drives
        R-->>C: model_request_start
        R-->>C: text_delta … (a word at a time)
        R-->>C: final_result, then complete
    end
```

**Frames que envías**

```json
{ "type": "message", "text": "Do you ship to Poland?" }
```

Ese es todo el vocabulario de entrada, más `context` (lo que la página dice del
visitante) y `file_ids` (lo que adjuntó, en una página que acepta ficheros).

Cada frame `message` cuenta para un límite por conexión, aunque esté vacío o
sea inválido. Los mensajes válidos consumen además la cuota existente del
visitante; las entradas inválidas no agotan la cuota compartida con otros
visitantes anónimos. Un frame admite como máximo tres identificadores de archivo;
las listas mayores se rechazan antes de analizarlas. Los identificadores inválidos
de una lista acotada se descartan y generan un único log agregado sin sus valores.

**Deliberadamente no incluye los tres campos que sí lleva el frame propio del
dashboard:** el agent, el perfil de modelo y el entorno.

Un frame que pudiera elegir modelo es un visitante eligiéndolo a cuenta del
operador, y uno que pudiera elegir agent es un visitante hablando con algo que
nadie publicó en esta clave. Los tres salen de la fila del embed.

Un campo desconocido se **ignora en vez de rechazarse**. Un cliente cacheado en el
navegador de alguien puede ser más antiguo que este servidor, y cerrar el socket
por ello se llevaría la conversación por delante.

**Frames que recibes**

!!! note "Este es el vocabulario de frames del propio dashboard, no un segundo vocabulario"

    `/chat` y este socket mueven un solo bucle (`app/services/run_stream.py`), así
    que aquí una respuesta llega palabra a palabra igual que allí. Una página
    alojada mostraba antes un bloque de texto de golpe tras treinta segundos de
    nada, y eso era el bucle, no el transporte.

Cada frame lleva `{ "type": …, "data": { … } }`.

| `type` | `data` | Significado |
|---|---|---|
| `ready` | `visitor` | Conectado. `visitor: true` cuando un token identificó a la persona. |
| `history` | `messages` | Lo que se dijo en el hilo que este visitante retoma — **una página alojada con visitante anónimo**, la única conexión que lleva una clave de continuidad: la conversación de un widget dura lo que su socket, y a un visitante `jwt` ya lo nombra su token. Cada entrada es `role`, `text` y `at`, así que un turno reproducido conserva la hora debajo. |
| `model_request_start` | — | El agent ha ido al modelo. Muestra un indicador. |
| `part_start` | `index`, `part_type`; `tool_name`, `tool_call_id` en un `ToolCallPart` | Empieza un bloque de la respuesta. Una llamada a una herramienta se nombra aquí, para que un cliente pueda mostrarla mientras llegan sus argumentos. Se envía solo para un bloque que esta superficie vaya a llevar de verdad — una página que no muestra razonamiento no anuncia un `ThinkingPart`, porque el anuncio por sí solo ya dice que el agent razonó. |
| `text_delta` | `index`, `content` | Palabras de la respuesta. Añádelas. |
| `thinking_delta` | `index`, `content` | El razonamiento del modelo. **Solo si el operador lo activó.** |
| `call_tools_start` | — | El agent va a usar herramientas. |
| `tool_call` | `tool_call_id`, `tool_name`, `args` | Un paso. `args` solo cuando el operador muestra los resultados. |
| `tool_call_delta` | `index`, `args_delta` | Los argumentos de una llamada según llegan. |
| `tool_result` | `tool_call_id`, `content` | Lo que devolvió el paso. |
| `sources` | `names` | Los documentos que devolvió una búsqueda en la base de conocimiento, por nombre. **Solo en un widget cuyo operador activó las fuentes**, y nunca los pasajes. |
| `final_result_start` | `tool_name` | La respuesta la está produciendo una output tool. |
| `final_result` | `output` | Con qué terminó el run. Vacío en un turno que quedó aparcado. |
| `complete` | — | El turno ha terminado. **No lleva uso**: lo que costó un run es asunto del operador, no del visitante. |
| `error` | `message` | Algo que el visitante debe ver: rate limit, budget alcanzado, un rechazo, un turno que no produjo nada. |
| `compaction_started`, `compaction_finished`, `compaction_impossible` | según el frame | El agente ordena sus propias notas porque el hilo desbordó la ventana del modelo. Se envía siempre, sean cuales sean los interruptores del operador: un resumen tarda decenas de segundos, y una superficie que hace streaming y no dice nada simplemente se detiene ese rato ([#936](https://github.com/vstorm-co/agenticos/issues/936)). |

Algunos frames del dashboard nunca llegan a un socket público, y son rechazos, no
ajustes. **`user_prompt_processed`** lleva el prompt *tal como se ensambló* — la
nota de colocación y el bloque suministrado por encima de lo que el visitante
escribió — que es texto del operador y no del visitante para releerlo.

**`ask_user` y `tool_approval_required` no tienen aquí a nadie que los conteste,
pero fallan de manera distinta.** Un visitante no puede aprobar un efecto
secundario sobre la organización de otro, así que `tool_approval_required`
**aparca** el run exactamente igual que en un canal, y el turno termina con un
`error` que dice que tiene que decidir una persona — a diferencia de un canal, sin
el enlace a `/runs`, porque allí quien lee es un miembro que puede abrirlo y aquí
es un desconocido con un enlace. `ask_user` **no** aparca: `AgentDeps.ask_user` es
`None` en esta superficie, así que la herramienta *rechaza* cuando el modelo la
llama (`app/agents/ask_user.py`) y el modelo sigue adelante y responde sin esa
entrada — una respuesta degradada, no un run aparcado.

**Un cliente ignora lo que no dibuja**, y `widget.js` es el ejemplo resuelto: lee
`model_request_start`, `text_delta`, `final_result`, `complete` y `error` para la
respuesta, `tool_call` y `tool_result` para mostrar cada paso como una etiqueta
(*Buscando en la base de conocimiento*), y `sources` para los documentos que
nombró una respuesta. Ignora el razonamiento a propósito. Lleva la cara del agent,
convierte la respuesta de Markdown en nodos y no en HTML, sigue el tema claro u
oscuro del visitante y ocupa toda la pantalla de un teléfono.

**Códigos de cierre**

| Código | Significado |
|---|---|
| `4003` | Rechazado. El origen no está permitido, el token falló, o el widget está en pausa. No reintentes — la respuesta no va a cambiar. |
| `4029` | Demasiadas conexiones desde esta dirección en el último minuto. Espera y reintenta. |
| `1011` | Este cliente no estaba leyendo. Un frame tardó más de 30 segundos en llegarle, así que el servidor dejó de escribir en vez de mantener la sesión de base de datos del turno y el stream abierto del provider una vez por frame. Reconecta; una página alojada retoma su hilo. |

El rechazo es deliberadamente un único código con un único mensaje. Una página que
no está en la lista de permitidos se entera de que no está permitida y de nada más
sobre si un token habría ayudado.

`4029` está separado por la razón contraria: «no permitido» y «permitido pero
demasiado rápido» le piden a un cliente cosas opuestas — parar para siempre, y
volver a intentarlo luego — así que un cliente que no sabe distinguirlos o bien
machaca un rechazo o bien abandona ante un límite. Cuántas conexiones tiene una
dirección lo fija `RATE_LIMIT_EMBED_PER_MINUTE`; cuántos *mensajes* tiene un
visitante una vez conectado es el rate limit propio del widget, fijado en el
Builder.

Un cliente mínimo:

```js
const socket = new WebSocket(`${BASE}/api/v1/embed/${KEY}/ws`);
let answer = "";
socket.onmessage = (event) => {
  const { type, data } = JSON.parse(event.data);
  if (type === "text_delta") render((answer += data.content));
  if (type === "final_result" && data.output) render((answer = data.output));
  if (type === "complete") answer = "";
  if (type === "error") render(data.message);
};
socket.send(JSON.stringify({ type: "message", text: "hello" }));
```

`final_result` se asigna en vez de añadirse: es aquello con lo que *terminó* el
run, y un provider que no emitió deltas lo deja como la única copia de la
respuesta.

---

## Qué ofrece cada superficie, y por qué las diferencias son diferencias { #what-each-surface-offers-and-why-the-differences-are-differences }

Tres superficies ejecutan el mismo agente a través del mismo runner: el chat del
panel, este socket y la API pública. **No** ofrecen la misma ejecución, y hasta
[#936](https://github.com/vstorm-co/agenticos/issues/936) nada decía cuáles de
esas diferencias eran decisiones.

Ahora sí. Cada «no» de abajo tiene un motivo, y un motivo es o bien «aquí esto
estaría mal» o bien «esto todavía no está construido» — nunca silencio.

La última columna es Teams (#1985), en representación de las plataformas de chat.

| | `/chat` (panel) | WebSocket en crudo | La API pública | Teams |
|---|---|---|---|---|
| Streaming | sí | sí, filtrado por lo que el operador muestra | **no** — el POST es la vía sin streaming; una variante SSE es otra cuestión | un mensaje reescrito a medida que llega la respuesta |
| Adjuntos | sí | sí (`file_ids`) **en una página alojada** — el endpoint de subida resuelve la clave a través de `find_page`, así que un widget o un socket en crudo no tienen ruta que produzca un id | sí (`file_ids`), y siguen el mismo camino que en todas partes | sí, desde un chat personal; un archivo vuelve a través de la tarjeta de consentimiento de Teams |
| Continuidad de la conversación | sí | sí (`continuity_key`), en una página alojada con visitante anónimo | sí (`conversation_id`) | un chat, o un hilo de canal |
| `environment_id` | sí | **no, deliberadamente** — véase abajo | sí | **no** — lo decide el binding, como en toda plataforma de chat |
| Anulación del modelo | sí | **no, deliberadamente**: una superficie pública no puede dejar que su visitante elija lo que gasta | **no**: la petición no lleva `model_profile_id`. Qué versión se ejecuta sigue siendo del que llama, por `environment_id`, y un entorno puede fijar una que nunca fue la predeterminada | **no**, como en toda plataforma de chat |
| `ask_user` | sí | **no, todavía no** — véase abajo | **no, con razón**: nadie espera colgado de una petición HTTP para responder una pregunta | sí, como una Adaptive Card |
| Aviso de compactación | sí | sí | n/a — no hay nada en streaming a quien decírselo | no — la respuesta llega cuando está lista |
| Frames de delegación | sí | **no, deliberadamente**: enganchar un sink hace que la biblioteca abra una petición *en streaming* por cada hijo, así que un delegado cuyo proveedor no sabe hacer streaming se rompe en cuanto alguien mira | n/a — no hay nada en streaming donde dibujarlos | **no** — la respuesta llega cuando está lista, sin dibujar los pasos |
| Aprobaciones: aparcan la ejecución | sí | sí | sí | sí |
| Aprobaciones: un camino hasta la decisión | sí (`/runs`) | una frase, deliberadamente: a un desconocido con un enlace no se le enseña una URL a la consola de otra persona | sí — el resultado lleva `parked`, que nombra qué espera y a qué aprobación enviar la decisión | sí — una Adaptive Card, decidida como el miembro que la pulsa |
| Menciones `@` | sí (`mentions`) | **no, todavía no**: un widget no tiene menú del que elegir, y la mención de un visitante necesitaría la misma comprobación en su nombre | **no, todavía no**: un script nombra qué usar en el propio mensaje | **no**, como en toda plataforma de chat |

### `environment_id` en el socket, y por qué es de quien publica { #environment_id-on-the-socket-and-why-it-is-the-publishers }

La API lo tiene porque quien llama es la organización: probar un entorno de
desarrollo antes de promocionarlo es su propio trabajo sobre su propio agente. Quien
llama a un socket es quien tiene la clave del embed, y en un widget eso es el
navegador de un desconocido. Un frame que eligiera el entorno dejaría al visitante
escoger qué *versión* del agente le responde — incluida una que la organización no ha
promocionado, que es lo contrario de lo que significa publicar una versión.

Así que se rechaza con esa forma. Si una integración de primera parte necesita
ejecutar un entorno con nombre, su sitio es la configuración del propio embed, donde
quien publica elige una vez y todos los visitantes reciben la misma respuesta.

### `ask_user` en el socket, y qué haría falta { #ask_user-on-the-socket-and-what-it-would-need }

El docstring del módulo `ask_user` dice que la pausa y la reanudación viven «en la
sesión WebSocket», y se refiere a la sesión del *chat*. Este socket está igual de
vivo, con una persona igualmente sentada delante — así que a un delegado que
preguntaría se le dice que no se ha podido localizar a nadie, que es la respuesta
correcta para una ejecución programada y la equivocada aquí.

No se cierra aquí porque la mitad que falta no es del servidor. El vocabulario de
frames no tiene `ask_user` ni `ask_user_response`, y el widget no tiene formulario
para dibujar uno — así que enganchar el callback aparcaría ejecuciones en una
pregunta que nada puede responder, y eso es peor que rechazarlo. Qué hace falta: los
dos frames, el formulario del widget y una decisión sobre la *página alojada*, donde
a quien se pregunta es un desconocido y la pregunta puede llevar cualquier cosa que
el agente haya decidido decir.

### Aprobaciones en el socket: decididas, y deliberadamente sin ampliar { #approvals-on-the-socket-decided-and-deliberately-not-widened }

Una ejecución aparcada responde al visitante con «Eso necesita que alguien lo apruebe
antes de poder ejecutarse», y así se queda. El socket en crudo se vende como una
interfaz que construyes tú — una app móvil, un kiosco — y a un cliente de primera
parte con un usuario autenticado se le podría en principio entregar la llamada
aparcada y una forma de decidirla.

No se hace, porque la clave del embed no identifica a un *miembro*: identifica la
superficie publicada de un despliegue, y la persona del otro lado puede ser
cualquiera a quien la integración haya dejado entrar. Decidir una aprobación es un
acto de miembro, protegido por `approvals:decide`, y pertenece a una superficie que
sabe quién pregunta.

## Una página alojada { #a-hosted-page }

La integración más corta que hay: **envíale un enlace a alguien.** Ningún sitio
propio, ninguna etiqueta `<script>`, ningún cliente que escribir, ningún inicio de
sesión.

En el Builder, abre el agent → **Availability** → *Hosted page*. No hay ningún
sitio que nombrar ni nada que pegar — el formulario pide un título, una
bienvenida, un acento y un logo, todo opcional, y publica:

```
https://your-app.example.com/e/PUBLIC_KEY
```

Es **un embed como los otros dos**: el mismo tipo de clave, el mismo rate limit,
el mismo budget y el mismo interruptor de pausa. Ponerlo en pausa detiene la
página al instante, y con ella todos los enlaces ya enviados.

### Qué la protege { #what-protects-it }

Di esta parte en voz alta antes de publicar una, porque es todo el modelo de
seguridad:

> **Un enlace alojado en modo `public` está protegido por que la clave sea
> imposible de adivinar, más el rate limit del embed, su budget y su interruptor
> de pausa. Nada más.**

Quien tenga el enlace puede hablar con el agent. Ese es el sentido de un enlace, y
por eso la clave son 24 bytes aleatorios y no algo legible.

Aquí no hay lista de orígenes permitidos, deliberadamente, y el formulario no
ofrece ninguna: una lista de permitidos es una regla sobre los sitios *de otras
personas*, y esta página la servimos nosotros. Una página se admite desde el
origen propio del despliegue — derivado de `FRONTEND_URL`, nunca escrito a fuego —
y de ningún otro. Una restricción `CHECK` rechaza una página que lleve lista
siquiera, porque una lista guardada se lee como si fuera lo que protege el enlace,
y no lo es.

### Dos cosas que una página alojada rechaza { #two-things-a-hosted-page-refuses }

Ambas se rechazan al publicar, con un mensaje, en lugar de caer en silencio a un
widget:

- **Una página no puede usar el modo `jwt`**, y el formulario no lo ofrece. El
  token tendría que viajar en la URL, y por tanto al historial del navegador, a
  las cabeceras `Referer` y a todos los clientes de chat donde se pegue el enlace
  — y el truco del fragmento que evita parte de eso acaba con que el enlace sea
  «lo envías y funciona». Usa un widget o un socket para una integración por
  usuario; ahí `jwt` no se ve afectado. Una restricción `CHECK` mantiene la misma
  regla en la base de datos.
- **Una variable *obligatoria* que no sea segura en una URL no puede estar en una
  página** — ver más abajo.

### Variables desde la barra de direcciones { #variables-from-the-address-bar }

Una página alojada no tiene ninguna página tuya de la que leer
`window.AgenticOSContext`. Su única fuente para una variable declarada es la
propia URL del visitante:

```
https://your-app.example.com/e/PUBLIC_KEY?var_plan=pro
```

**Un parámetro de query es entrada controlada por el visitante**, así que esto
está desactivado por variable y solo activo donde alguien lo decidió: marca
*URL-safe* en la variable dentro del Builder. Sin eso, un `?var_user_tier=premium`
escrito en la barra de direcciones se descarta — que es de lo que se trata.
Cualquier cosa no declarada se descarta igual que en el widget.

Por eso también hay que marcar una variable *obligatoria*: en esta superficie la
URL es la única forma de suministrar una, así que una variable obligatoria y no
URL-safe es una promesa que la página no puede cumplir por construcción.

### Volver a ella { #coming-back-to-it }

La conversación de un widget dura lo que dura su socket. Un enlace guardado en
marcadores es una promesa más fuerte, así que la página guarda una clave de
visitante aleatoria en `localStorage` — una por clave pública — y el servidor la
asocia a una conversación. Reabrir el enlace reproduce el hilo y se le recuerda al
agent la misma ventana que está leyendo el visitante.

**La clave es una credencial al portador para esa conversación**: quien la tenga
retoma el hilo, incluido lo que ya hay en él. Son 128 bits aleatorios y nada sobre
la persona. Borrar los datos del sitio empieza un hilo nuevo.

Esa clave es todo lo que guarda la página, y por eso **una página alojada y una
conversación compartida no muestran aviso de cookies** — las dos superficies que
se sirven a alguien que no es miembro son las dos que no tienen ninguna cookie
opcional que consentir. El banner del producto aparecía antes aquí, pidiendo
permiso para una analítica que este despliegue no ejecuta, colocado encima del
composer y tapando Send (#644). Un aviso de consentimiento por una única clave
esencial es un aviso cuyo único efecto es el solape.

Esa forma se impone, no se da por supuesta — el socket acepta de 32 a 64
caracteres hexadecimales en minúscula como `visitor` y **descarta cualquier otra
cosa**, abriendo un hilo nuevo en su lugar. Importa para un cliente propio (más
abajo): basar la continuidad en un id de cliente, un correo o un contador le daría
a cada uno de tus usuarios una conversación en la que el siguiente puede entrar
adivinando. Una clave descartada cuesta continuidad y nunca la conversación, así
que un valor caducado en el navegador de alguien no es una página que no carga.

### Qué ofrece { #what-it-offers }

Tres opciones, y todas son del operador y no de la página — una capability que
una página se activara a sí misma sería una que nadie podría desactivar.

- **Un botón para empezar un hilo nuevo**, activado por defecto. Acuña una clave
  de continuidad nueva, así que el hilo viejo no se borra: deja de ser el que
  retoma ese navegador.
- **Entrada de voz**, desactivada por defecto: `browser` dicta con el propio
  navegador del visitante, y `server` con el provider de speech-to-text de la
  organización. Cuál de los dos decide adónde va la voz de un visitante - ver
  [dictado en un widget o en una página alojada](#dictation-on-a-widget-or-a-hosted-page).
  Una página publicada antes de que existiera la opción conserva lo que tenía:
  su antiguo *micrófono en el composer* pasó a ser `browser`, y sin micrófono
  pasó a ser `off`.
- **Una forma de adjuntar un fichero**, desactivada por defecto. Ver más abajo: es
  lo único en esta superficie que permite a un desconocido *almacenar* algo.

### En qué puede escribir un desconocido con el enlace { #what-a-stranger-holding-the-link-can-write-to }

Todo lo demás en una superficie pública lee. Esto escribe, así que merece la pena
decir exactamente qué puede poner un visitante y dónde.

**Puede almacenar un fichero**, y solo si el operador marcó el interruptor. Los
bytes recorren el mismo camino que la subida de un miembro — la lista de MIME
permitidos, `CHAT_MAX_UPLOAD_SIZE_MB` (10MB por defecto — el techo propio de la
superficie de chat, no el mayor `MAX_UPLOAD_SIZE_MB` de la base de conocimiento),
el parser, el backend de almacenamiento, una fila `ChatFile` — con tres
estrechamientos por delante:

| | |
|---|---|
| **Un tope propio de esta superficie** | `EMBED_MAX_UPLOAD_SIZE_MB`, 5MB por defecto. Un miembro que sube una exportación de cincuenta megabytes es alguien a quien la organización emplea; la misma holgura en un enlace público es una forma de llenar un disco desde una dirección que nadie conoce. Es un techo *encima de* `CHAT_MAX_UPLOAD_SIZE_MB`, nunca una forma de saltárselo |
| **Un límite por dirección y por visitante** | `RATE_LIMIT_EMBED_UPLOAD_PER_MINUTE`, en el Redis compartido, y **ambos** tienen que permitirlo. Contar solo la clave de continuidad no acota nada: la acuña el navegador y cualquier cadena de 32 caracteres hex es válida, así que un script la varía por fichero. Contar solo la dirección deja que un navegador en una compartida gaste lo de todos |
| **Tres ficheros por mensaje** | Lo que acota cuánto del prompt de un turno es el documento de otra persona |

Esos tres acotan lo que se *almacena*. Lo que acota lo que un desconocido puede
hacer que este despliegue *reciba* está una capa por encima de todos ellos, porque
el cuerpo multipart se parsea antes de que corra la ruta: una petición que declara
más que el techo de petición completa se responde con un 413 sin leerla. Ver
[configuración](configuration.md#the-size-of-a-request-as-opposed-to-the-size-of-a-file),
incluido lo que no cubre.

**La fila pertenece al miembro que publicó la página**, porque
`chat_files.user_id` es `NOT NULL` y un visitante no tiene cuenta — la misma
respuesta que ya se dio para saber como quién se *ejecuta* un turno público. Una
página cuyo publicador ya no tiene cuenta no puede, por tanto, aceptar ficheros en
absoluto, y dice «no disponible» en vez de almacenarlos a nombre de nadie.

**A dónde va luego el fichero es decisión del runner, no de esta superficie.** Es
el enrutado de [Procesamiento de ficheros](file-processing.md), sin cambios: al
workspace del agent donde lo tiene, plegado dentro del prompt donde no, y una
imagen de las dos maneras hasta el techo inline. Nada del fichero de un visitante
es un caso especial.

Un frame solo puede nombrar un fichero que pertenezca al dueño de esta página y
que no cuelgue ya de un mensaje, así que un id no puede reproducirse en un segundo
turno ni en el hilo de otra persona. Eso es proporcionado, no completo, y lo que
lo hace suficiente es el id mismo: `uuid4` son 122 bits aleatorios, así que un id
de otro visitante es un valor que nadie puede producir sin que se lo hayan dado.

**No puede escribir nada más.** Ninguna base de conocimiento, ninguna ruta propia
del workspace, ninguna config, ninguna variable que no esté declarada y sea
URL-safe. La fila de la conversación y los turnos que hay en ella se escriben
*sobre* él, por parte de la plataforma.

### Qué ve el visitante del trabajo { #what-the-visitor-sees-of-the-work }

Tres interruptores más, y son **filtros sobre lo que envía el servidor**, no sobre
lo que dibuja la página. Esa distinción es todo el diseño: un razonamiento
escondido con CSS es el razonamiento de un agent sentado en las devtools de un
desconocido, y una página es justo el sitio donde un desconocido las tiene
abiertas. Un visitante que abra las suyas ve lo que esté marcado aquí y nada más.

| Interruptor | Por defecto | Qué deja pasar |
|---|---|---|
| **Qué está haciendo el agent** | activado | Una línea por paso — *Searching the documents*, *Ran a query*. Activado, porque una página que se queda callada treinta segundos se lee como rota |
| **Qué devolvió cada paso** | desactivado | Los argumentos con los que se llamó a un paso y lo que volvió. Está escrito para el modelo, así que es aquí donde aparece algo interno: una dirección, una fila de un sistema, un pasaje que nadie quería publicar |
| **El razonamiento del agent** | desactivado | Lo que el modelo se dice a sí mismo antes de responder. No está escrito para que nadie lo lea, y no es una respuesta que un operador pueda respaldar |

*Qué devolvió cada paso* no se puede activar solo — no hay ningún paso que abrir, y
el servidor descarta ambos independientemente de lo que diga la config.

**Un turno se ve como un turno en el chat web**, hasta en el marco que lo rodea: el
nombre del agent encima de la respuesta, el avatar en el margen — el logo de la
página donde lo hay, la inicial del agent donde no — la hora bajo cada turno en el
lado en el que está, y una tarjeta de composer con el campo y sus controles dentro.

Tres cosas que el chat web sí dibuja ahí están ausentes a propósito, y las tres son
la misma decisión que los paneles de abajo: lo que costó el turno, lo que ha costado
el mes, y qué agent y qué modelo ejecutar.

Esto último porque un frame que pudiera elegir modelo es un visitante eligiéndolo a
cuenta del operador.

**Un turno lo renderizan los propios componentes del chat web**, no un segundo
juego que se les parece.

`TurnParts` es lo que renderiza el dashboard y lo que renderiza la página. Así que
el razonamiento es la misma revelación, la respuesta el mismo Markdown, y una tanda
de llamadas a herramientas el mismo raíl — el icono de
`src/lib/tool-catalog.ts`, el texto de `toolStep`, y los mismos renderizadores
abriéndose bajo un paso para una búsqueda en el conocimiento, una búsqueda web, un
gráfico, código que se ejecutó, un skill que se cargó, un fichero que se escribió.

Deliberadamente no hay una segunda tabla de nombres de herramientas ni un segundo
renderizador de turnos (#144). Lo que la página *no* dibuja es todo lo relativo a
ser miembro — ver más abajo.

Una cosa se lee distinto por necesidad: una llamada que vino de un servidor MCP se
llama *Linear · Create issue* en el dashboard y aquí lleva un nombre humanizado,
porque el mapeo es la lista de conexiones de la organización y leerla requiere una
sesión.

**Una imagen, un audio de voz o un vídeo generados también se reproducen en la
página** (#2309). La consola los lee a través de `/api/v1/generated/{filename}`,
que necesita una sesión, y un visitante no la tiene. Por eso, al enviar un
resultado con una imagen, una voz o un vídeo generados que nombra un
archivo guardado por las propias herramientas de esa ejecución, el socket le añade
un `token`, y la página lee el archivo a través de
`/api/embed/<key>/generated/<file>?token=…`, que lo transmite en streaming y deja
pasar los rangos de bytes para un reproductor.

El token es un HMAC, bajo la `SECRET_KEY` del despliegue, de la clave de la
página, ese único archivo y una caducidad a seis horas vista. No abre nada más: ni
el archivo de otra conversación, ni el de otra organización, ni ningún otro nombre.
Un token incorrecto, caducado o ausente recibe el mismo 404 que un archivo que no
existe. Se añade cuando sale el frame, así que la transcripción no guarda ninguno,
y la tarjeta es el resultado de ese paso: solo aparece donde *Qué devolvió cada
paso* está activado. Una integración por socket encuentra la misma dirección en el
`url` del payload, como ruta de la API.

Un resultado que nombra un archivo que esta
ejecución no guardó no recibe token, se llame como se llame la herramienta que lo
devolvió. Un enlace puede pedirse 120 veces por minuto, margen para los rangos de
bytes de un reproductor; por encima de eso recibe un 429.

**Un widget y un socket en crudo no llevan interruptores y reciben estos valores
por defecto**, leídos de `PageConfig` en vez de repetidos — una segunda copia de
«desactivado por defecto» es una copia que puede discrepar de la que alguien lee en
el Builder. Lo que `widget.js` *dibuja* luego es todavía más estrecho, y lo dice más
arriba.

Ninguno de los dos acepta ficheros tampoco, y eso es cosa de la ruta y no del
cliente: el endpoint de subida resuelve la clave con `find_page`, así que una clave
de widget llega hasta él y recibe un «no disponible». Un widget vive en una página
que el operador ya controla, que es donde va un selector de ficheros propio.

### Qué es deliberadamente solo para miembros { #what-is-deliberately-member-only }

El chat web dibuja tres paneles que una superficie pública no dibuja, y cada
omisión es una decisión y no un hueco — registrada aquí para que no se vuelva a
discutir como si lo fuera.

| Panel | En una superficie pública | Por qué |
|---|---|---|
| **La tira de uso** | No, y no es un interruptor | Informa de los tokens del turno, su coste, el mes frente al tope de la organización y lo lleno que está el workspace. Un visitante no es quien paga, y el budget restante del operador es un hecho sobre el operador. `complete` no lleva uso alguno, así que no hay nada que ocultar en el cliente |
| **El panel de ficheros** | No | Lista todo lo que hay en el *workspace* del agent, que se comparte entre las conversaciones de todos los que usan ese agent. A un desconocido que adjuntara un fichero se le mostrarían todos los ficheros que se le han dado al agent en su vida. Su propio adjunto está en su propio turno, que es lo que le corresponde |
| **El panel de delegación** | No | Nombra a los delegados por slug, qué se le pidió a cada uno y qué costó cada uno — la forma del grafo de agents de la organización. Una página que lo mostrara publicaría un organigrama interno a quien tenga el enlace. Una delegación se *ejecuta* igualmente: es un paso `tool_call` llamado `task`, bajo el mismo interruptor que cualquier otro paso |

El patrón detrás de los tres: lo que ve un miembro es *sobre la organización*, y lo
que ve un visitante es *sobre su propio turno*. Un panel que cruza esa línea es
solo para miembros cueste lo que cueste renderizarlo.

### Qué aspecto tiene { #what-it-looks-like }

La respuesta se renderiza como **Markdown**, igual que en el chat web: un agent al
que se le dice que responda en Markdown está respondiendo en Markdown tanto si la
página reinterpreta los asteriscos como si no. Lo que escribió un *visitante* no se
reinterpreta — no es un documento.

Cuatro campos, todos opcionales:

| Campo | Por defecto |
|---|---|
| **Título de la página** | el nombre del agent |
| **Mensaje de bienvenida** | ninguno. **Markdown**, escrito en el mismo editor que usa la nota de colocación y renderizado como Markdown en la página. Se muestra antes de la primera pregunta y nunca se envía al modelo — un saludo en el historial del modelo es un turno que el agent cree haber dado |
| **Color de acento** | `#4f46e5`. El claro y el oscuro siguen dependiendo del sistema del visitante |
| **Logo** | el avatar del agent; o el de la organización, uno que subas, o ninguno. Elijas el que elijas, la página no muestra **nada** en vez de una imagen rota cuando no hay fichero detrás — un agent sin avatar es el caso habitual, y un navegador no distingue un 404 de una imagen lenta |

Tres de esos cuatro son imágenes que esta plataforma ya tiene. El cuarto acepta un
fichero — PNG, JPEG, WebP o GIF, hasta 2MB — y solo se puede añadir una vez que la
página existe, porque una subida necesita una fila a la que engancharse.

La página lo pide desde **su propio origen**, no desde la API: `img-src` en
`next.config.ts` excluye una API en `http` a secas, así que una página que apuntara
un `<img>` a una renderizaba un glifo roto en desarrollo y en cualquier despliegue
que termine TLS en otro sitio. `/api/embed/<key>/logo` en el frontend hace de proxy.

**Lo que no acepta es una URL tuya.** Una página que servimos nosotros pidiendo una
imagen suministrada por el operador es una cosa más que hay que asegurar. Y la ruta
guardada es una *columna*, escrita por la ruta de subida y nunca por la config que
envías: la ruta se vuelve a leer y la sirve una ruta pública, así que una aceptada
desde el cuerpo de una petición sería un llamante nombrando cualquier fichero que
el proceso pueda abrir.

**Tampoco acepta tu nombre de fichero, ni tu palabra sobre qué es el fichero.**

Como la página pide el logo desde su propio origen, sea cual sea el tipo que lleve
esa respuesta es un tipo en el que el navegador confía en ese origen — y ahí
`script-src` permite script inline.

Una subida se acepta con el `Content-Type` que *declaró* su cliente, que no es
prueba de nada sobre los bytes. Así que el nombre en disco se acuña a partir del
tipo (`logo.png`, `logo.jpg`, `logo.webp`, `logo.gif`), y tanto la ruta de la API
como el proxy del frontend se niegan a responder con nada que no sea uno de esos
cuatro tipos de imagen.

Un `.html` o un `.svg` guardado — desde aquí, o desde un avatar subido hace años
por otra ruta — se sirve como **absolutamente nada** en lugar de como un script.

La página lleva `noindex`. Un enlace secreto no es una página para indexar, y un
rastreador que siga uno lo ha publicado.

---

## Dictado en un widget o en una página alojada { #dictation-on-a-widget-or-a-hosted-page }

Un widget y una página alojada tienen cada uno un ajuste `dictation`: `off`,
`browser` o `server`. **`browser`** usa el propio navegador del visitante para
reconocer la voz, así que el audio va al fabricante del navegador - Google en
Chrome, Apple en Safari - y nada llega a este despliegue. A un navegador sin
reconocedor, Firefox entre ellos, no se le muestra micrófono. **`server`** graba
en la página y envía el audio aquí, donde lo transcribe el provider de
speech-to-text de la organización: la anulación del agent y su clave si la tiene,
y si no, el predeterminado de la organización y su clave - la misma resolución
que el
[dictado en el chat](ml-services.md#dictation-in-chat).

El motor de la organización para `/chat` no influye en ninguna de las dos
superficies. Una organización que mantiene a sus miembros en el reconocedor del
navegador puede igualmente dar a una página pública el dictado `server`, y al
revés.

**Activar `server` se rechaza** mientras no se resuelva para el agent ningún
provider con una clave utilizable, y el formulario del embed muestra la opción
desactivada junto con el motivo. Solo se comprueba el cambio, así que una página
cuya clave se borró después todavía puede renombrarse. Mientras no se resuelva
nada, su config pública dice `off` y los visitantes no ven micrófono, en vez de
uno que falla. El agent además tiene que estar publicado: una grabación para un
agent que nadie ejecuta se rechaza antes de pagar nada.

**La página alojada** usa la propia grabadora del composer de la consola: la
barra de grabación con su forma de onda y su temporizador, las indicaciones bajo
la caja y el brillo de voz. Sus textos siguen el idioma del visitante y no
nombran a ningún administrador ni ajuste. **El widget** tiene una grabadora
propia en JavaScript plano: un botón de micrófono, una barra de nivel en el
color de acento, un temporizador, Stop y Cancel, y una línea bajo la caja cuando
algo sale mal. Sus textos están en inglés, como el resto del widget, y no tiene
brillo. En ambos, el texto cae en la caja y nunca se envía solo; Enter detiene
una grabación y Escape la descarta.

Ambos graban en el formato que el servidor anuncia en el `dictation_format` de
la config pública: WAV PCM de 16 bits, mono y 16 kHz, construido a partir de las
muestras del micrófono, salvo que el despliegue
[acepte audio comprimido](deployment.md#what-a-dictation-may-be-uploaded-as)
y el provider admita lo que produce `MediaRecorder` - WebM, Ogg o MP4. Mistral,
que no documenta ni WebM ni Opus, recibe WAV en cualquier caso.

### El endpoint { #the-endpoint }

`POST /api/v1/embed/{public_key}/dictation` recibe un `file` multipart y un
`duration` opcional, y responde `{"text": ...}`. La admisión sigue a la
superficie. Un **widget** se admite como su socket: el `Origin` tiene que estar
en su lista de permitidos, y en modo `jwt` el token del visitante viaja como
campo de formulario `token` - nunca en la URL, que acaba en los logs de acceso,
ni en una cabecera, que necesitaría un preflight de CORS que el sitio de otro no
supera. Una **página alojada** envía su clave de continuidad en `X-Visitor-Key`,
una cabecera propia que solo puede enviar el origen de este despliegue. Un
**socket en crudo** no tiene dónde dictar y recibe un 404.

Los rechazos llevan el mismo `details.reason` que la
[ruta del composer](ml-services.md#dictation-in-chat): 400 `empty`,
`unsupported_type` o `too_large`, 503 `not_configured`, `provider_error` o
`timeout`. A diferencia de la ruta del composer, esta rechaza como
`unsupported_type` una grabación comprimida cuya duración no se puede leer
([más abajo](#what-it-costs-and-what-bounds-it)). Todo lo relativo al propio embed - desconocido, en pausa, no en
`server`, un origen que no permite - es 404. Una vez admitido un widget, cada
respuesta lleva su origen en `Access-Control-Allow-Origin`, rechazos incluidos;
si no, el navegador oculta el motivo y una grabación demasiado larga le parecería
al visitante una falta de conexión.

### Lo que cuesta y lo que lo limita { #what-it-costs-and-what-bounds-it }

Una transcripción está **fuera de todo budget de modelo**: el budget del embed
cuenta los runs, no los dictados. Lo que limita el gasto de un desconocido son
dos rate limits y el dictado más largo de la organización. Cada dirección puede
enviar `RATE_LIMIT_EMBED_DICTATION_PER_MINUTE` grabaciones por minuto (6 por
defecto), y lo mismo cada visitante de un widget `jwt`, según el sujeto del
token. Cada página o widget acepta `RATE_LIMIT_EMBED_DICTATION_PER_LINK_PER_MINUTE`
por minuto (10 por defecto) de todos a la vez, por muchas direcciones que vaya
rotando un script.

La dirección se cuenta antes de buscar el enlace, así que una petición
rechazada gasta el minuto de su dirección. Las diez grabaciones del enlace solo
se gastan cuando el visitante ha sido admitido, así que alguien sin un token
`jwt` válido no puede agotarlas a los visitantes que sí lo tienen. En una página
alojada o un widget público se admite a todos, así que cualquiera con el enlace
puede seguir gastando su minuto; a la organización le cuesta como mucho esas
diez grabaciones.

Mientras el Redis del despliegue no está disponible, estos límites **no se
abren**: cada worker cuenta las mismas cuotas en su propia memoria hasta que
Redis vuelve. El techo pasa a ser la cuota por el número de workers de la API,
en lugar de ningún techo. Los límites de inicio de las llamadas de voz se
comportan igual.

**La duración se mide antes de llamar al provider**, y una grabación más larga
que `dictation_max_seconds` se rechaza como `too_large`, con tres segundos de
margen porque el navegador solo detiene la grabación una vez por segundo. Por
defecto una grabación es PCM en crudo y su duración es su tamaño: el servidor
cuenta las muestras, así que la cifra no puede ser otra que la real. Donde el
despliegue acepta audio comprimido, la duración de una grabación WebM, Ogg o MP4
se lee de su propia cabecera - la última página Ogg, las marcas de tiempo de los
bloques WebM, las cabeceras de película o de fragmento de MP4 - y una cuya
duración no puede leerse se rechaza como `unsupported_type`. Un límite de bytes
no bastaría: Opus a un bitrate bajo mete horas en los bytes de una grabación de
cinco minutos.

!!! danger "El peor caso para un enlace filtrado o público"

    El límite del enlace por el dictado más largo: 10 grabaciones por minuto de
    hasta 300 segundos cada una son 50 minutos de audio cada minuto. A unos
    $0.006 por minuto, que es el precio de lista de `whisper-1`, eso son cerca
    de $0.30 por minuto, $18 por hora o $430 al día, cobrados a la cuenta del
    provider de la organización. Baja `dictation_max_seconds` o el límite del
    enlace, pon el embed en pausa, o déjalo en `browser` donde eso sea demasiado.

    Con el valor por defecto, PCM en crudo, la cifra es exacta: ningún archivo
    puede contener más segundos de los que dice su tamaño. Donde el despliegue
    acepta audio comprimido, solo vale para un archivo cuya cabecera indica su
    duración real, como hace la de cualquier navegador. Un archivo hecho a mano
    para declarar menos duración de la que tiene queda limitado en cambio por el
    límite de bytes - `dictation_max_seconds` más tres segundos a 32.000 bytes
    por segundo -, que un audio de bitrate bajo puede llenar con hasta unas
    cuarenta veces más segundos. Ese es el riesgo que acepta el administrador al
    activarlo.

Cada intento que llega al provider deja un registro de uso con la superficie
(`hosted` o `widget`) y el agent, y **ninguna persona**: un visitante no es
miembro, y la llamada no es del que publicó la página. El audio nunca se guarda.

**El aviso del micrófono es del sitio del cliente.** El navegador pregunta en
nombre de la página en la que se ejecuta el widget, así que un sitio que envía
`Permissions-Policy: microphone=()`, un `<iframe>` sin `allow="microphone"` o
HTTP sin cifrar lo bloquea. El widget lo dice bajo su caja en vez de fallar en
silencio.

## Llamadas de voz en un widget o en una página alojada { #voice-calls-on-a-widget-or-a-hosted-page }

Un widget y una página alojada tienen cada uno un interruptor **Voice calls**
(`voice_conversation`), apagado por defecto e independiente del dictado. Con él
encendido, los visitantes pueden mantener una [llamada de voz](voice.md) con el
agent: la misma llamada que un miembro hace desde `/chat`, con los mismos
límites, ejecutada en el contexto del publicador y registrada con la superficie
`embed`.

**Encenderlo se rechaza** mientras el agent no tenga una sección Voice
publicada con la que se pueda mantener una llamada. Solo se comprueba el cambio, así que un embed cuyo agent pierda la voz
más tarde todavía puede editarse. Mientras una llamada no pudiera empezar, la
config pública dice `voice_conversation: false` y a los visitantes no se les
ofrece ninguna llamada; cuando sí pudiera, la config indica además qué
transporte usa la llamada (`voice_transport`).

**En una página alojada** aparece un botón de llamada en el composer, y la
llamada usa la propia [barra de llamada](console.md#voice-calls) de la consola:
el estado, el tiempo frente al límite de la llamada, la forma de onda, silenciar,
push-to-talk y End, con los subtítulos dentro del mensaje que se está diciendo.
Está recortada a lo que necesita un visitante: sin controles de aprobación, sin
el recuento de minutos de la organización, y con los finales y los rechazos
explicados en palabras de visitante. Cada turno guardado se suma al hilo de la
página, así que el visitante puede seguir escribiendo; **New chat** cuelga la
llamada.

**En el widget**, la llamada es un script aparte, `voice.js`, que se descarga la
primera vez que un visitante abre el panel y solo si la config pública ofrece
llamadas, para que el script que descargan todos los visitantes siga siendo
pequeño. Se sirve en `/api/v1/embed/{public_key}/voice.js` con el mismo contador
que el script del widget, y su URL lleva un hash del script, así que una copia en
caché nunca se combina con un widget más nuevo.

La fila de entrada del widget se convierte en una barra de llamada reducida - un
medidor de nivel en el color de acento del widget, el estado, el tiempo frente al
límite, Mute y End -, sin push-to-talk ni tarjeta de aprobación. Los subtítulos
aparecen en la lista de mensajes y dejan paso a los turnos guardados, y un
rechazo, un límite alcanzado o la línea ocupada se muestran en una sola línea
bajo la barra.

**Una llamada en el widget es una conversación aparte.** El chat escrito del
widget no guarda clave de visitante - cada carga de la página empieza de cero -,
así que los turnos de una llamada se guardan en una conversación propia, y un
mensaje escrito después empieza otra. En pantalla comparten una lista; para el
agent, lo dicho en la llamada no es contexto de lo que se escribe luego. La
página alojada tiene clave de visitante, y allí la llamada y lo escrito son un
mismo hilo.

End, cerrar el panel, salir de la página o una conexión que no llega a
establecerse terminan la llamada y liberan el micrófono, el audio y la conexión;
si la conexión se cae a mitad de llamada, se reintenta una vez, en un plazo de
ocho segundos. Los textos de llamada del widget están en inglés, como el resto
del widget.

**Un visitante no puede aprobar nada.** Las herramientas que necesitan una aprobación
quedan fuera por completo de la llamada de un visitante, así que el agent nunca
recurre a una; dice claramente cuándo no puede hacer algo ahí. Una llamada
recibe también la nota de ubicación del embed y las variables declaradas de la
página, exactamente igual que un mensaje escrito, así que el agent se comporta
igual en ambos casos.

**A un visitante se le dice solo lo que un visitante necesita.** Los
interruptores de [qué ve el visitante del
trabajo](#what-the-visitor-sees-of-the-work) filtran los frames de una llamada
igual que filtran las respuestas escritas: sin *Qué está haciendo el agent* un
visitante no sabe que se ejecutó una herramienta, y sin *Qué devolvió cada paso*
un paso no lleva con qué se llamó a la herramienta ni lo que volvió. El límite
que ve un visitante es el límite propio de la llamada, y un rechazo por la
configuración, el precio o el budget del operador dice solo que las llamadas no
están disponibles ahora. Pausar el embed, desactivar sus llamadas o apuntarlo a otro agent termina
las llamadas en él en un heartbeat. Los frames están en [la página de la
API](api.md#voice-calls).

### El socket de llamada { #the-call-socket }

`/api/v1/embed/{public_key}/voice/ws` habla los mismos frames que el
`/api/v1/ws/voice` de la consola ([API](api.md#voice-calls)). La admisión sigue
a la superficie, como la del socket de chat. Un **widget** se admite por su
lista de permitidos y, en modo `jwt`, por el token del cliente en `?token=`.
Una **página alojada** se admite por el propio origen de este despliegue y la
clave de continuidad en `?visitor=`, y la llamada cae en el hilo que guarda esa
clave, así que una llamada cortada puede reanudarse ahí. Un **socket en crudo**
no tiene desde dónde llamar.

El socket se cierra con `4029` al pasar un rate limit y con `4003` por cualquier
cosa relativa al embed - desconocido, en pausa, un socket en crudo, llamadas
apagadas, un origen que no permite - sin decir cuál. Todo lo que ocurre después
de la admisión es el rechazo propio de la llamada, enviado como un frame `ended`
con su motivo.

### Lo que cuesta una llamada a un enlace público { #what-a-call-costs-a-public-link }

Las llamadas están dentro de los budgets: cada una es un run, medido en los
budgets del agent y de la organización, y dura como mucho la llamada más larga
del agent. Además, un enlace público tiene límites propios:

| Límite | Por defecto | Qué cuenta |
|---|---|---|
| `RATE_LIMIT_EMBED_VOICE_PER_MINUTE` | 3 | Inicios de llamada por dirección, y por sujeto del token del visitante en un widget `jwt` |
| `RATE_LIMIT_EMBED_VOICE_PER_LINK_PER_MINUTE` | 6 | Inicios de llamada en una página o widget, de todos a la vez |
| `VOICE_MAX_CALLS_PER_EMBED` | 3 | Llamadas abiertas a la vez en un embed; el siguiente que llama se rechaza como `line_busy` |

El propio `rate_limit_per_minute` del embed cuenta **mensajes**, no inicios de
llamada, así que no limita las llamadas. La dirección se cuenta antes de buscar
la clave, así que un inicio rechazado gasta el minuto de su dirección; los
inicios del enlace solo se gastan una vez admitido el visitante. El límite
por sujeto `jwt` es una tasa de inicios, no un tope de llamadas abiertas; las
llamadas abiertas quedan limitadas, junto con las de todos, por el del embed.

!!! danger "El peor caso para un enlace filtrado"

    Tres llamadas abiertas a la vez, cada una como mucho la llamada más larga
    del agent - 10 minutos por defecto, 50 como máximo -, gastan como mucho tres
    minutos de llamada por minuto, en la cuenta de la organización en el
    provider. Lo que lo detiene son los budgets del agent y de la organización:
    cada llamada se compara con lo que queda antes de empezar, y termina cuando
    el budget se agota, incluidos los runs de los miembros. Dale al agent un
    budget propio, pon el embed en pausa o apaga sus llamadas si eso es
    demasiado.

---

## La API pública { #the-public-api }

Ningún frontend, y ningún navegador. Una petición, una respuesta:

```bash
curl -X POST https://your-api.example.com/api/v1/agents/AGENT_ID/run \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Organization-Id: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Summarise this week's refunds"}'
```

Pasa por el mismo runner que cualquier otra superficie, así que el run se registra,
el budget se aplica y el coste aterriza en el mismo dashboard — un llamante de la
API no puede esquivar la gobernanza por no usar la UI. Los runs se sellan como
`api`.

La respuesta lleva `run_id`, `output`, `status` y lo que costó. Un `status` de
`awaiting_approval` con una salida vacía significa que una llamada a herramienta
está aparcada: el run está en la cola de aprobaciones, y continúa cuando alguien
decide.

Limitado por llamante y no por dirección — una oficina detrás de un NAT no es un
solo llamante — con `RATE_LIMIT_RUN_PER_MINUTE`.

## Otros agents, por A2A { #a2a }

El agent de otro sistema puede enviar tareas a un agent mediante el
[protocolo Agent2Agent](https://a2a-protocol.org). Está desactivado para cada agent
hasta que alguien que pueda publicarlo active **Agent2Agent (A2A)** en su pestaña
**Availability**, que muestra las dos direcciones que hay que dar al otro lado:

- la **agent card**, en `/api/v1/a2a/{agent_id}/.well-known/agent-card.json`,
  legible sin clave — el nombre del agent, su descripción, su versión, su única
  habilidad y cómo autenticarse;
- el **endpoint** de tareas, `POST /api/v1/a2a/{agent_id}`, JSON-RPC 2.0.

Quien llama envía una clave API de la organización, o un token de acceso OAuth que
emitió este despliegue, como bearer token, y debe tener permiso para ejecutar el
agent: uno que no lo tiene recibe un HTTP `403`, o un `404` cuando ni siquiera puede
ver el agent, porque A2A deja la autorización al transporte. `message/send` solo
acepta partes de texto, y rechaza con `-32005` un mensaje con una parte de datos o
de archivo, en lugar de ejecutarlo solo con su texto.

`message/send` ejecuta la versión publicada como un run normal marcado `a2a`,
así que se aplican el budget, la aprobación, la auditoría y Activity. El id de la
tarea es el del run y su `contextId` es la conversación en la que se archiva el run,
así que volver a enviarlo continúa esa conversación. Un run que se detiene en una
aprobación responde `input-required`; se decide en la consola, y `tasks/get` lee
dónde está desde entonces.

Una tarea la lee quien la envió, o quien puede leer todos los runs
(`runs:view`); para cualquier otro su id es desconocido. Un mensaje con `taskId` se
rechaza en lugar de ejecutarse junto a la tarea que espera: para preguntar algo
nuevo, se envía sin él, en el mismo `contextId`.

Se responde de forma síncrona: `message/stream`, las notificaciones push y
`tasks/cancel` se rechazan por su nombre, y la card dice que no se ofrece streaming.
Un agent que no se sirve — desactivado, sin publicar, archivado o de otra
organización — responde `404`, tanto la card como el endpoint.

---

## SMS { #sms }

Un SMS a un número de Twilio llega a un agent como un mensaje directo en cualquier otra
plataforma: por el mismo router, la identidad vinculada, la política de acceso y los
presupuestos (#2082). Solo el transporte es nuevo.

**Añade el bot** en Canales → SMS (Twilio). Su token es el SID de la cuenta de Twilio, el token
de autenticación y el número desde el que envía, unidos con dos puntos:
`ACxxxxxxxx:auth_token:+15551234567`. Un bot de SMS funciona solo por webhook; tras guardarlo,
pega la URL del webhook que se muestra en el ajuste de mensajes entrantes del número en la consola
de Twilio. La dirección que Twilio firma se construye a partir de `PUBLIC_BASE_URL`, así que ese ajuste tiene que ser la dirección a la que Twilio llama de verdad: tras un proxy que la reescribe, todo SMS se rechaza.

**Quién escribe.** Una petición se acepta solo con una firma de Twilio válida - HMAC-SHA1 de la
dirección a la que Twilio llamó y de sus campos, con el token de autenticación - y cualquier otra
recibe un 403 antes de leer una palabra. Una firma válida prueba que la petición vino de Twilio. No
prueba quién es el remitente: el número es una afirmación, así que una persona actúa con sus propios
permisos solo cuando ha vinculado ese número a su cuenta como cualquier otra identidad de canal. El vínculo caduca tras `SMS_LINK_MAX_AGE_DAYS` (90 por defecto, `0` lo mantiene siempre), porque un número puede reasignarse o ser tomado; la persona lo vincula de nuevo.

**Qué vuelve.** El webhook responde al instante y el agent contesta por la API de Twilio cuando
tiene una respuesta. Una respuesta es texto plano - se quitan los marcadores de markdown y un
enlace se escribe completo - y se corta hacia los 1.500 caracteres con un aviso de que el resto
está en AgenticOS, así que una pregunta cuesta unos segmentos, no una docena. El SMS no puede
transmitir en directo, editar un mensaje ni pulsar un botón, así que una aprobación o una pregunta
llega como líneas numeradas y se responde enviando el número por SMS. Un solo número que nombre una
opción de una pregunta enviada a ese número es la respuesta. Las preguntas que esperan a la vez nunca comparten números: la segunda sigue donde terminó la primera, así que tras 1 y 2 vienen 3 y 4, y decidir una deja abierta la otra. Una pregunta sigue abierta un día, así que una selección múltiple admite varios números; cualquier otro texto es un mensaje normal.

Los archivos MMS entrantes, también sin texto, pasan por el procesamiento
compartido de adjuntos tras verificar identidad y acceso. Se leen hasta diez
archivos; sus URL deben identificar la cuenta Twilio del bot y el mensaje
entrante. Las descargas autenticadas rechazan redirecciones, se detienen en
`CHAT_MAX_UPLOAD_SIZE_MB` y tienen un límite total de 30 segundos. La ruta de
carga compartida valida tipo y contenido y guarda los archivos aceptados en la
conversación. Un número con un archivo es un mensaje, no una aprobación numerada.

Las respuestas pueden enviar archivos del workspace elegidos explícitamente y
gráficos como MMS, incluso sin texto. La herramienta de archivos del canal sigue
controlando el archivo y destino. El adaptador rechaza tipos no admitidos,
archivos vacíos, más de diez adjuntos o más de 5 MB de texto y medios en total.
JPEG/PNG/GIF pueden usar ese total; otros tipos aceptados, incluidos PDF y CSV,
tienen un límite de 500 KB. Sigue siendo necesaria la compatibilidad del operador
y destino; un rechazo del proveedor sigue siendo un error.

MMS requiere una `PUBLIC_BASE_URL` HTTPS pública y Redis compartido. Solo se
almacenan copias entregadas explícitamente al adaptador, cada una con una
credencial aleatoria de descarga durante 15 minutos. Twilio la usa para GET y
HEAD; el almacén autenticado original no se expone. Su cola se limita a diez
minutos, menos que la vida de la copia. Los enlaces no se guardan en el chat ni
se devuelven al modelo. La aceptación de la API significa envío al proveedor,
no entrega del operador. Consulta [formatos y límites de Twilio](https://www.twilio.com/docs/messaging/guides/accepted-mime-types).

## Correo electrónico { #email }

Un correo a una dirección llega a un agente como un mensaje directo en cualquier otra plataforma: por
el mismo router, la misma identidad vinculada, la misma política de acceso y los mismos presupuestos
(#2082). Solo el transporte es nuevo.

**Añade el bot** en Canales → Correo electrónico. Su token es la dirección desde la que responde, por
ejemplo `agent@mail.example.com`, y las respuestas salen por el proveedor de correo del propio despliegue,
así que `EMAIL_PROVIDER` debe ser uno que envíe de verdad: un bot con el proveedor `log` se niega a
responder en lugar de fingir que lo hizo. Un bot de correo funciona solo por webhook y necesita un
secreto: usa Generar y cópialo en el relay.

**El relay.** El correo no se consulta. Lo que lo recibe - un Cloudflare Email Worker, una ruta entrante
de Postmark o Mailgun, un script en un servidor de correo - envía por POST un documento JSON por mensaje
a la URL que se muestra en el bot tras guardarlo:

```json
{"from": "ada@acme.com", "subject": "Invoice", "text": "Please send it",
 "sender_authentication": {"dmarc": "pass", "from": "ada@acme.com"},
 "message_id": "<1@acme>", "in_reply_to": "<0@acme>", "references": ["<0@acme>"],
 "attachments": [{"filename": "a.pdf", "content_type": "application/pdf",
                  "content_base64": "..."}]}
```

y lo firma con dos cabeceras: `X-AgenticOS-Timestamp` (segundos unix) y `X-AgenticOS-Signature`, es decir,
`sha256=` más el HMAC-SHA256 hexadecimal de `timestamp.body` con el secreto. Una petición sin firma
válida, o con más de cinco minutos de diferencia, recibe un 403 antes de leer una palabra, y un bot sin
secreto rechaza todo. El cuerpo está limitado a 25 MB, se leen hasta diez adjuntos y un identificador de
mensaje ya visto se ignora, de modo que un reintento del relay ejecuta el agente una sola vez.

Los adjuntos entrantes comparten un límite de caché de 64 MiB de datos codificados y 1024 archivos por bot. Cada entrega se guarda atómicamente; al agotarse el límite, se devuelve 429 con Retry-After antes de guardar archivos o el hilo. Leer o descartar archivos libera su capacidad inmediatamente. Los archivos no leídos caducan tras 15 minutos; la siguiente operación recupera su capacidad. Los errores de procesamiento también descartan los adjuntos sin usar. Las respuestas y preguntas conservan el hilo de su propio mensaje entrante aunque llegue otro mientras tanto.

El webhook guarda un trabajo `process-email-delivery` antes de devolver 200. El trabajo conserva el cuerpo validado, incluidos los adjuntos; un reinicio de la API o una caché caducada no elimina el trabajo aceptado. La deduplicación por Message-ID se confirma junto con el trabajo; una transacción revertida permite reintentar. Los workers vuelven a comprobar bot y organización, serializan entregas al mismo interlocutor y registran intentos fallidos en Admin → Background jobs. Los payloads terminados siguen `BACKGROUND_JOB_RETENTION_DAYS`; los administradores del despliegue pueden consultarlos.

Los webhooks SMS e iMessage guardan un trabajo `process-channel-delivery` antes de confirmar un mensaje válido. Payload y recibo Message-ID comparten una transacción: el rollback permite reintentar y los reinicios de API conservan el trabajo aceptado. Los workers vuelven a comprobar organización, plataforma y actividad del bot, y resuelven respuestas numéricas contra preguntas actuales; las identidades SMS caducadas no pueden aprobar solicitudes antiguas. Los payloads siguen la retención de trabajos en segundo plano y son visibles para administradores del despliegue.

**Quién escribe.** La firma prueba que el relay envió la petición, no quién escribió el correo: una
dirección es una afirmación. Una persona actúa con sus propios permisos solo cuando ha vinculado esa
dirección a su cuenta como cualquier otra identidad de canal. Cada mensaje debe incluir
`sender_authentication: {"dmarc": "pass", "from": "ada@acme.com"}`, que confirme un resultado DMARC
válido y alineado para esa dirección exacta del encabezado From. El relay debe obtener el resultado
de su servidor de correo receptor de confianza, nunca de encabezados de autenticación proporcionados
por el remitente. Las confirmaciones ausentes, fallidas o discordantes se ignoran antes de buscar
la cuenta, guardar adjuntos o procesar aprobaciones. Los relays existentes deben añadir este campo
antes de actualizar; una firma HMAC por sí sola ya no permite admitir un mensaje.

**Qué vuelve.** El webhook responde de inmediato y el agente contesta por correo como respuesta al
último mensaje del remitente: `Re:` y el mismo asunto, con `In-Reply-To` y `References` para que el
cliente de correo lo archive en el hilo, y con los archivos que el agente haya producido adjuntos. El
historial citado bajo una respuesta se corta antes de que el agente lo lea. Una aprobación o una
pregunta llega como líneas numeradas y se responde contestando solo con el número; los números se
comportan como en SMS, y una pregunta sigue abierta un día.

Las `references` entrantes deben ser una lista de cadenas sin CR ni LF; las entradas conservadas no pueden superar los 998 caracteres. Los mensajes con referencias incorrectas se ignoran antes de procesarlos. Los asuntos de varias líneas se unen en una sola antes de crear las cabeceras de respuesta.


## Slack { #slack }

1. **api.slack.com/apps → Create New App → From scratch.** Ponle nombre y elige el
   workspace.
2. **OAuth & Permissions → Bot Token Scopes → Add an OAuth Scope**, y añade los
   trece de abajo. *Install to Workspace* sigue en gris hasta que se añade al menos
   uno, que es lo que está esperando una app en blanco.
3. **Install App → Install to Workspace → Allow.** Copia el **Bot User OAuth
   Token** (`xoxb-…`).
4. **Event Subscriptions → Enable Events → Subscribe to bot events**, y añade los
   cinco events de abajo.
5. Registra el bot: **Channels → Add channel**, plataforma `slack`, pega el token del
   bot.
6. Elige un transporte — Socket Mode no necesita nada expuesto y es la elección
   correcta en un portátil:
     - **Socket Mode.** *Basic Information → App-Level Tokens → Generate*, scope
       `connections:write`; pega el token `xapp-` en los ajustes del bot aquí. Sin
       Request URL, sin signing secret.
     - **Events API.** Pega el signing secret de *Basic Information → App
       Credentials* en los ajustes del bot **primero**, y luego fija la *Request
       URL* de Slack a `https://your-api.example.com/api/v1/slack/BOT_ID/events` —
       el id del bot está en su fila bajo **Channels**.
7. **App Home → Messages Tab**: actívala y marca *Allow users to send Slash
   commands and messages from the messages tab*. Sin eso Slack esconde la caja de
   mensajes del bot y un mensaje directo es imposible.
8. Publica el agent y luego vincúlalo: Builder → el agent → **Availability** → el
   bot.
9. Invita al bot a un canal — `/invite @your-bot` — y menciónalo.

!!! tip "O pega un manifiesto y sáltate los pasos 2, 4, 6 y 7"

    **App Manifest** en la navegación izquierda acepta toda la configuración de
    golpe — scopes, events, Socket Mode y la pestaña de mensajes — que es menos
    propenso a errores que once pasadas por un selector de scopes. Crea la app *a
    partir de un manifiesto*, o pega esto sobre el de una app existente:

    ```yaml
    display_information:
      name: Support Copilot
    features:
      bot_user:
        display_name: Support Copilot
        always_online: true
      app_home:
        messages_tab_enabled: true
        messages_tab_read_only_enabled: false
    oauth_config:
      scopes:
        bot:
          - chat:write
          - files:write
          - files:read
          - app_mentions:read
          - users:read
          - channels:read
          - groups:read
          - channels:history
          - groups:history
          - im:history
          - mpim:history
          - im:read
          - mpim:read
    settings:
      event_subscriptions:
        bot_events:
          - app_mention
          - message.channels
          - message.groups
          - message.im
          - message.mpim
      socket_mode_enabled: true
      token_rotation_enabled: false
    ```

    Para la Events API en su lugar, pon `socket_mode_enabled: false` y añade
    `request_url` bajo `event_subscriptions` — y lee antes la siguiente
    advertencia, porque Slack valida esa URL en cuanto se guarda el manifiesto.

!!! danger "El signing secret entra antes que la Request URL, no después"

    Slack verifica una Request URL nueva enviándole un challenge firmado de
    `url_verification`, y ese challenge recorre el mismo camino que cualquier otro
    event. Un bot sin signing secret responde **500 a todos ellos**, así que Slack
    informa de «Your request URL didn't respond with the correct challenge value»
    — que se lee como un problema de red y no lo es.

!!! warning "No actives la rotación de tokens"

    *Advanced token security via token rotation*, arriba del todo en **OAuth &
    Permissions**, hace que el token `xoxb-` caduque. Este despliegue guarda un
    token de bot estático en el vault y no lo refresca, así que el bot funciona y
    luego deja de funcionar. Las redirect URLs, PKCE, los user token scopes, los
    rangos de IP y la Enterprise-Managed Authorization de esa página tampoco se
    usan — déjalos en paz.

### La app de Slack desde un manifiesto { #the-slack-app-from-one-manifest }

Los pasos de arriba construyen la app a mano. Los ajustes del bot aquí también
ofrecen **Copy Slack app manifest**: en Slack elige *Create New App → From a
manifest*, pega, instala y vuelve a pegar aquí el token del bot y el signing
secret. El manifiesto dirige cada petición a este despliegue y activa lo que hace
del bot una app:

- **El panel del asistente.** Un hilo nuevo en la vista de IA de Slack recibe tres
  prompts sugeridos, y el hilo muestra *is thinking…* mientras el agent trabaja.
- **App Home.** La pestaña propia del bot nombra su agent, dice cuántos runs de la
  persona esperan una aprobación y enlaza a la consola y al Architect.
- **`/agent <pregunta>`** pregunta al agent donde se escribió, y el atajo de
  mensaje **Ask the agent about this** pregunta por un mensaje en su propio hilo.
  Ambos se ejecutan exactamente como una mención: la misma comprobación de enlace,
  los mismos límites y la misma auditoría.
- **Botones** en aprobaciones y preguntas — ver
  [Aprobaciones y preguntas como botones](#approvals-and-questions-as-buttons).
- **Unfurls.** Un enlace de la consola a un agent de la organización muestra su
  nombre.

Un bot sirve a exactamente un agent, así que en un mensaje directo no hay selector
de agent; un segundo agent es un segundo bot.

El manifest es **org-ready**, así que un administrador de Enterprise Grid puede
instalar la app una vez para todos los workspaces de la organización; un workspace
corriente la instala como antes. Cada respuesta nombra el workspace de quien
pregunta, que es lo que Slack necesita para transmitirla bajo Grid.

### En qué sentido va la conexión { #which-way-the-connection-runs }

Los ajustes del bot preguntan **cómo llega Slack a AgenticOS**:

- **Slack llama a este deployment.** Slack envía cada mensaje a la dirección
  pública de este deployment, verificada con el signing secret. El manifest nombra
  las URLs.
- **AgenticOS se conecta a Slack.** El deployment abre la conexión por sí mismo
  (Socket Mode) con el token `xapp-` de nivel de app, así que no necesita dirección
  pública - la opción para un deployment detrás de un firewall u on-premise. El
  manifest activa Socket Mode y no nombra ninguna URL, porque nada respondería en
  ella.

El formulario pide solo la credencial que usa el sentido elegido.

### Respuestas que Slack dibuja solo { #answers-slack-draws-itself }

Las apps de IA de Slack dibujan una respuesta mientras se escribe en vez de
editarla en su sitio. La respuesta se transmite bajo la pregunta, y cada cosa que
hace el agent por el camino - una búsqueda web, una consulta al conocimiento, un
cálculo - es una fila que pasa de en curso a hecha o fallida. Un paso que leyó
páginas web las enlaza. Los ajustes del bot muestran los pasos como una **línea de
tiempo** o como un **plan** encabezado por la pregunta, o desactivan la
transmisión, que vuelve a una respuesta editada una vez por segundo. Lo mismo
ocurre solo si Slack se niega a empezarla.

Una tabla Markdown en la respuesta se dibuja como una tabla de Slack, y un gráfico
llega como imagen, como siempre. También un [visual](reference/capabilities.md#visuals),
dibujado por el navegador del despliegue cuando `VISUAL_RENDER_CDP_URL` lo indica. Si la respuesta terminada no es lo transmitido -
un guardrail de salida redactó algo -, el mensaje se reescribe entero.

### Scopes y events { #scopes-and-events }

Catorce bot token scopes, y cada uno se gana su sitio con una llamada que hace el
adaptador:

| Scope | Qué lo necesita |
|---|---|
| `chat:write` | `chat.postMessage`, y `chat.update` para la respuesta reescrita según se escribe |
| `files:write` | `files.upload` — un gráfico o un fichero que el agent devuelve |
| `files:read` | un adjunto que alguien publicó, descargado de `url_private_download` |
| `app_mentions:read` | que lo nombren en un canal |
| `channels:history`, `groups:history`, `im:history`, `mpim:history` | los events `message`, y `conversations.history` para la transcripción del canal |
| `channels:read`, `groups:read` | `conversations.info`, `.list` y `.members` — las consultas de canal que un agent puede hacer |
| `im:read`, `mpim:read` | `conversations.members` en un mensaje directo. Fácil de olvidar y silencioso cuando lo haces: la comprobación de pertenencia falla en cerrado, así que una conversación en la que no se pudo confirmar a nadie **desaparece de la lista de conversaciones** en vez de dar error |
| `users:read` | `users.info`, que convierte ids de miembro en personas |
| `users:read.email` | `users.lookupByEmail`, que encuentra a un miembro nuevo para [darle la bienvenida](#a-welcome-for-a-new-member). Una app de Slack instalada antes de que se añadiera sigue funcionando sin él, y no da la bienvenida a nadie hasta que se reinstala desde el manifiesto nuevo |

Cinco bot events: `app_mention`, `message.channels`, `message.groups`,
`message.im`, `message.mpim`.

`message.channels` y `message.groups` entregan **todos** los mensajes de todos los
canales en los que está el bot, no solo los que lo nombran — y eso es
deliberado, así que suscríbete a los dos. Lo que decide si el bot habla es la regla
de abajo, leída del event: Slack sustituye `<@U0123>` en una mención real, así que
el adaptador sabe qué mensajes iban dirigidos a él y se mantiene al margen del
resto. Que llegue la conversación entera es lo que necesitará un agent que decida
*por sí mismo* si un mensaje merece respuesta; una suscripción reducida a
`app_mention` no se puede ampliar después sin que todos los operadores editen su
app de Slack.

| Dónde | Cuándo responde |
|---|---|
| **Un mensaje directo** | Siempre. No hay nadie más en la sala, así que exigir una mención sería pedirle a alguien que se dirija al único participante |
| **Un mensaje directo de grupo** | Solo cuando se le nombra. Lo comparten varias personas, así que es una sala y no una conversación con una sola persona |
| **Un canal** | Solo cuando se le nombra — `@the-bot`, o `@agent-slug` para el agent que hay detrás |

Vale una mención en cualquier parte del mensaje, no solo al principio. Un handle
escrito sin dejar que Slack lo resuelva se queda en texto plano y no es una mención
— la plataforma no entregó ninguna — y `@channel`, `@all`, `@here` y `@everyone` se
dirigen a la sala y no a un agent.

**Un mensaje con un fichero adjunto es un mensaje.** Slack lo marca con
`subtype: file_share`, y tanto el fichero como el pie que lo acompaña llegan al
agent — una imagen con *«¿qué ves?»* debajo es un turno, no dos. Lo que sigue
rechazándose es que la plataforma describa el canal en vez de que alguien hable en
él: una edición, un borrado, una incorporación, un cambio de tema y cualquier cosa
que publicara el propio bot.

El bot ve aquello con lo que se instaló la app y nada más allá. Añadir un scope más
tarde significa reinstalar la app, lo que acuña un token `xoxb-` nuevo — pega ese
también, o el bot conserva el acceso que tenía.

!!! info "Un bot que no responde nada: qué se informa y qué no"

    La conexión sí. Un bot de polling cuyo stream no llegó a abrirse, o que sigue
    fallando, lleva una insignia **Not connected** en su fila bajo **Channels**, con
    el motivo escrito - el supervisor lo sabe, y hasta #1351 lo escribía en el log
    del contenedor y en ningún otro sitio.

    Lo demás sigue siendo silencioso, y este es el orden en el que comprobarlo:
    ningún agent vinculado al bot (la fila lo dice), un scope o una suscripción a
    events que falta, y luego un `BOT_ID` equivocado en la Request URL. Eso último
    no puede informar de sí mismo: la ruta de events responde **200 y no hace nada**
    para un id de bot que no encuentra, a propósito, porque quien sondea solo debe
    enterarse de que el endpoint existe - lo que la URL ya decía.

Funciona en canales y en DMs. Un mensaje de una cuenta vinculada se ejecuta como
esa persona — nunca como el bot; uno de una cuenta que nadie ha vinculado se
ejecuta bajo la vinculación, y solo en un canal. En un mensaje directo se pide la
cuenta primero.

Una cuenta vinculada cuyo miembro ha dejado la organización, o cuya cuenta ha sido
desactivada, se trata como no vinculada: rechazada en un mensaje directo, ejecutada
bajo la vinculación en un canal. La baja no borra ni la fila de pertenencia ni el
vínculo de la cuenta de chat, así que el rol se lee solo de una pertenencia que
todavía puede iniciar sesión.

### Una conversación por hilo { #one-conversation-per-thread }

**La unidad es el hilo, y eso ahora incluye un mensaje directo.** Mándale un
mensaje al bot y responde en un hilo enraizado en el tuyo; ese hilo es una
conversación y conserva su contexto mientras la gente siga respondiendo en él.
Responde dentro de un hilo que ya existe y se une a ese en su lugar.

Así que dos personas que preguntan cosas distintas en el mismo canal obtienen dos
conversaciones, y ninguna lee el contexto de la otra — y una persona que pregunta
por dos cosas distintas en un DM obtiene dos, por la misma razón.

!!! warning "Continuar una conversación significa responder en su hilo"

    Un mensaje nuevo escrito al final de un chat es una conversación **nueva** sin
    memoria de la anterior. Ese es el intercambio, y es deliberado: un chat
    indexado por sí mismo nunca pasa página, así que se sale de la ventana de
    contexto en cuestión de días y cada turno paga por todo el historial que lleva
    detrás. Un hilo por pregunta es un contexto por tema en vez de una única
    transcripción larga que alguien tiene que recortar.

    Un DM antes se indexaba por el chat. Las conversaciones anteriores no se
    migran — dejan de ser alcanzables, y no se pierde nada de lo que hay en ellas.

!!! info "Esto estuvo mal hasta hace poco"

    Una mención al principio de un canal se indexaba antes por el canal, mientras
    que el hilo que abría la respuesta se indexaba por el hilo. Eran dos
    conversaciones, así que el agent respondía a una pregunta y luego, un mensaje
    más tarde en el hilo que acababa de crear, no tenía memoria de ella — y además
    todas las menciones sin relación en ese canal se amontonaban en una sola
    conversación.

    Ahora el hilo que una respuesta *va a* abrir es lo que indexa el primer
    mensaje, así que las dos mitades coinciden. Las conversaciones anteriores al
    arreglo no se migran; simplemente dejan de ser alcanzables, y no se pierde nada
    de lo que hay en ellas.

## Telegram { #telegram }

1. Crea un bot con @BotFather y copia el token.
2. **Channels → Add channel**, plataforma `telegram`.
3. Registra el webhook desde la UI, o ejecuta polling en desarrollo — no hace falta
   ninguna URL pública.

Registrar el webhook es lo que le entrega a Telegram el secreto del bot, y **un bot
sin secreto rechaza todas las llamadas de webhook** en vez de confiar en ellas. Así
que a un bot que se pasa de polling a modo webhook hay que registrarle el webhook
antes de que responda a nada: el secreto se acuña cuando cambia el modo, y Telegram
solo se entera de él cuando se registra el webhook.

El bot define su menú de comandos — `/agents` dice qué agent responde y qué hace,
junto a `/new`, `/link` y `/help` —, muestra *escribiendo…* mientras trabaja y
nombra el paso en el que está, envía las respuestas como HTML de Telegram
convertido desde el Markdown del agent para que los bloques de código y los
enlaces sobrevivan, y responde en el tema de un grupo con temas dentro de ese
tema.

## Mattermost { #mattermost }

Mattermost es autoalojado, así que un bot lleva **la URL de tu servidor** además de
su token — no hay ningún api.mattermost.com al que recurrir. Registrar uno sin ella
se rechaza en vez de aceptarse y descubrirse más tarde: un bot que no sabe cuál es
su servidor no puede responder, no puede abrir su stream de events y no puede
descargar un fichero que alguien adjuntó.

Dos formas de entrar; elige según si tu Mattermost puede alcanzar este despliegue.

**Stream de events (nada expuesto).** La elección correcta detrás de una VPN.

1. En Mattermost, *Integrations → Bot Accounts → Add Bot Account*. Copia el token
   que muestra una sola vez — ese es el **token del bot**.
2. Regístralo: **Channels → Add channel**, plataforma `mattermost`, pega el token y fija
   **Server URL** a tu Mattermost, por ejemplo
   `https://mattermost.acme.internal` o `http://mattermost:8065` dentro de compose.
   Deja vacío el token del webhook.
3. Añade el bot al **equipo**, cosa que *Integrations → Bot Accounts* no hace:
   *System Console → User Management → Users*, búscalo, **Manage Teams**, añade el
   equipo — o `mmctl team users add <team> <bot>`. Hasta entonces un canal se niega
   a admitirlo, diciendo que «is not a part of this team».
4. Invita al bot a un canal. El despliegue abre un WebSocket autenticado hacia tu
   servidor y todos los events `posted` llegan por ahí.

**Todas** las publicaciones, que es lo que hay que saber de este transporte: el
socket no es una suscripción a los mensajes dirigidos al bot, es el canal. Así que
la regla es la que sigue un compañero — y pertenece al bot y no a una forma
concreta de alcanzarlo, de modo que el webhook saliente de abajo obedece la misma
tabla:

| Dónde | Cuándo responde |
|---|---|
| **Un mensaje directo** | Siempre. No hay nadie más en la sala, así que exigir una mención sería pedirle a alguien que se dirija al único participante |
| **Un canal** | Solo cuando se le nombra — `@the-bot`, o `@agent-slug` para uno de los agents expuestos en él |

*Cómo* sabe que lo han nombrado es cosa del transporte, porque los dos payloads
dicen cosas distintas:

| Transporte | Qué lee |
|---|---|
| **Stream de events** | La propia lista de menciones de Mattermost en cada event `posted`, contra la cuenta que el bot resuelve una vez por sesión |
| **Webhook saliente** | La `trigger_word` con la que disparó la integración. El cuerpo no lleva lista de menciones, así que aquí no se puede leer `@the-bot` — pon la trigger word igual al handle del bot si es así como la gente debe alcanzarlo |

Un `@agent-slug` no necesita ninguna de las dos y funciona en ambos: se lee del
texto, porque un slug es un nombre de *este* producto y por tanto nunca está en una
lista de menciones.

El stream lee la lista en vez de buscar coincidencias en el texto por la misma
razón: `@ada` es alguien cuyo nombre visible el bot no puede resolver, y un bot
llamado `bot` no debería responder a la palabra «robot». Un handle que resulta no
nombrar ni al bot ni a uno de sus agents se responde en un mensaje directo y se
deja pasar en un canal, porque allí era el compañero de alguien.

**`@channel`, `@all`, `@here` y `@everyone` se dirigen a la sala, no a un agent.**
Tienen la forma de un slug, y una mención a todo el canal mete a todos los miembros
del canal — el bot incluido — en la propia lista de menciones de la plataforma, así
que un anuncio se leía como un mensaje que nombra a un agent que nadie tiene. Esos
cuatro handles nunca son una mención aquí, y un agent llamado como uno de ellos
recibe `-agent` al final de su handle para que siga siendo alcanzable.

Si la cuenta propia del bot no se puede resolver, el stream responde a todo, como
hacía antes de que existiera esta regla: quedarse callado en un servidor que no
quiso decir quiénes somos es el peor de los dos fallos. El webhook no tiene ese
recurso ni lo necesita — una integración sin trigger word es un filtro de canal, y
un filtro de canal no dice nada sobre para quién era una publicación.

**Webhook saliente.** Para un Mattermost que puede alcanzar esta API.

1. Crea la cuenta de bot y regístrala exactamente como arriba.
2. *System Console → Integrations → Outgoing Webhooks → Add*, con la URL de
   callback `https://your-api.example.com/api/v1/mattermost/BOT_ID/webhook` — el id
   del bot está en la fila una vez registrado, y `channel-webhook-register` imprime
   la URL entera. Sus **trigger words** son lo que se dirige al bot en este
   transporte, según la tabla de arriba; déjalas vacías y solo un `@agent-slug` lo
   alcanza en un canal.
3. Mattermost muestra un **token** cuando se guarda el webhook. Pega ese en el
   campo **Webhook token** del bot aquí.

El token es lo único que la gente equivoca dos veces, así que merece la pena ser
exacto: **lo genera Mattermost, y tú lo pegas en AgenticOS** — la dirección
contraria a Telegram, donde este despliegue genera el secreto y se lo entrega
cuando se registra el webhook. Para Mattermost no se genera nada localmente, porque
un valor generado localmente es uno que Mattermost nunca va a enviar.

Mattermost no firma los cuerpos de webhook como hace Slack — el token del payload
es toda la comprobación — así que **un bot sin token de webhook rechaza todas las
llamadas** en vez de confiar en ellas. La fila del bot lo dice con una insignia.

Cualquiera de las dos formas se puede hacer desde la línea de comandos, que es la
única forma en un despliegue al que nadie apunta con un navegador:

```bash
uv run agenticos cmd channel-add-bot \
    --platform mattermost --name "Ops" --token <bot-token> \
    --org <organization-id> \
    --api-base-url https://mattermost.acme.internal \
    --webhook-secret <token-from-mattermost>   # omit for the event stream

uv run agenticos cmd channel-test-message --bot-id <uuid> --chat-id <channel-id>
```

**Qué puede ser una URL de servidor.** Se comprueban el esquema y la forma — http
o https, un host, sin `user:pass@` — y una dirección privada o de loopback se
permite deliberadamente, porque un Mattermost autoalojado detrás de una VPN es
justo el despliegue para el que existe esto. Las direcciones de metadatos de
instancia son la excepción y se rechazan. La frontera que realmente sostiene esto
es el permiso para gestionar bots de canal, no esta comprobación.

Las aprobaciones y las preguntas llegan como botones. Mattermost llama a cada botón
en `https://your-api.example.com/api/v1/mattermost/BOT_ID/actions`, así que el
servidor de Mattermost tiene que alcanzar la API; en una red privada, añade el
host de la API en *System Console → Developer → Allow untrusted internal
connections to*.

### `/agent` en Mattermost { #agent-in-mattermost }

Un slash command pregunta al agent donde se escribió, como `/agent` en Slack. En
Mattermost: *Integrations → Slash Commands → Add Slash Command*: palabra
`agent`, método `POST` y la URL de solicitud que muestran los ajustes del bot.
Mattermost muestra entonces un token; pégalo en el campo **Slash command token**
del bot. Cada solicitud lo lleva, y un bot sin él las rechaza todas.

## Microsoft Teams { #microsoft-teams }

Un bot de Teams es un recurso **Azure Bot**, creado como **single-tenant** en tu
inquilino de Entra — Microsoft dejó de crear recursos de bot multiinquilino en julio
de 2025 — y al que se llega por el protocolo propio del Bot Connector en vez de por
un SDK (#1985).

1. En el portal de Azure, crea un **Azure Bot** de tipo *Single Tenant*. En
   *Configuration*, copia el **Microsoft App ID** y el inquilino, y en *Certificates
   & secrets* del registro de la aplicación, crea un **secreto de cliente**.
2. Regístralo: **Canales → Añadir canal**, plataforma `teams`, con el secreto como
   token y los ID de aplicación e inquilino al lado.
3. Pega el **endpoint de mensajería** que muestran los ajustes del bot en la
   configuración del Azure Bot, y activa allí su canal de **Microsoft Teams**.
4. **Descarga el paquete de la aplicación** desde los ajustes del bot — el manifiesto
   y sus dos iconos, construidos para este bot — y súbelo al catálogo de
   aplicaciones de Teams de tu inquilino. Añade la aplicación a un chat personal, un
   chat de grupo o un equipo.

**Cada actividad se verifica antes de leerse.** El Connector firma cada una con un
JWT: la firma se comprueba contra las claves que publica el emisor, el emisor debe
ser el Bot Framework o tu propio inquilino, la audiencia debe ser el App ID de este
bot, no puede haber caducado y el servicio que nombra debe ser el de la actividad.
Una petición que falla cualquiera de estas comprobaciones es un 401. El bot es
single-tenant, así que también se rechaza una actividad de otro inquilino, y el
token del bot solo se envía a una dirección del Bot Connector.

| Dónde | Cuándo responde |
|---|---|
| Un chat personal | A cada mensaje. El chat es una conversación |
| Un chat de grupo | Cuando se le @menciona |
| Un canal | Cuando se le @menciona, en el hilo en el que se le preguntó — cada hilo su propia conversación |

**Quién habla** es el ID de objeto de Entra del miembro, el mismo en cada chat del
inquilino. Donde este despliegue inicia la sesión de las personas con Entra en el
mismo inquilino, una cuenta de Teams sin vincular se vincula al miembro que inicia
sesión con la dirección que da la lista de miembros de Teams, sin enlace que pulsar;
en cualquier otro caso se aplica el flujo de vinculación como en toda plataforma, y
`require_link` decide como allí.

**Las aprobaciones y las preguntas son Adaptive Cards.** Una pulsación se decide
como el miembro que pulsó, y la tarjeta se sustituye por lo elegido. Un archivo que
envía el agent llega a un chat personal a través de la tarjeta de consentimiento de
Teams; en un chat de grupo o un canal la respuesta lo nombra en su lugar, porque
Teams no acepta ahí archivos de un bot. Un gráfico se dibuja en el mensaje.

**Un trigger puede publicar su respuesta en Teams.** Se recuerda cada conversación en
la que está el bot, y un trigger puede nombrar uno de los canales de equipo o chats de
grupo de la organización: la respuesta de cada ejecución se publica entonces ahí como
un mensaje nuevo, con el nombre del trigger. Nunca se ofrece un chat personal — es de
alguien.

## Discord { #discord }

Un bot de Discord es una aplicación del Discord Developer Portal, a la que se habla
por el Gateway y la API REST propios de Discord, sin ninguna biblioteca en medio
(#2079). Los mensajes, los comandos slash y las pulsaciones de botones llegan todos
por la única conexión de Gateway del bot, así que **un despliegue no necesita
dirección pública para Discord**, y un bot de Discord no tiene modo webhook.

1. En el [Developer Portal](https://discord.com/developers/applications), crea una
   aplicación. En **Bot**, restablece y copia el **token**, y activa **Message
   Content Intent**. Sin él Discord rechaza la conexión, y la fila del bot dice qué
   interruptor activar. En **Installation**, pon **Install Link** en *None* y
   luego desactiva **Public Bot** en **Bot**. Cualquiera puede añadir un bot
   público a su propio servidor, así que el bot se
   niega a conectarse mientras sea público. Uno que se vuelve público mientras
   está conectado se detiene la próxima vez que se añade a un servidor.
2. En **OAuth2 → URL Generator**, marca los scopes `bot` y `applications.commands`
   y los permisos **View Channels**, **Send Messages**, **Send Messages in
   Threads**, **Create Public Threads**, **Read Message History** y **Attach
   Files**, y abre la URL para añadir el bot a un servidor. Sin **Attach Files**,
   un archivo o un gráfico que envía el agente se rechaza, y el texto enviado con
   él también se pierde.
3. Regístralo: **Canales → Añadir canal**, plataforma `discord`, con el token.

**Autoriza los servidores expresamente.** En el formulario para añadir o editar el bot, indica los **ID de servidores de Discord autorizados**. Activa el modo de desarrollador de Discord, copia el ID de cada servidor y separa los ID con comas o espacios. En la CLI, pasa `--discord-guild 123456789012345678` a `channel-add-bot`; repite la opción para varios servidores. Una lista vacía permite solo mensajes directos. Las instalaciones anunciadas por READY no son autorizaciones; quitar y volver a añadir el bot no evita la lista.

`/ask` confirma la interacción en privado antes de consultar el canal o comprobar el acceso en la base de datos. La pregunta solo se publica tras la admisión; los rechazos y las invitaciones para vincular la cuenta permanecen privados. Las invitaciones consumen el límite del remitente. La autorización del servidor elige ubicaciones; `jwt_linked` o `require_link` sigue exigiendo una cuenta de la organización. Acceder a conversaciones guardadas desde la consola requiere **View Channel** y **Read Message History**, además de pertenecer al hilo si es privado.

El bot registra sus comandos al conectarse: `/ask`, `/agents`, `/new`, `/link`,
`/unlink` y `/help`, y **Ask the agent** en *Apps* sobre cualquier mensaje. Discord
puede tardar un rato en mostrar los comandos nuevos en todos los servidores.

| Dónde | Cuándo responde |
|---|---|
| Un mensaje directo | A cada mensaje. El mensaje directo es una conversación, y `/new` la empieza de nuevo |
| Un canal de servidor | Cuando se le @menciona o un mensaje le responde. La respuesta abre un hilo sobre la pregunta, y el hilo es la conversación |
| Un hilo que abrió el bot | A cada mensaje del hilo |
| `/ask` | En cualquier sitio: se publica la pregunta, de hasta 2000 caracteres, y la respuesta va en un hilo debajo |
| *Apps → Ask the agent* | Sobre cualquier mensaje: se pregunta al agent por ese mensaje, en un hilo sobre él |

`/agents`, `/link` y los demás comandos responden en privado, solo a quien los
ejecutó. `/new` empieza de nuevo la conversación de un hilo; en un canal del
servidor, donde cada mención ya empieza una nueva, lo dice en su lugar. En un canal donde el bot no puede abrir hilos, la respuesta es en cambio
una respuesta a la pregunta, y responder a ella continúa esa conversación.

**La respuesta se escribe en su sitio.** El bot publica un mensaje y lo edita a
medida que la respuesta llega, y pasados 2000 caracteres continúa en mensajes
siguientes. Solo se escribe texto que los guardrails ya han revisado, así que un
secreto redactado no se muestra nunca, ni por un momento. Nada de lo que publica el
bot puede notificar a nadie: `@everyone` y las menciones de roles en una respuesta
quedan como texto plano.

**Las aprobaciones y las preguntas son botones**, o listas de selección para una
pregunta con más de 25 opciones. Debajo de cada respuesta hay pulgares, y un pulgar
abajo abre un formulario de Discord que pregunta qué estaba mal. Discord no tiene
reacción sobre la pregunta.

**Quién habla** es el ID de usuario de Discord, vinculado a un miembro con `/link`
como en todas las plataformas. Una conversación de un canal de servidor se muestra
en la consola a quienes todavía pueden ver ese canal, según los roles del servidor
y las sobrescrituras de permisos del canal, que es lo que significa estar en un
canal en Discord. Un hilo privado es solo por invitación, así que es una sala
propia: una conversación de él se muestra solo a quienes siguen en ese hilo, y
las notas que el agent guarda allí no se comparten con el canal.

**Un hilo al que el bot se incorpora a mitad se lee primero.** Los últimos
mensajes del hilo, con quién los escribió y cuándo, y los archivos que la gente
publicó allí llegan al agente antes de que responda, como en Slack y Mattermost;
los archivos que publicó el propio bot se dejan fuera. Hace falta **Read Message
History**, y `read_channel_history` lee un hilo o un canal igual donde la
vinculación lo permite.

### Los roles de un servidor como grupos { #discord-roles }

Un administrador puede asignar un rol de uno de los servidores del bot a un grupo de
la organización (#2569). Abre **Canales** y luego la acción **Roles de Discord** en
la fila del bot. El servidor y el rol se eligen entre lo que el propio bot lee de
Discord, así que no se ofrecen `@everyone` ni los roles que Discord gestiona para un
bot. Crear una asignación requiere `members:manage`, y las rutas requieren además
`channels:manage`.

Un miembro vinculado que tiene el rol entra en el grupo antes de cada mensaje que
envía a cualquiera de los bots de Discord de la organización, y de nuevo con el
trabajo `sync-discord-roles` cada 15 minutos. Sale del grupo cuando se le quita el
rol, y el trabajo es lo que lo detecta en alguien que ha dejado de escribir al bot.
El bot pregunta a Discord por los roles de un miembro cada vez, así que no necesita
ningún intent privilegiado, y una asignación nueva coloca a las personas en su
siguiente mensaje y no de inmediato.

Esas comprobaciones antes de un mensaje cuentan contra el `rate_limit_rpm` del
remitente en un cupo propio, incluidos comandos como `/help`. A quien lo supera se
le pide que vaya más despacio, y no se vuelve a preguntar a Discord hasta que pase
el minuto.

Una asignación también puede nombrar un rol de la organización. Una persona
vinculada con el rol que todavía no es miembro entra entonces en la organización con
ese rol, lo que ahorra invitar uno a uno a todos los de un servidor de comunidad.
Nombrar un rol requiere `roles:manage`, y solo un rol que el propio rol de quien
llama supera estrictamente. El orden entre varios roles que coinciden y las reglas
sobre lo que la sincronización nunca toca son las de la
[sincronización del directorio](directory.md#what-the-sync-never-touches):

- Solo cuenta una cuenta vinculada. Un usuario de Discord que nunca ejecutó `/link`
  no se coloca en ningún sitio, tenga los roles que tenga.
- Un rol coincide solo en el servidor que nombra la asignación, leído por el bot al
  que pertenece la asignación. El mismo ID de rol en el servidor de otro bot no
  coincide con nada.
- La sincronización cambia solo las filas que ella misma creó, marcadas **Discord**
  en la página de miembros y en un grupo. Una pertenencia a un grupo añadida a mano
  se queda cuando se quita el rol, una pertenencia que creó un administrador
  conserva su rol, y a un propietario nunca se le degrada ni se le quita. Quien
  entró en la organización por la sincronización sale de todos sus grupos cuando
  la sincronización lo quita, como quien se quita a mano.
- Si un servidor no se puede leer, sus grupos asignados se conservan. Los posibles roles de organización solo conservan autoridad ya existente; una asignación `viewer` sin leer no conserva `admin`. Las concesiones confirmadas siguen aplicándose.

Cada reconciliación comparte un presupuesto de red de diez segundos. Las consultas REST comparten el `rate_limit_rpm` del bot por usuario entre todas sus cuentas vinculadas, separado del límite de comprobaciones por mensaje. Borrar un grupo asignado también requiere `channels:manage`.

El descubrimiento permite una solicitud simultánea por organización. La paginación y las consultas de roles comparten diez segundos, con un máximo de ocho servidores en paralelo. Superar los 2500 servidores admitidos o recibir una respuesta incompleta produce un error explícito.

Desvincular la cuenta de Discord retira lo que dieron sus roles, salvo que otra
cuenta de Discord vinculada lo siga ganando. Borrar una asignación retira lo que dio
en el siguiente mensaje de cada persona o en la siguiente ejecución del trabajo.
Borrar el bot o un grupo asignado, o darle al bot un token nuevo, retira lo mismo,
y por eso pide lo que pide borrar cada una de esas asignaciones. Cada cambio queda en el registro de auditoría como `discord.member_added`,
`discord.role_changed`, `discord.member_removed`, `discord.group_joined` o
`discord.group_left`, y crear o borrar una asignación como
`discord.mapping_created` o `discord.mapping_deleted`.

Todavía no: una conexión sirve a un bot en como mucho 2500 servidores, por encima
de los cuales Discord exige sharding y el bot se detiene con ese motivo. Las
herramientas de miembros del canal, detalles y búsqueda de canales no se ofrecen en
Discord.

Cuando ambas fuentes conceden pertenencia, el directorio tiene prioridad al iniciar
sesión. Al terminar su acceso, un rol vigente de Discord asume la misma pertenencia
sin revocar claves. Véase [el traspaso de pertenencia](directory.md#what-the-sync-never-touches).

## Un bot que no puede arrancar se detiene, en vez de reintentar { #a-bot-that-cannot-start-stops-rather-than-retrying }

El polling de Telegram, el Socket Mode de Slack, el stream de events de Mattermost
y el Gateway de Discord corren todos bajo un mismo supervisor que reconecta una sesión caída. Un valor de
configuración que falta o que se rechaza no es una sesión caída, y el supervisor lo
trata de otra manera: registra el bot como caído con el motivo, lo escribe en el
log una vez y se detiene. Nada de lo que haga cambiaría la fila — un operador tiene
que añadir el token `xapp-` de Slack o la URL del servidor de Mattermost, o
sustituir un token de bot que Telegram rechaza.

Esto importa más de lo que parece. Reintentar un arranque que falla de inmediato
nunca suspende, así que el supervisor gira sin ceder y todas las demás tareas del
proceso — peticiones, health checks, WebSockets de chat — dejan de planificarse. La
API sigue en pie y no responde nada. Los dos estados que lo disparan son filas
corrientes que alguien todavía no ha rellenado, así que el fallo estaba a un
reinicio de distancia en cualquier momento.

Si un bot está callado, busca `not started` en el log antes de dar por supuesto que
hay un problema de red.

Una sesión caída es otra cosa: esa sí se reintenta, esperando cinco segundos y
doblando hasta un minuto, así que a un servidor caído durante una hora no lo
machacan 720 veces todos los bots que hay en él. Una sesión que terminó
limpiamente vuelve a empezar la escalera. La línea registrada antes de cada espera
nombra el retardo que va a esperar, y el mismo bucle sirve a todas estas plataformas,
así que la política no puede desviarse entre ellas.

---

## Aprobaciones y preguntas como botones { #approvals-and-questions-as-buttons }

Un run iniciado en Slack, Teams, Telegram, Mattermost o Discord puede detenerse por una persona:
una llamada a herramienta con aprobación espera una decisión, o el agent hace una
pregunta `ask_user`. El chat recibe las opciones donde está — un mensaje por
aprobación con **Approve** y **Reject**, un mensaje por pregunta con sus opciones
y **Skip**. Pulsar uno decide o responde, el run continúa y su respuesta se
publica en el mismo chat; el mensaje pulsado conserva la pregunta y muestra lo
elegido.

Una pregunta que admite varias respuestas queda abierta: cada pulsación añade o
quita una opción, el chat recibe lo elegido hasta ahora y **Done** las envía. La ejecución sigue
cuando todo aquello por lo que se detuvo está resuelto - cada aprobación decidida,
cada pregunta que hizo respondida, en cuantos mensajes sea - y la última respuesta
las envía todas juntas.

Una pregunta que admite una respuesta con palabras propias — y una sin ninguna
opción — lo dice debajo: el siguiente mensaje de esa persona en ese chat es la
respuesta, no un turno nuevo junto a una ejecución que sigue esperando. Solo
cuenta un mensaje de la persona a quien se hizo, y va a la pregunta más antigua.

Una pregunta solo puede hacerse a un miembro vinculado. Un remitente sin cuenta
vinculada se ejecuta como el publicador del binding, así que una pregunta que el
agent le haga se rechaza y el turno sigue sin la respuesta, en vez de esperar a
botones que solo el publicador podría pulsar.

Una aprobación se lee como una petición del agent, con su propia voz: lo que quiere
hacer y el único argumento que dice de qué trata — *I need your approval to
**search in Notion**: “what's new in releases”*. Una herramienta de un servidor MCP
se nombra por su servicio y su acción, no por su identificador con prefijo, y el
resto de los argumentos se queda en **Approvals** de la consola, donde la misma
petición empieza por esa línea y pliega lo demás tras **Details**.

Una pulsación actúa como la persona que pulsó, mediante su cuenta enlazada:
decidir requiere `approvals:decide`, y solo la persona a quien se preguntó puede
responder. Una decisión tomada en otro sitio actúa como la persona que la tomó, incluido un app admin ([más abajo](#decided-outside-the-chat)). A quien no tiene cuenta enlazada se le pide `/link`. Un run espera una
respuesta durante `QUESTION_EXPIRY_HOURS` y una decisión durante
`APPROVAL_EXPIRY_HOURS`, como en cualquier otro sitio. Slack firma sus
pulsaciones; Mattermost no firma nada, así que cada botón lleva la firma propia
de este despliegue sobre su valor y una pulsación sin ella se rechaza.

Quién pulsó
tampoco se toma del cuerpo de la pulsación: el trigger id que el servidor de
Mattermost firma en cada pulsación nombra a la persona y se comprueba con la clave
pública que el servidor publica, así que una pulsación que nadie puede probar
también se rechaza.

**Una navegación que necesita a una persona.** Un agente con el
[navegador web](reference/capabilities.md#browser-automation) publica en su paso el
enlace a la vista en directo de la navegación — *Watch or take over* — en Slack,
Telegram y Mattermost por igual. Cuando la página necesita a alguien, un inicio de
sesión o un captcha, el paso cambia a *Needs you* y dice para qué, y nombra quién tiene
el navegador mientras una persona lo maneja. El enlace abre la consola, así que tomar
el control necesita una cuenta en ella: la persona para la que actúa el run, o quien
puede editar el agente.

### Decidido fuera del chat { #decided-outside-the-chat }

Una petición hecha en un chat también se puede decidir en **Approvals** de la
consola, en el chat web o por la API HTTP. Cuando una persona lo hace y con ello
queda resuelto lo último que el run esperaba, el servidor continúa el run como ella
y publica la respuesta en el chat donde se preguntó. La consola lo dice en vez de
reanudar el run por su cuenta, así que el run se continúa una sola vez. Una
pregunta funciona igual cuando la persona a quien se hizo la responde en la consola.

Una decisión tomada con una clave de API se registra y no continúa el run: un run
que inició una persona solo lo continúa una persona. Por la API, `continues_in_chat`
le dice a un cliente en qué caso está (véase [Ejecutar un agent](api.md#running-an-agent)).

Tres finales que ocurren fuera del chat publican allí un mensaje fijo cada uno, para
que nadie espere botones que ya no hacen nada. Los botones abiertos del chat se
cierran a la vez.

| Final | El chat recibe |
|---|---|
| El run caduca, tras `APPROVAL_EXPIRY_HOURS` para una decisión o `QUESTION_EXPIRY_HOURS` para una pregunta | "This request expired without a decision." |
| Alguien pulsa **Stop** en la consola | "This run was stopped from the console." |
| Un run se decidió y nada lo continuó en aproximadamente una hora | "A decision was recorded, but the run could not continue." |

El último es una red de seguridad. Se queda callado en dos casos: cuando la decisión
tiene más de 7 días y cuando el chat ya supo que el run no pudo continuar. Un chat
recibe como máximo un aviso cada vez que un run se aparca.

Un botón pulsado después de que la petición se respondiera en otro sitio responde
"That has already been answered." y no cambia nada.

Una continuación puede ser rechazada, por ejemplo si el chat empezó de nuevo desde
la pregunta, su política de acceso ahora rechaza a quien preguntó, quien decidió ya
no puede decidir aprobaciones, el bot se quitó del agente o el agente ya no se puede
construir. El chat lo sabe una vez: "That was decided outside the chat, but the run
could not continue."

**Resume** en la página del run sigue funcionando, hasta
aproximadamente una hora después de la última decisión. Se ejecuta como antes, sin la comprobación de la
sala, y publica la respuesta solo en un chat que aún admita a quien preguntó. Una
comprobación de pertenencia que la plataforma no puede responder no detiene una
continuación.

Un run aparcado antes de 1.29.0 no tiene chat registrado, así que la consola lo
continúa como siempre. Si nadie lo reanuda en aproximadamente una hora desde la
última decisión, la red de seguridad lo termina sin mensaje.

El worker envía las respuestas y los avisos de finalización necesarios mediante
trabajos duraderos guardados en la misma transacción que el resultado o el cierre.
Tras un reinicio o un fallo temporal de envío, reintenta únicamente la entrega
hasta tres veces, sin repetir el agente ni las herramientas aprobadas. Antes de
enviar, vuelve a comprobar el bot, la sesión y la política de acceso al chat.
Un destino cuyo acceso se haya revocado no recibe nada. Las plataformas sin envío
idempotente pueden mostrar un duplicado si aceptaron el mensaje pero se perdió
la confirmación. Los intentos agotados aparecen en **Admin → Background jobs**
(`deliver-channel-handback`).

### Pasos siguientes como botones { #next-steps-as-buttons }

A la respuesta de un agent con [Follow-ups](reference/capabilities.md#follow-ups)
que ofreció pasos siguientes le sigue un mensaje de botones, uno por paso - *Hazlo
una tabla*, *Haz esto cada lunes*. Pulsar uno es quien pulsa diciendo ese paso: se
responde como su siguiente mensaje en ese chat, sujeto a todo lo que lo está uno
escrito - la vinculación, la política de acceso del bot, el límite de frecuencia -
y el mensaje pulsado muestra después el paso en lugar de sus botones.

Cada mensaje se pulsa una vez; cualquiera en la sala puede hacerlo, y la respuesta es
suya. Una respuesta que se detuvo en una aprobación o una pregunta ofrece esas en
su lugar. Las tarjetas de configuración que un agent puede proponer solo las
dibuja la consola.

## Una bienvenida para un miembro nuevo { #a-welcome-for-a-new-member }

Cuando alguien se une a la organización - por una invitación, uniéndose a la
organización por defecto o por la sincronización del directorio - cada bot activo
de Slack, Mattermost y Teams de la organización lo busca por su dirección de
correo y, donde lo encuentra, le envía un mensaje directo en el idioma de su
consola: quién es, que puede preguntarle ahí mismo y la dirección de la consola.
Es una tarea en segundo plano que se intenta una vez; un bot que no lo encuentra,
o una plataforma que se niega, no cuesta nada más. Slack necesita el scope
`users:read.email` para la búsqueda; Teams lo encuentra en el directorio del
tenant.

## Reacciones y valoraciones { #reactions-and-ratings }

Dos detalles que comparte cada plataforma de chat aquí, ambos en los ajustes del
bot:

- **Una reacción a la pregunta.** El bot reacciona en cuanto llega una pregunta
  que va a responder, con el emoji nombrado en sus ajustes - `eyes`, por ejemplo -
  para que quien pregunta vea que se le ha oído. Slack y Mattermost aceptan el
  nombre de cualquier emoji; Telegram permite un conjunto fijo, y los nombres que
  acepta son `eyes`, `+1`, `ok_hand`, `fire`, `thinking_face`, `writing_hand`,
  `zap` y `hourglass`.
- **Pulgares bajo una respuesta.** Una respuesta terminada lleva un pulgar arriba y
  otro abajo. Pulsar uno es la valoración de la respuesta de ese run por quien
  pulsa, la misma que registra la consola, y pulsar de nuevo la cambia. Un pulgar
  abajo pregunta luego **¿qué estuvo mal?** en un modal de Slack o un diálogo de
  Mattermost; la respuesta queda como comentario de la valoración. Telegram acepta
  los pulgares sin la pregunta.

**Valorar en Mattermost no requiere una cuenta de AgenticOS.** La identidad del
participante, firmada por el servidor, es la autora del voto. Cada persona puede
cambiar su voto; los botones siguen disponibles para las demás y la confirmación
es privada. El comentario negativo pertenece al mismo participante. Slack y
Telegram conservan el requisito de vinculación; aprobar herramientas también
requiere pertenecer a la organización. Las valoraciones de canal devuelven
`user_id: null` en la API, sin atribuirlas al propietario del bot. Actualiza la
base de datos antes de iniciar esta versión. Revertir la migración elimina las
valoraciones de canal y conserva las de cuentas.

La tarjeta *Answer quality* incluye estos votos y los separa por superficie,
para mostrar diferencias entre la consola y Mattermost.

## Qué comparte cada canal { #what-every-channel-shares }

- **Un mensaje enviado mientras el agent trabaja se suma a su tarea.** De la
  persona cuyo turno es, un mensaje normal en el mismo chat llega al turno en curso
  en su siguiente paso en lugar de esperar un turno propio; uno que llega después
  del último paso del agent se responde como el siguiente mensaje. `/btw` seguido de
  una pregunta pregunta aparte: se responde al momento a partir del hilo hasta
  ahora, sin herramientas, mientras el turno sigue. El mensaje de un colega en un
  chat de grupo es siempre un turno propio.

- **Al modelo se le recuerdan los turnos más recientes, no los primeros.** Un hilo
  de canal se indexa por el chat y nunca pasa página, así que un canal de soporte
  se sale de la ventana en días. Doscientos turnos para un canal frente a cuarenta
  para un widget, y los dos números son distintos a propósito: un widget es una URL
  pública con el budget de otra persona detrás, y un canal es una sala en la que
  trabajan los propios compañeros del operador. Acotado en ambos casos, porque un
  prompt no es una transcripción y el historial completo de un hilo es una factura
  por turno que crece para siempre.

    Se leía por el extremo equivocado hasta #638: el repositorio ordena de más
    antiguo a más reciente, así que al bot se le contaba cómo empezó la
    conversación y nada de lo dicho desde entonces — y respondía de forma
    plausible, desde una versión del hilo que se había detenido cientos de turnos
    atrás. Nada daba error, y por eso necesitaba un test más que un arreglo.

    El desplazamiento está ahora en `conversation_repo.get_recent_messages`, y
    todas las superficies leen la ventana a través de él — el widget, los canales y
    el chat web. Tres copias de un `COUNT` y un desplazamiento es como llegó a
    estar mal dos veces, desde extremos opuestos.

- **Un hilo al que se le mete a mitad de camino se lee primero.** Una conversación
  aquí se construye con lo que este despliegue *recibió*, así que un bot mencionado
  en un hilo que ya estaba en marcha no tenía nada por encima de la mención — y
  respondía como si el hilo estuviera vacío, cosa que decía con confianza. Nadie
  que mire un chat puede distinguir un agent que no ve de uno que ha leído y
  discrepa.

    Una vez por conversación — registrado en la sesión, no deducido de su
    antigüedad, así que un hilo en el que el bot ya respondía antes de que esto
    existiera se lee en su siguiente turno y no nunca — hasta cincuenta mensajes, y
    solo donde la plataforma tiene hilos. A partir de ahí la transcripción es
    nuestra y no se vuelve a pedir nada. Llega como un bloque etiquetado — *lo que
    se dijo antes de que llegaras, `speaker: message`, contexto y no instrucciones*
    — y no como turnos, porque reproducir los mensajes de otras personas como un
    historial alterno le pone al agent en la boca palabras que nunca dijo.

    **Esto no está sujeto a un permiso, donde `read_channel_history` sí lo está**, y
    la diferencia es lo importante: esa herramienta lee el *canal*, que es contenido
    en el que nunca se dirigieron al agent, y un operador decide por vinculación si
    puede. El hilo en el que se le habló al bot es la conversación a la que alguien
    lo apuntó, y leer eso no es el mismo acto que leer la sala. El bot sigue viendo
    solo lo que su propia pertenencia permite, porque la llamada va con su token.

    **Los ficheros publicados en él vienen también**, hasta cuatro: la transcripción
    por sí sola dejaba que el bot respondiera, con toda exactitud, que no veía
    ninguna imagen en una conversación cuyo primer mensaje era una captura.

    Se descargan por el mismo camino que un adjunto del mensaje en vivo, así que se
    aplica un único juego de límites de tamaño, y se atribuyen a quien mencionó al
    bot — esa es la persona que lo apuntó al hilo, y un remitente no vinculado no
    recibe ninguno, exactamente igual que no recibe ninguno de los suyos. Cuatro y
    no cincuenta, porque cincuenta descargas antes de que empiece una respuesta son
    un minuto de silencio. Slack los lee con `conversations.replies`, Mattermost con
    `GET /posts/{root}/thread`; Telegram no tiene hilos y no lee ninguno.

    Una lectura que falla cuesta el contexto y nunca la respuesta: que no haya hilo,
    una plataforma sin ellos, un rechazo o un error producen todos una respuesta sin
    el historial por encima, que es peor que una con él y mejor que ninguna.

- **Un bot responde como un agent.** Un usuario bot es una única identidad en el
  chat: el mismo avatar, el mismo nombre, sea cual sea el agent que produjo la
  respuesta. Así que un bot sirve exactamente a un agent, y vincular un segundo se
  rechaza — en el selector del Builder, que no ofrece un bot en el que ya está el
  agent de otra persona, y en la base de datos, que es lo que lo hace cierto.

    Un agent va en el otro sentido sin restricciones: un agent puede responder en un
    bot de Slack, un bot de Telegram y dos servidores de Mattermost a la vez, y cada
    una de esas vinculaciones lleva sus propias instrucciones, sus propias consultas
    de canal y su propio alcance de workspace.

    Esto sustituyó al enrutado de varios agents detrás de un bot con `@slug`.
    Funcionaba y se leía mal: alguien en un canal tenía que escribir un handle para
    elegir entre agents que no veía, y a un mensaje que no nombraba a ninguno se le
    respondía con una lista de handles en vez de con una respuesta. Un segundo bot
    le cuesta a un operador dos minutos y hace que el chat diga con qué agent está
    hablando, cosa que ningún enrutado puede. `@slug` se sigue interpretando — como
    un alias del agent que hay detrás de este bot, rechazado cuando nombra a
    cualquier otro.
- **Un mensaje de voz se transcribe, salvo que el bot lo tenga desactivado.**
  Es el único adjunto que no se le puede entregar a un agent como fichero: lee un
  PDF y mira una captura, y un blob `audio/ogg` es un recuento de bytes.

    La grabación se descarga, se transcribe y se **teje dentro del mismo turno**
    como una cita etiquetada — `[Voice message, transcribed]` — en vez de enviarse
    como un segundo mensaje. Etiquetada a propósito: el reconocimiento de voz oye
    mal los nombres, los números y cualquier cosa dicha con tráfico de fondo, así
    que un agent al que se le dice la fuente puede matizar una cifra que oyó a
    medias y preguntar, mientras que uno al que no se le dice nada la afirma como un
    hecho. Es también lo que mantiene una grabación como cita y no como
    instrucciones que alguien escribió. Una nota de voz con pie sigue siendo un
    mensaje, porque eso es lo que alguien envió.

    El formulario de cada bot bajo **Channels** ofrece tres respuestas. **Inherit**,
    la opción por defecto, usa el provider y el modelo del agent al que sirve el
    bot, después los de la organización, y el idioma del agent, después el de la
    organización ([el orden](ml-services.md#which-engine-a-recording-reaches)).
    **Choose** nombra el par propio del bot — un provider y uno de sus modelos, de
    `app/core/catalog/speech_to_text_models.json` — y la clave del vault con la
    que se envía. **Off** no transcribe nunca, digan lo que digan el agent o la
    organización. Junto a la elección, Inherit nombra a qué se resuelve, cuál de
    los dos lo eligió y si ese nivel aún necesita una clave.

    **El par propio de un bot necesita su propia clave**: una clave de API cuyo
    propósito es el provider, y **visible para toda la organización**, porque las
    notas de voz de un bot se pagan en nombre de quien le escriba. La clave
    privada de un miembro se rechaza al guardar el bot, y una que se vuelve
    privada después deja de usarse. El selector ofrece solo las claves que
    encajan, dice cuándo un provider no tiene ninguna y enlaza al vault para
    añadir una. Un bot que hereda toma la clave del nivel heredado junto con su
    par, nunca una propia. Borrar la clave deja el par pidiendo otra, y el bot no
    transcribe nada hasta que alguien la elige.

    Heredar no cuesta nada hasta que alguien configura un provider: sin ninguno en
    el agent ni en la organización no se transcribe nada, así que gastar el crédito
    de provider de la organización sigue siendo decisión de la organización. Un bot
    registrado desde la CLI, o por un cliente que no envía el interruptor, hereda.

    **Una nota de voz se ajusta al dictado más largo de la organización** antes
    de pagar a ningún provider: su duración se lee del archivo cuando el
    contenedor lo dice (Ogg, WebM, MP4, WAV, MP3), y si no, tal como la informó
    la plataforma. Una nota más larga se rechaza con una línea que lo dice, y
    también una cuya duración no indica ninguno de los dos - un MP3 de Mattermost
    con algo más que una etiqueta tras su último frame, por ejemplo. Los bytes no
    la acotan por sí solos - 25 MB de audio de baja tasa de bits son horas - y el
    límite del remitente cuenta mensajes, no minutos. El uso registra la duración
    del provider, y si no, la medida aquí.

    Un bot que no puede escuchar lo dice en vez de descartar la grabación, porque
    una nota de voz que no produce ninguna reacción es indistinguible de un bot roto
    — y dice por qué motivo, porque cada uno lo arregla otra persona: el bot está
    desactivado, o nadie ha configurado un provider. Un bot sin agent vinculado,
    o cuyo agent está archivado o no tiene versión que ejecutar, no transcribe
    nada en absoluto, porque el mensaje se rechaza de todos modos.

    Cada grabación enviada al provider deja un
    [registro de uso](ml-services.md#what-is-recorded) que nombra el bot, su agent
    y la persona con la que está vinculada la cuenta del remitente - mientras
    siga siendo miembro de la organización -, con la duración de la grabación
    donde Telegram, Slack o el provider la indican.

    !!! note "Al actualizar: un bot que no transcribía sigue sin transcribir"

        Antes de que existiera este interruptor, un bot sin par era un bot que no
        transcribía, y la API vaciaba el par para desactivar la transcripción. La
        migración `0151_bot_transcription_switch.py` pone todos esos bots en
        **off**, así que ninguno empieza a enviar audio a un provider al
        actualizar; un bot con par lo conserva. A partir de ahí, un cliente que
        vacía el par obtiene **inherit**, y **off** es
        `transcribe_voice_notes: false`, que también vacía el par. El idioma de la
        organización o del agent llega ahora también a un bot con su propio par.

        Un bot con par transcribía hasta ahora con el primer perfil de modelo,
        por etiqueta, que tuviera una clave para su provider. La misma migración
        anota esa clave como la propia del bot, cuando es una clave de API del
        provider visible para la organización y el perfil no tiene `base_url`
        propio. Un bot sin un perfil así conserva su par sin clave: su
        formulario dice «elige una clave» y no transcribe nada hasta que alguien
        lo haga.

    Lo que cuenta como mensaje de voz se lee del tipo del fichero, y cada
    plataforma tipa sus ficheros a su manera. Una nota de voz o un fichero de
    audio de Telegram cuenta siempre. En Slack cuenta un clip de audio grabado en
    el cuadro de mensaje, sea cual sea el tipo que le dé Slack — un clip de la
    app del móvil puede llegar como `video/mp4` —, y también cualquier fichero de
    audio subido. Un fichero al que la plataforma no dio tipo cuenta cuando su
    extensión indica audio: `.m4a`, `.mp3`, `.ogg`, `.oga`, `.opus`, `.webm` o
    `.wav`. Un `.mp4` sin tipo que Slack no marcó como clip sigue siendo un
    fichero normal: mucho más a menudo es un vídeo, y transcribirlo sería pagar
    por una banda sonora.

    En Mattermost cuenta cualquier fichero de audio subido, por su tipo o por su
    extensión. Un webhook saliente envía solo el id de cada fichero, así que para
    un bot en modo webhook el router consulta antes el fichero con
    `GET /api/v4/files/{id}/info`; si la consulta falla, queda como un adjunto
    normal. Un clip de Slack que llega antes de que Slack termine de procesarlo
    aún no tiene URL de descarga y se omite, porque el bot no se suscribe al
    evento `file_shared` que llega después. El backend registra ambos casos, y
    registra cada grabación que envía a transcripción con su tipo y su nombre de
    fichero — la línea que hay que leer cuando una no se ha oído.

    El provider recibe la grabación como `voice.<ext>`, nombrada por su tipo y no
    por cómo alguien llamó al fichero: OpenAI y Groq eligen el decodificador por
    el nombre y rechazan `.opus` o un nombre sin extensión para audio que sí
    saben leer. Una nota de voz de Telegram es Ogg/Opus y un clip grabado en la
    app de escritorio de Slack es WebM, y Mistral no incluye ninguno entre los
    formatos que acepta, así que un bot de Telegram o de Slack que llega a
    Mistral — elegido o heredado — puede no lograr transcribir; el formulario lo
    dice.

    Los mensajes de voz publicados en un hilo antes de que el bot se uniera a él
    no se transcriben. La primera mención lee los ficheros anteriores del hilo, y
    transcribir cada grabación entre ellos cobraría el audio de todo un hilo por
    una pregunta. En su lugar, cada una se nombra en la respuesta: «Voice
    messages sent before I joined this thread are not transcribed - send it
    again here.»

    Vienen tres providers: OpenAI, Groq y Mistral, todos sirviendo el
    `POST /audio/transcriptions` de OpenAI. Añadir un modelo es una entrada en ese
    fichero. Todos los fallos — que no haya credencial, una grabación por encima
    del límite del endpoint, un rechazo, un timeout — se informan en la respuesta y
    el turno sigue adelante sin ella.

- **Política de acceso por bot** — open, whitelist, group-only, o `jwt_linked`:
  «tiene que estar vinculado a un miembro», tanto en un canal como en un mensaje
  directo, sin ningún segundo ajuste que cambiar.
- **Una credencial se puede añadir o sustituir después del registro.** El lápiz de
  la fila de un bot lo abre: renómbralo, pega un token rotado, o suministra la
  credencial que no tenías a mano cuando se registró — que es el caso corriente en
  Slack, cuyo token `xapp-` se genera en otra pantalla unos minutos después.

    Todas las credenciales de aquí están selladas en reposo y **nunca se vuelven a
    leer**, así que cada campo empieza vacío y un campo vacío significa *conserva lo
    que hay guardado* y no *bórralo*. Solo se envía lo que alguien escribió. Lo que
    el diálogo no ofrece es la plataforma, porque decide qué credenciales lleva la
    fila y cómo le llegan los mensajes, y el transporte, porque pasar a modo webhook
    es solo medio movimiento — el webhook todavía tiene que registrarse en la
    plataforma, y un bot que informa de `webhook` sin tener uno no responde nada.
    `channel-webhook-register` hace las dos mitades.
- **La vinculación, y dónde es obligatoria** — cada run pertenece a alguien: el
  budget que gasta, lo que puede leer y la entrada de auditoría que escribe se
  atribuyen todos. De dónde sale ese *alguien* depende de si se le está hablando al
  bot en privado o está de pie en una sala.

    **Un mensaje directo pide una cuenta.** Es una conversación con una sola
    persona, así que una cuenta de chat no vinculada se rechaza hasta que nombre
    una.

    **Un canal responde a cualquiera que esté en él.** Quien pudo invitar al bot
    eligió la audiencia, así que a un remitente sin cuenta vinculada no se le
    rechaza: el turno se ejecuta bajo la *vinculación* que lo admitió — el rol de
    quien ató el agent a este bot, bajando a `viewer` si desde entonces ha dejado la
    organización — y la cuenta de chat que lo escribió queda registrada en el run.
    Lo que eso amplía es real y merece decirse: cualquiera que pueda hablar en el
    canal puede gastar el budget de la organización y alcanzar lo que alcanza el
    creador de la vinculación, que es el mismo trato que hace un widget público. Los
    techos son el rate limit por cuenta de chat, la política de acceso y el tope
    mensual de la organización. El rate limit (`rate_limit_rpm` en la política de
    acceso del bot, diez por minuto por defecto) se cuenta en el Redis compartido
    del despliegue, así que vale para todos los workers de la API y no una vez por
    worker.

    Pon **`require_link`** en la política de acceso del bot para rechazar también en
    los canales, que es el comportamiento antiguo. El modo **`jwt_linked`** rechaza
    por sí solo: un modo que lleva el nombre de una cuenta vinculada pide una, y
    antes no decidía nada salvo que además estuviera puesto `require_link`.

    La vinculación sigue importando en un canal, y merece la pena hacerla: un
    remitente vinculado se ejecuta como *él mismo* en vez de bajo la vinculación, y
    vincularse más tarde hace que sus turnos anteriores en el canal le sean
    atribuibles — el run apunta a la cuenta de chat, y la cuenta de chat gana una
    persona. Solo mientras esa persona sea un miembro que pueda iniciar sesión: un
    remitente vinculado que se ha ido, o cuya cuenta fue desactivada, se admite como
    se admite a un desconocido — bajo la vinculación en un canal, rechazado en un
    mensaje directo.

    Es también lo que deja que un agent alcance *sus* herramientas. Una vinculación
    a [la cuenta propia de cada persona](mcp.md#whose-account-a-binding-speaks-through)
    habla con Notion o Jira como quien escribió el mensaje, tanto en un canal como
    en un mensaje directo — y un remitente no vinculado no tiene ninguna cuenta a
    través de la que hablar, así que el agent le dice que haga `/link` primero.

    **Un hilo de canal es una conversación con varias personas dentro**, y aparece
    en la lista de conversaciones de todos aquellos cuya cuenta de chat vinculada ha
    escrito en él — no solo de quien habló primero, y no de nadie, que es a donde
    llegaba antes un hilo sin ningún hablante vinculado. Cada turno registra la
    cuenta que lo escribió, así que una sala se lee como una sala y no como una
    persona hablando sola. Vincularse después es lo que pone el hilo anterior
    delante de alguien, sin relleno hacia atrás: el turno apunta a la cuenta de chat
    y la cuenta gana una persona.

    **La lista de `/chat` omite por defecto los hilos de canales.** `GET /conversations` acepta `hide_channels`, verdadero por defecto, para que la barra lateral no se llene de hilos en los que responde un bot; se siguen abriendo desde su enlace, y `hide_channels=false` los vuelve a listar.

    **Hablar es una afirmación; la plataforma decide si sigue siendo cierta.** El
    registro del turno dice quién *habló*, y antes de [#641][641] esa era toda la
    comprobación — alguien expulsado del canal seguía leyendo el hilo, incluido todo
    lo dicho después de irse. Ahora cada afirmación de participación se confirma
    contra la pertenencia actual de la plataforma (`getChatMember` en Telegram,
    `conversations.members` en Slack, la consulta de miembro por usuario en
    Mattermost) antes de que el listado muestre el hilo y antes de que se abra,
    detrás de una caché compartida en Redis de un minuto aproximadamente. La
    comprobación **falla en cerrado**: una plataforma que no puede responder, un bot
    que ya no está y un hilo cuyo canal ya no nombra nada — `/new` vuelve a apuntar
    la sesión a una conversación nueva — rechazan todos la participación en vez de
    fiarse de la afirmación. El dueño del hilo y cualquiera con quien se compartiera
    explícitamente conservan su acceso en cualquier caso; la comprobación de
    pertenencia controla la participación y nada más.

    **Y abre un hilo en vez de ser su dueño.** Hablar en una sala te admite a
    leerla; renombrarla, archivarla, borrarla o añadirle un turno sigue siendo cosa
    del dueño del hilo y de cualquiera con quien se compartiera explícitamente. Si
    no, una persona que dijo «gracias» en un canal podría borrar toda la
    transcripción de la sala, o escribir un turno como el agent que todos leen y que
    al modelo se le devuelve como sus propias palabras en el turno siguiente.

    Un hilo cuyo primer hablante nunca vinculó una cuenta no tiene dueño, y ahí los
    participantes *son* quienes pueden cambiarlo — el mismo conjunto que puede
    abrirlo. No hay nadie a quien se le estaría quitando la escritura, y la
    alternativa era toda la organización: cualquier miembro podía borrar una
    transcripción cuya entrada en la lista no había visto nunca ([#701][701]). La
    escritura se apoya en la misma participación confirmada que la lectura: una
    afirmación que la plataforma ya no respalda no lleva ninguna de las dos
    ([#641][641]).

[701]: https://github.com/vstorm-co/agenticos/issues/701

[641]: https://github.com/vstorm-co/agenticos/issues/641

    El rechazo lleva dentro la salida. Mándale un mensaje al bot y responde con una
    URL; ábrela, y el dashboard — donde ya has iniciado sesión — nombra la cuenta de
    chat y te pide que confirmes. No se escribe nada y no se copia ningún código.
    Pídelo de nuevo cuando quieras enviándole al bot `link` (o `/link` donde la
    plataforma entrega una barra; Mattermost no).

    Qué hay conectado, y cómo desconectarlo, está en **Settings → Profile → Chat
    accounts**. Desconectar borra al dueño y conserva la fila, así que las
    conversaciones que cuelgan de ella sobreviven - la persona sigue escribiéndole
    al bot desde la misma cuenta después.

    **Solo en un mensaje directo.** La URL es una credencial al portador: quien la
    abra reclama esa cuenta de chat. En un canal el bot dice que le escribas
    directamente y no acuña nada. Un enlace dura quince minutos, sirve una sola vez,
    y pedir otro retira el anterior.
- **Una respuesta que puedes ver escribirse.** El bot publica un mensaje en cuanto
  llega tu pregunta y lo reescribe según va apareciendo la respuesta — incluido lo
  que está haciendo mientras tanto («Searching the web…», «Drawing a chart…»), que
  es cuando el silencio solía ser más largo, porque una llamada a herramienta no
  produce texto mientras se ejecuta. Editado más o menos una vez por segundo: por
  token serían cientos de escrituras por segundo contra un servidor que a menudo es
  de alguien. Una plataforma que no puede editar un mensaje enviado recibe
  simplemente la respuesta terminada, como antes. La respuesta de un agent con un
  [guardrail de salida](reference/capabilities.md#guardrails) se reescribe en
  cambio paso a paso, y cada paso se publica solo después de comprobarse.
- **Cada vinculación lleva sus propias instrucciones extra**, añadidas solo en
  esa superficie. Las pautas actuales de formato proceden del despliegue: Slack
  escribe enlaces como `<url|text>`, Mattermost renderiza encabezados y tablas,
  y Telegram rechaza el formato sin cerrar. Un vínculo nuevo no guarda una copia
  de esas pautas. Puedes añadir, editar o vaciar instrucciones propias sin
  congelar las pautas de la plataforma en la versión del día de instalación.
  Esto configura la entrega; el propósito del agente pertenece a su versión
  publicada.
- **Un bot responde en cuanto se registra.** Un bot de polling - long-polling de
  Telegram, Socket Mode de Slack, un stream de events de Mattermost - se alcanza
  por una conexión que mantiene el proceso de la API, y esa conexión se abre cuando
  se escribe la fila y no en el siguiente reinicio. Pausar, borrar, cambiar el token
  o la dirección del servidor y alternar entre polling y webhooks surten efecto de
  inmediato, por la misma razón: el stream se vuelve a abrir para coincidir con lo
  que la fila dice ahora. Se abre *después* de que la transacción confirme, así que
  un registro que falla no deja ninguna conexión detrás.
- **Una conexión por bot, tenga los workers de API que tenga.** Telegram permite
  un `getUpdates` por bot y dos sockets de Mattermost recibirían cada evento, así
  que el worker que tiene la conexión de un bot tiene también un lease sobre ella
  en el Redis compartido y lo renueva cada 10 segundos. Los demás no tocan ese
  bot. Un worker que se apaga pasa sus bots a otro en unos 10 segundos, y uno que
  cae, en unos 40. Una edición que atiende otro worker abre la conexión nueva al
  momento, y la vieja se cierra en la siguiente renovación de quien la tenía. Con
  Redis inalcanzable, se conecta cada worker, como antes.
- **Un segundo poller de Telegram se detecta, no se combate.** Cuando Telegram
  corta el poll de un bot porque otro proceso consulta el mismo token, el worker
  que no tiene el lease de ese bot cierra su copia en el acto. El que lo tiene
  sigue consultando y marca el bot como caído en la página de canales: «otro
  proceso está consultando el token de este bot». Ese proceso está fuera del
  despliegue, normalmente otro despliegue o un script con el mismo token, y solo
  su dueño puede pararlo.
- **El contexto del canal se añade automáticamente en cada run.** Los runs de Mattermost, Slack, Teams y Telegram reciben nombre, propósito, tema, visibilidad, número comunicado de miembros y una lista visible limitada, también con prompts existentes o vacíos. `{channel_members}` nombra la lista; `{member_list}` sigue como alias. Los demás marcadores son `{channel_name}`, `{channel_purpose}`, `{channel_topic}`, `{channel_visibility}` y `{member_count}`.

    Cada fuente se consulta una vez por run, sin caché entre turnos: hasta 50 miembros visibles y 2000 caracteres por valor. Telegram devuelve administradores, no todos los miembros. Cada fuente tiene cinco segundos; los valores no disponibles siguen desconocidos. Se aplanan y se entregan como datos de conversación no confiables, fuera de las instrucciones del sistema. Slack devuelve identificadores de miembros sin consultar cada perfil. El texto editable añade indicaciones junto al contexto de ejecución.

- **Elija si las menciones de grupo inician un run.** Al crear o editar un bot, *Responder a menciones de grupo* está desactivado por defecto. Las invocaciones directas siguen funcionando. Al activarlo admite también `@all`, `@here` y `@channel` de Mattermost, menciones nativas de Slack y menciones estructuradas de canales o equipos de Teams. Se mantienen las comprobaciones de identidad, acceso y límites. El código citado y las menciones a otras personas no cuentan.

    Teams necesita instalar o actualizar el paquete de la aplicación en el equipo o chat de destino, con consentimiento del propietario para `ChannelMessage.Read.Group` y `ChatMessage.Read.Chat`. El paquete los solicita solo con el interruptor activado. Telegram usa los alias de texto `@all`, `@here`, `@channel` y `@everyone`; no notifican al grupo. El bot debe recibir mensajes del grupo; compruebe el modo de privacidad en BotFather. Las menciones, los comandos y las respuestas se verifican contra la identidad real del bot de Telegram. Discord cuenta `@everyone` y `@here` solo cuando el remitente tiene permiso para usarlos, es decir, cuando Discord notifica de verdad a la sala.

- **Enviar archivos del workspace bajo petición también requiere aprobación.** Conceder `send_channel_file` habilita la herramienta; publicar los bytes sigue esperando una confirmación explícita.
- **Las lecturas MCP de remitentes vinculados siguen la política de aprobación del vínculo; las escrituras siguen requiriendo aprobación.** Una herramienta de solo lectura no pide permiso únicamente por ejecutarse en una app de chat. Las herramientas sin `readOnlyHint: true` siguen pidiéndolo, incluso una que el Builder deja ejecutarse sin preguntar por sí sola, y una política explícita `all` incluye las lecturas. Se aplica a conexiones personales, de organización y de plataforma, delegados y llamadas reanudadas. Las conexiones personales siguen siendo del remitente actual, nunca del publicador ni de otro participante.

Los mensajes de canal guardan solo el texto del remitente. Las etiquetas del autor se resuelven desde las identidades de canal justo antes de enviar la solicitud al modelo, fuera de las instrucciones del sistema. La consola y las acciones de copiar, fijar y crear atajos reciben el texto original. Los historiales guardados y las ejecuciones pausadas conservan referencias opacas; los manifiestos omiten las partes de referencia del canal añadidas. Al eliminar la cuenta se borran sus identidades, por lo que las solicitudes futuras no pueden resolver sus etiquetas; se conservan los mensajes compartidos.

Los nombres de canal, temas, descripciones y etiquetas de autor editables pasan las comprobaciones de entrada antes de llegar al modelo. Los valores redactados sustituyen a los originales; los valores de referencia bloqueados se ocultan y una pregunta permitida puede continuar. La sintaxis fija, los identificadores de plataforma y los tipos de chat no se examinan como texto de la pregunta.

Las referencias del autor se pasan por separado del mensaje actual para que los guardrails de entrada examinen la pregunta real. Los participantes sin cuenta vinculada siguen necesitando aprobación para todas las llamadas MCP, incluidas las lecturas de organización.

Tanto las invocaciones explícitas `@slug` como las respuestas predeterminadas reciben el historial guardado de la conversación junto con el contexto inicial del hilo del canal.

Las Apps publicadas se pueden editar desde otra conversación sin el sandbox original. `read_artifact` recupera el código guardado y `publish_artifact` con el mismo nombre y cambios o contenido de reemplazo actualiza la app. Conserva su enlace e historial de versiones. Poder verla no concede permiso de edición; siguen aplicándose los controles existentes.

- **El agent conoce dónde responde y qué cuenta está disponible.** Las indicaciones de ejecución identifican el comunicador y la consola del despliegue, explican el workspace gestionado y distinguen conexiones MCP de la organización y servicios personales. Estos necesitan la identidad vinculada del remitente y su propia conexión autorizada al servicio. Para vincular se envía `link`, sin barra, en un mensaje directo; el enlace privado no pertenece a un canal. El agent comunica resultados reales y un siguiente paso concreto si falta autorización o capacidad.
- **Un mensaje reentregado se responde una vez.** Todas las plataformas entregan al
  menos una vez: las rutas de webhook responden 200 antes de hacer ningún trabajo,
  así que un handler lento nunca dispara un reintento, pero un 200 perdido por el
  camino — un proxy lo tira, el pod se reinicia — nunca se recibió, y la reentrega
  que viene después es una petición válida, firmada y completamente nueva que lleva
  el mismo mensaje. La primera entrega reclama el mensaje en Redis (un `SET NX`
  atómico, indexado por como la plataforma llame al mensaje, dentro de su chat) en
  el punto por el que pasan todos los caminos de entrada — las tres rutas de webhook
  y los tres streams de polling por igual — así que el reintento se acepta y se
  descarta, sea cual sea el worker de la API que lo reciba. Una reclamación dura
  quince minutos, que sobrevive a la ventana de reintentos de cualquier plataforma.

    La reclamación se toma al recibir, así que un run que el proceso no pudo
    terminar la devuelve: una reentrega tras un run cancelado, o una que un pod
    reiniciándose dejó caer, se responde en vez de confundirse con un duplicado. Eso
    importa sobre todo para los streams de polling, que vuelven a leer un mensaje en
    el que murió el proceso. Un error que el propio router captura no es eso: se
    disculpa una vez con el remitente y conserva la reclamación, para que la
    reentrega de la plataforma no vuelva a ejecutar un fallo que solo volvería a
    fallar.

    La garantía se degrada en abierto, nunca en cerrado. Un mensaje que llega sin id
    de mensaje de la plataforma, y un Redis que no se puede alcanzar, se procesan
    ambos en vez de rechazarse — una respuesta duplicada es el fallo más raro y más
    barato que una pregunta perdida — y cada uno escribe un aviso diciendo que la
    garantía estuvo desactivada para esa entrega. Nada se rechaza solo por la
    cabecera de reintento de una plataforma: el `x-slack-retry-num` de Slack dice
    que se está produciendo una reentrega, no que el primer intento llegara lo
    bastante lejos como para hacer algo, y `reason=http_error` significa que
    explícitamente no lo hizo. La cabecera se registra; decide la reclamación.
- **Rate limits** por chat, en el bot - quién puede hablarle y con qué frecuencia es
  cosa del operador, a diferencia de todo lo de arriba, que es del autor del agent.
- **Los gráficos se renderizan como imágenes** donde la plataforma lo permite, y
  caen a una tabla de texto donde no.
- **Lo que costó un turno**, dicho o solo registrado — ver más abajo.
- **Ficheros, en ambos sentidos** — ver más abajo.
- **Quién comparte un workspace, por superficie.** El spec de un agent fija el valor
  por defecto; cada vinculación puede anularlo, porque un chat web y un canal de
  Slack no plantean la misma pregunta sobre compartir.

### Qué registra cada superficie { #what-each-surface-records }

Todas las superficies llegan al mismo runner, así que todos los runs tienen su fila
— su coste, su estado, sus tokens y el budget aplicado contra él. También tienen su
**transcripción**: la pregunta, la respuesta y cada llamada a herramienta con los
argumentos con los que se hizo y lo que volvió. Eso importa porque el detalle de un
run se lee de esas filas — lo que nada escribió, ninguna página puede mostrarlo.

Para todo salvo el chat web la transcripción la escribe el runner, no la superficie.
Antes era tarea de la superficie, y cuatro de ellas no la hacían: el widget, una
mención, la API y todos los runs reanudados no registraban absolutamente nada, así
que a una organización se le facturaba una respuesta sin ninguna fila que dijera qué
se preguntó. Algo que todas las superficies tienen que acordarse de hacer es algo
que la siguiente superficie no hará.

El chat web sigue escribiendo la suya, porque tiene events que adjuntar y un socket
al que responder — y escribe en ambos finales. **Un turno que no termina se registra
hasta donde llegó**, a partir del mismo texto que se le transmitió al cliente, así
que lo que se guarda es lo que su lector vio de verdad.

| Superficie | Qué llega a `messages` y `tool_calls` |
|---|---|
| Chat web, run terminado | Todo — prompt, razonamiento, argumentos y resultados de herramientas, modelo y versión, y el orden en el que ocurrió todo |
| Chat web, run interrumpido | Lo mismo, hasta donde llegó. Un run que falló, que alcanzó su budget, que se detuvo o que perdió su socket conserva las palabras ya transmitidas, atribuidas a la versión que las produjo, sin inventarle ninguna cifra de coste — la contabilidad vive en la fila del run |
| El agent por defecto de un bot de canal | Todo salvo el razonamiento, que solo expone un run transmitido |
| `@mention` en un canal | Lo mismo, con el handle quitado del prompt registrado |
| Widget embebido | Lo mismo. El visitante es anónimo; el run y los turnos pertenecen al dueño del widget |
| API HTTP | Lo mismo cuando la llamada lleva un `conversation_id`. Nada sin él — no hay ningún hilo en el que escribir un turno, y la fila del run sigue siendo el registro de que ocurrió |
| A2A | Lo mismo. Cada tarea se archiva en una conversación, nueva o la que nombra su `contextId` |
| Un run reanudado tras una aprobación | Su continuación — la respuesta y las llamadas que hizo, y las llamadas incluso cuando no hay respuesta, que es lo que tiene una continuación que vuelve a aparcarse en una segunda llamada con puerta. Ningún turno de usuario: retoma en la llamada en la que se detuvo, e inventar una pregunta sería poner palabras en boca de alguien |

**Lo que se registra es lo que escribió la persona, no el prompt ensamblado a su
alrededor.**

Todas las superficies construyen algo más grande antes de que el modelo lo vea:
`AttachmentRouter` añade un informe sobre cada fichero, y un widget embebido
antepone la nota de colocación del operador.

Registrar eso metía el informe de la propia plataforma en la transcripción como si
fueran las palabras de alguien. Un fichero publicado en Mattermost se releía como
`co tu widzisz` seguido de `--- Attached file: … (/uploads/…, 43 KB, image)`, y el
turno inicial de todas las conversaciones de widget se leía como un visitante
recitando la página en la que estaba.

**El fichero en sí es una fila en ese turno**, que es lo que el dashboard renderiza
como una tarjeta — igual que una subida hecha allí.

Una cosa deliberadamente no se registra: las **notas de entrega** de una respuesta
de canal — *este fichero era demasiado grande para enviarlo* — se quedan fuera de la
transcripción, porque hablan de lo que la respuesta no pudo llevar y no de lo que
dijo el agent.

### Qué aspecto tiene un turno en el chat web { #what-a-turn-looks-like-in-web-chat }

**El trabajo es una narración, no un montón de tarjetas.** Cada llamada a herramienta
es una línea — *Wrote test1.md*, *Searched for TODO in app.py*, *Ran pytest -q*,
*Linear · Create issue* — escrita en el tiempo verbal en el que es cierta: presente
mientras la llamada se ejecuta, pasado una vez hecha. La línea nombra el *asunto* y
no la función, porque `write_file` no es lo que nadie quiere leer. Cada línea se abre
hacia lo que la llamada produjo de verdad, y los argumentos y la salida en crudo se
quedan un clic más adentro para quien esté depurando una.

Las llamadas consecutivas cuelgan de un mismo raíl, y **solo la última fila sigue
visible**. Las anteriores se pliegan en «4 earlier steps», que dice que hubo trabajo
sin echar la respuesta fuera de la pantalla.

Tres tipos de tanda no se pliegan nunca: una que contiene un fallo, una que contiene
una llamada aparcada a la espera de aprobación, y una que contiene un paso cuyo
resultado *es* la respuesta — que hoy significa un gráfico.

Las dos primeras son la línea del turno que está pidiendo algo. La tercera está ahí
porque un turno que dibujó tres gráficos plegaba dos de ellos, y tres gráficos son
tres respuestas y no una con dos notas al pie.

Qué herramientas cuentan como de ese tipo es `opensOnSight` en
`lib/tool-catalog.ts`, la misma fila que lee el paso para decidir si se abre solo,
así que el raíl y el paso no pueden discrepar. Nada marca un paso que simplemente
funcionó, así que una marca significa lo que dice.

En cuanto el turno ha respondido, **el trabajo sobre la respuesta se pliega en una
línea** — *Worked for 12s · 3 steps* —, con el razonamiento, las llamadas y la breve
narración entre ellas detrás, a un clic. La duración es la del run, de su inicio a
su fin, así que incluye cualquier espera por una aprobación. Mientras el turno sigue
trabajando, en el mismo sitio se lee *Working for 5s…* con una luz que pasa por
encima.

Lo que nunca se pliega se queda en la página: los tres tipos de tramo de
arriba, una pregunta que hizo el agente, lo que la persona añadió a mitad del turno
y un pasaje lo bastante largo como para ser una respuesta por sí mismo.

**Lo que se abre solo sigue a lo que alguien está mirando, salvo cuando el resultado
es lo importante.**

Una llamada que termina mientras el turno se está transmitiendo se abre en el acto —
código que se ejecutó, o un fichero que se escribió, es la respuesta y no una nota al
pie de ella.

Una conversación *reabierta* muestra una línea por cada llamada pasada y mantiene
abierta exactamente una: la última llamada del turno más reciente que **usó una
herramienta**, que es el resultado por el que ha vuelto el lector.

El *turno* más reciente es el ancla equivocada, y fue la primera forma en que se
escribió esto: un agent que escribe un fichero y luego responde sobre él en prosa
termina la transcripción con texto, y el fichero que acababa de escribir se plegaba.
Abrir todas las llamadas terminadas al montar convertía un chat reabierto en un muro;
no abrir ninguna escondía justo lo que se había pedido.

Un gráfico es la excepción por ambos lados. Se abre esté donde esté y se lea como se
lea el turno, porque una imagen que nadie puede ver no es una respuesta.

### El mismo turno, en directo y reabierto { #the-same-turn-watched-and-reopened }

**Un turno es un mensaje, y su orden se registra en vez de adivinarse.** Las dos
mitades de eso fueron falsas en su día, y juntas hacían que la transcripción en
directo y la recargada fueran dos documentos distintos.

Un turno de varios pasos hace una petición al modelo por cada ronda de herramientas, y
el cliente abría antes un mensaje en cada una — así que un turno que dibujaba tres
gráficos llegaba como cuatro burbujas, cada una con su propio avatar. Un turno es una
fila de `messages`, así que solo una de esas burbujas podía llegar a casar con lo
guardado; las demás conservaban un id temporal, no llevaban coste ni valoración, y
desaparecían al recargar.

Y la fila decía qué contenía un turno sin decir cuándo. `content`, `thinking` y
`tool_calls` son tres cubos, así que un cliente tenía que reconstruir un orden y el
único que podía reconstruir era razonamiento, luego todas las herramientas, luego la
respuesta. Un turno que presentaba los gráficos, los dibujaba y luego los resumía
tiene dos bloques de texto y una sola columna donde meterlos: la introducción se
perdía al guardar y el resumen reaparecía por encima del trabajo que describía.

Así que `messages.parts` guarda la secuencia tal como se transmitió —
`{"type": "text"|"thinking", "text": …}` y `{"type": "tool", "tool_call_id": …}`, en
orden — y las dos superficies renderizan el mismo array en vez de coincidir por
casualidad. Los argumentos y el resultado de una herramienta siguen en `tool_calls`;
la línea de tiempo nombra la llamada en vez de copiarla.

Es null en un turno de una sola parte, donde no hay secuencia que preservar, y en
todos los turnos de asistente escritos antes de que esto existiera. Esas filas se
siguen pudiendo leer — el texto está en las columnas de siempre — pero su orden nunca
se registró y no se puede recuperar, así que un cliente que encuentra null recurre a
reconstruir uno. Ese recurso es una conjetura, y se conserva solo para ellas.

**Una escritura termina en el fichero, no en una frase sobre él.** `write_file`
responde «Wrote 1 lines to /workspace/test1.md»; lo que muestra la transcripción es
una tarjeta que nombra el fichero, con *Open* — el mismo visor que usa la pantalla
Workspaces — y *Download*. La ruta se resuelve contra el listado propio de la
conversación en vez de fiarse de los argumentos, porque una herramienta llamada con
`test1.md` informa de `/workspace/test1.md` y el workspace puede guardar cualquiera de
las dos; si no hay coincidencia la tarjeta se dibuja sin controles que fallarían.

**Una llamada MCP lleva el nombre de su servidor.** Nada en una llamada a herramienta
registra de dónde vino — el único rastro es el prefijo que el backend pone en las
herramientas de una conexión, que es el nombre de la conexión — así que el frontend
casa ese prefijo con los servidores que el llamante puede ver y muestra el logo del
propio servidor junto al paso. Un fallo se lee como el nombre humanizado de la
herramienta, que es como se leía antes.

**Una delegación es un panel, no una pausa.**

Cuando el agent le pasa trabajo a
[un delegado o un especialista](concepts.md#delegate-vs-inline-specialist), esa
delegación es la conversación entera de un segundo agent ocurriendo dentro de un turno
del primero. A su aire es una llamada a herramienta llamada `task` que se queda
callada treinta segundos.

Así que se transmite a un panel propio: qué especialista está trabajando, su texto y su
razonamiento según se generan, sus *propias* llamadas a herramientas — que pueden
alcanzar una colección que el padre ni siquiera ve — y al cerrarse su estado, sus
tokens y su parte del coste del turno.

Cada frame lleva el id de tarea de la delegación y su profundidad. Un abanico de tres
son tres paneles, y entrelazar a tres especialistas en un mismo párrafo es peor que no
transmitir nada. Un frame de apertura lleva además el id de tarea de la delegación
*dentro de la cual* se hizo, así que un especialista que delega a su vez se anida bajo
el panel correcto y no bajo el que empezó más recientemente.

El texto de un hijo **nunca** se pliega dentro de la respuesta del padre. Eso pondría
en boca del padre palabras que su propio modelo nunca generó, y la conversación se
persiste con ellas.

**Una llamada aprobada no es el final del turno, y el resto también se dibuja.**

Aprobar continúa el run por HTTP, así que nada de la continuación llega por el socket
de esta conversación. Sus pasos vuelven en la propia respuesta de la reanudación y se
añaden como un turno de asistente más: las llamadas que hizo, y luego lo que dijo.

Sin ellos la segunda mitad de un turno era invisible, y un run que se aparcaba dos
veces era la peor versión de eso — apruebas un comando, no ves que pase nada, y se te
pide que apruebes un segundo comando sin ningún paso en pantalla que dé cuenta del
primero.

La llamada recién aparcada se dibuja en ese turno como *a la espera de una persona*,
que es además el paso sobre el que se escribe de vuelta la siguiente decisión.

**Un run es un turno en pantalla, por muchos mensajes que le hayan hecho falta.**

Un run que se aparca escribe lo que había hecho hasta entonces, y cada continuación
se escribe según ocurre en vez de plegarse de nuevo en el mensaje anterior —
reescribir un turno que alguien ya ha leído es peor que añadirle algo.

Así que un run puede dejar tres filas de asistente, y dibujar tres avatares y tres
nombres de agent a lo largo de la página se lee como tres agents respondiendo a una
pregunta.

`MessageList` agrupa los mensajes de asistente *consecutivos* que llevan el mismo
`run_id` en un solo turno: el avatar y el nombre una vez, arriba. Lo de consecutivos
es parte de la regla — que hable una persona entre dos segmentos significa que el
turno se reinicia de verdad — y un mensaje sin run registrado no se agrupa nunca,
porque ausente significa «no registrado» y no «el mismo run».

En directo, el id del run llega en el frame `tool_approval_required`, que es el único
frame que lo nombra y el único turno que lo necesita. Al recargar sale del mensaje
guardado.

**La hora y el coste van bajo el final del turno**, una vez, por muchos mensajes que
haya hecho falta. Un run informa de lo que ha gastado cuando se *aparca*, así que la
cifra se registra en el primer segmento — dibujada ahí quedaba a media respuesta, sin
nada bajo su final. El último segmento muestra el total del run: cada cifra es
acumulada hasta ese punto, así que la posterior sustituye a la anterior en vez de
sumarse a ella, y la continuación toma sus números de la propia respuesta de la
reanudación.

**Lo que devolvió la llamada aprobada se registra en el paso que se aprobó.** La fila
se escribe abierta cuando el run se aparca — todavía no se ha ejecutado — y la
reanudación que finalmente la ejecuta produce el *retorno* sin la llamada a la que
pertenece, porque esa llamada la hizo la ejecución anterior. Así que salda la fila
existente en vez de escribir un paso nuevo: la alternativa es el mismo comando dos
veces en un turno, y la alternativa a *eso* era que la única llamada que alguien
revisó deliberadamente fuera la única llamada que no abría a nada.

**Un paso reproducido nunca se anima.**

Una llamada a herramienta se guarda como en ejecución hasta que algo registra su
desenlace, y no todos los finales registran uno: una aprobación que caduca no ejecuta
nada, así que el paso en el que se aparcó se escribió abierto y así se quedó.

Al releerlo, latía en presente bajo una conversación que había terminado días antes,
prometiendo un resultado que nada iba a entregar.

Así que el barrido que caduca una aprobación cierra ahora también el paso — el único
final que nunca ejecutó la llamada — y una llamada reproducida que sigue marcada como
en vuelo se renderiza como **sin terminar**: en pasado, sin ruleta, sin resultado.

Ni un error ni un éxito. El desenlace que nadie anotó.

**Y el panel pertenece a su conversación, no a la pestaña.** Abrir otro hilo se lleva
de la pantalla el panel de aprobación y cualquier pregunta pendiente, igual que ya se
lleva los paneles de delegación. Si se dejaba ahí, la aprobación no solo estaba
caducada sino que era accionable: *Approve* seguía decidiendo la llamada, desde
debajo de la transcripción de otro agent, y el paso que salda está en mensajes que ya
no están cargados — así que nada en pantalla cambiaba para decir que había ocurrido.
Limpiarlo no pierde nada, porque la cola de aprobaciones tiene la misma fila. La
única transición que no es un cambio es un primer turno que se entera de su propio id
de conversación a mitad de la transmisión, y el panel sobrevive a eso.

Un delegado también puede detenerse a esperar a una persona — una herramienta con
puerta dentro de un especialista aparca el turno entero en la cola de aprobaciones.

El panel se cierra entonces en un estado de *a la espera de una persona* en vez de
girar sobre «working» todo el tiempo que tarde quien aprueba, y la delegación conserva
el id de tarea bajo el que se aparcó, así que su identidad sobrevive a la reanudación
en vez de aparecer un segundo panel al lado del primero.

La reanudación misma corre por HTTP (`POST /runs/{id}/resume`), que no lleva frames de
delegación. Así que el panel en espera se mueve al desenlace propio del run reanudado
— completado, fallido o cancelado — a partir de esa respuesta. Una reanudación que se
vuelve a aparcar en una decisión nueva lo deja esperando.

La respuesta del asistente **no** va en una burbuja; solo el mensaje de la persona.
Una respuesta es prosa con encabezados, código y tablas dentro, y un relleno redondeado
alrededor pelea con todos ellos.

**Cada palabra de cualquiera de estas pantallas sale de `frontend/messages/en.json`.**
El inglés es el idioma de origen; `pl.json` y `de.json` solo tienen lo que se ha
traducido de verdad - `src/i18n.ts` fusiona el inglés por debajo de cada locale,
así que una traducción que falta renderiza inglés y no la clave. `make lint` ejecuta
`frontend/scripts/check-i18n.ts`, que falla en ambos sentidos: por texto dejado en un
componente, y por una clave que un componente lee y que el catálogo no tiene.

### Una delegación en una superficie que no puede mostrarla { #a-delegation-on-a-surface-that-cannot-show-one }

Todas las demás superficies — Slack, Telegram, Mattermost, el widget embebido, la API
REST — no reciben ningún frame de delegación. La delegación se ejecuta igual y se
registra igual; simplemente no se narra, el mismo arreglo que tiene `ask_user`.

Ese valor por defecto sostiene el edificio en vez de ser una comodidad, y es lo único
que hay que saber antes de añadir una superficie que quiera los paneles.

!!! danger "Enganchar un handler a una delegación cambia el transporte, no solo la observabilidad"

    La librería mueve a cada hijo con `iter()` y abre una petición **transmitida**
    para él.

    Así que un delegado cuyo modelo o provider no puede transmitir funciona
    perfectamente desde la API y deja de funcionar en el momento en que alguien abre
    la ventana de chat — la misma versión publicada, el mismo agent, fallando en una
    sola superficie.

Por eso un handler se engancha solo donde existe un sumidero, en vez de
incondicionalmente en beneficio de la única superficie que los dibuja.
`tests/test_subagents_library_contract.py` fija esa propiedad de la librería, así que
una release que empiece a caer a una petición normal se pone en rojo y lo dice.

### Ficheros { #files }

Que alguien soltara una hoja de cálculo sobre un bot solía terminar en que se
descartara. `IncomingMessage` no tenía campo de adjunto, así que ningún adaptador
parseaba uno y el agent respondía sobre un documento que nunca recibió.

Ahora un mensaje con un fichero — con pie o sin él — llega al agent igual que una
subida web, y **se relee igual**: el fichero es una fila en el turno con el que llegó,
así que el hilo en `/chat` muestra una tarjeta y no el informe que se le dio al modelo
sobre él.

Una subida sin pie sigue siendo un turno, y su mensaje nombra lo que llegó —
`Attached image: photo.jpg` — en vez de quedarse en blanco encima de la tarjeta,
porque un mensaje de usuario en blanco se lee como alguien que no envía nada.

**En todos los transportes, porque cada adaptador tiene exactamente un parser.**

Cada plataforma tiene dos formas de entrar — un webhook y un stream, o long-polling —
y la segunda construía antes su propio mensaje normalizado. El bucle de polling de
Telegram leía texto y nada más; el webhook saliente de Mattermost no leía ningún
`file_ids`.

Ahora los dos devuelven su actualización a la forma que envía la plataforma y se la
entregan al mismo `parse_incoming`, así que qué cuenta como mensaje se decide **una
vez**. Se había decidido dos veces, y las copias discrepaban sobre los ficheros — cosa
que importaba sobre todo en los caminos que ejecuta de verdad un despliegue
autoalojado.

Lo que se le *entrega* a cada transporte sigue siendo distinto, y eso es cosa de la
plataforma y no nuestra: el bucle de polling de Telegram se suscribe solo a mensajes
nuevos, así que una edición llega al receptor del webhook y nunca al poller.

**La entrada** es el camino de subida web alcanzado de otra manera. Los bytes vienen
de una plataforma en vez de de un navegador y luego pasan exactamente por lo que pasa
una subida web: la lista de MIME permitidos, `MAX_UPLOAD_SIZE`, el parser, el
almacenamiento y una fila `ChatFile`. Un bot es el borde más permisivo que tiene esta
plataforma — cualquiera en un canal puede soltarle un fichero — así que no puede ser
además el laxo. Desde ahí el fichero sigue el enrutado de
[Procesamiento de ficheros](file-processing.md): pegado en línea para un agent sin
workspace, escrito en `/uploads` con una referencia para uno que lo tiene.

El tamaño se comprueba dos veces a propósito: contra lo que la plataforma *afirma*
antes de descargar nada, porque descargar un gigabyte para luego rechazarlo es el
ataque, y contra los bytes después, porque una afirmación no es una medición.

**Un fichero con el que se ejecutó el turno pertenece a ese turno.** Su fila
`ChatFile` se enlaza al mensaje de usuario que escribe la transcripción del run,
exactamente igual que una subida web se enlaza al mensaje que alguien escribió — así
que una transcripción de un hilo de canal muestra la hoja de cálculo junto a la
pregunta que se hizo sobre ella. Importa más de lo que parece: `chat_files` no lleva
organización, así que una fila sin mensaje queda acotada solo por el remitente,
alcanzable a través de `GET /files/{id}` por su dueño y por nadie más. Todos los
turnos de canal dejaban antes una así, porque el enlazado lo hacía la única superficie
que escribe su propia transcripción.

Lo que el enlace amplía son los *metadatos*, no los bytes. La conversación de un canal
pertenece a quien habló primero en él, así que en un canal compartido el fichero de un
compañero aparece ahora en una transcripción que otros miembros pueden leer — como un
nombre, un tipo y un tamaño. Descargarlo sigue respondiendo solo a su dueño, que es la
mitad correcta que hay que mantener privada y la mitad de la que hay que avisar a un
lector: la etiqueta está ahí, los bytes no son suyos.

Descargar un fichero necesita una segunda petición autenticada en todas las
plataformas, que es la razón de que un adjunto llegue como un identificador y no como
contenido:

| | |
|---|---|
| Slack | La URL privada del event, descargada con el token del bot. Slack responde **200 con una página de inicio de sesión** en vez de 401 cuando el token no puede leer un fichero, así que se comprueba el content type — si no, se guardaría una página de login como la hoja de cálculo del usuario |
| Telegram | `getFile` resuelve un `file_id` a una ruta que caduca, y luego la API de ficheros. Una foto llega en varios tamaños; el que se conserva es el mayor |
| Mattermost | `/files/{id}` en el servidor propio de ese bot. Un bot cuyo servidor no está registrado lo dice en vez de adivinar al servidor de qué empresa mandarle un token |
| Teams | Un archivo en un chat personal trae una URL de descarga que Teams ya ha autorizado; una imagen pegada en un mensaje se obtiene del Bot Connector con el token del bot, que no va a ningún otro sitio |

**Las grabaciones todavía no están soportadas.** Telegram pone cada tipo de medio en
su propio campo, así que una nota de voz llega sin ningún texto — y hasta este cambio
se parseaba como nada y desaparecía sin una línea de log. Ahora se lee, se rechaza, y
el rechazo dice lo que de verdad es cierto: la grabación llegó y aquí todavía no hay
nada que pueda escucharla. La transcripción es
[#54](https://github.com/vstorm-co/agenticos/issues/54); cuando aterrice, el audio se
sumará a la lista de permitidos y ese rechazo desaparecerá.

Un fichero que se rechaza — tipo no soportado, una grabación, demasiado grande, una
descarga que falló — se **nombra en la respuesta**. Un fichero malo entre tres no
pierde los otros dos ni la pregunta que venía con ellos, y un bot que ignora un
adjunto en silencio se parece exactamente a un bot que lo leyó.

**Un turno rechazado antes de ejecutarse devuelve sus ficheros.**

Los bytes se descargan y se guardan antes de resolver el agent, así que un rechazo
lanzado en lugar del run — ningún agent expuesto en este bot, un remitente cuya cuenta
de chat no es de nadie — dejaba antes atrás las filas y los ficheros sin ningún
mensaje que llegara nunca a enlazarlos. `chat_files` no lleva organización, así que
una fila sin enlazar queda acotada solo por `user_id` y nada la recoge.

Ahora se borran los dos antes de enviar el rechazo, y el rechazo se envía tanto si eso
salió bien como si no
([#661](https://github.com/vstorm-co/agenticos/issues/661)).

Un turno que sí llegó a ejecutarse conserva sus ficheros — lo alimentaron, y el run
está en la transcripción.

**La salida** de ficheros se envía solo mediante `send_channel_file`, cuando el usuario pide recibir o descargar el fichero. Crearlo, leerlo o editarlo no lo adjunta. Conceda la herramienta por vinculación del bot en *Dónde está disponible este agent*. Las vinculaciones existentes conservan su selección; active esta acción explícitamente tras actualizar. La herramienta tiene efectos secundarios y sigue la política normal de aprobación. Envía solo el fichero indicado del workspace, rechaza rutas internas y ficheros de más de 8 MB y permite hasta tres intentos por turno. Los fallos de la plataforma se comunican sin afirmar que se entregó. Teams puede requerir aceptación en un chat personal. Los gráficos y las visualizaciones solicitadas mantienen su entrega separada como imagen.

Cada fichero lleva el tipo que su nombre implica y no un `application/octet-stream`
plano, así que un gráfico que escribió un agent llega como una imagen en las
plataformas que leen ese campo, en vez de como un blob que alguien tiene que descargar
para identificar.

Con un tope de 3 ficheros y 8 MB cada uno, por debajo del límite propio de cada
plataforma para que el rechazo sea nuestro y se pueda explicar en vez de llegar como
un error opaco de la API. Todo lo que pase del tope se nombra en la respuesta y se
queda en el workspace.

Un gráfico se mantiene aparte de todo esto. Es una *foto* en estas plataformas,
renderizada en línea, que es todo el sentido de la capability `charts` — plegarlo
dentro de la lista de adjuntos haría que todos los gráficos llegaran como una
descarga.

### Decir lo que costó un turno { #saying-what-a-turn-cost }

Un bot que deja de responder porque su organización alcanzó su tope mensual parece
roto. La única diferencia entre «roto» y «sin budget» es que alguien lo haya dicho de
antemano, así que un bot puede informar de lo que gastó un turno: tokens, coste,
cuánto del mes se ha ido y lo lleno que está el workspace que hay detrás.

En el chat web esos mismos dos números están bajo el composer, y vienen de sitios
distintos porque miden cosas distintas.

**El coste** es la respuesta medida más reciente *de la conversación que hay en
pantalla*. Se lee de la transcripción, así que está ahí cuando se reabre un hilo y no
después del siguiente mensaje — y filtrado por id de conversación, porque el store
todavía tiene los mensajes del hilo anterior durante el instante que va del clic a que
aterrice la petición. Los informaba bajo la conversación nueva hasta que se filtró.

**El llenado** es el workspace tal como está ahora. Un turno en vivo lo informa — la
memoria residente de un contenedor solo puede venir de su host — y una conversación
reabierta lo lee del listado del workspace, que lleva el techo contra el que se llena
un workspace almacenado. Sin eso, «workspace 0% full» aparecía solo después de que
alguien enviara un mensaje, el único momento en que nadie lo necesita.

Se elige **por vinculación**, en el Builder bajo *Where this agent is available* -
junto a las instrucciones extra y las consultas de canal, porque si una respuesta
lleva un pie con el coste es parte de lo que este agent dice en esta superficie.
Estaba en el bot hasta que un bot pasó a servir a un agent, donde era un ajuste del
operador en una tabla de servidores y tokens sin nada más sobre el agent cerca.

| Modo | |
|---|---|
| `log only` | Registrado y no dicho. No dicho no es no medido — «el bot se quedó callado» es una pregunta que alguien hace días después |
| `near a limit` | Se dice en cuanto el budget o el workspace pasan un umbral (80% por defecto). **El valor por defecto** |
| `every n messages` | Se dice cada n turnos *de ese chat*, no del bot |
| `every reply` | Se dice en cada turno |

`near a limit` es el valor por defecto en vez de `log only`, porque poner el silencio
por defecto dejaría a todos los bots ya registrados exactamente en el estado que esto
existe para evitar. Y en vez de `every reply`, porque un pie bajo cada mensaje de un
canal concurrido es la otra forma de hacer inútil un aviso.

El workspace cuenta igual que el dinero. Un workspace almacenado que se llena empieza
a *rechazar escrituras*, cosa que el agent informa como un error de herramienta en
mitad de hacer algo — un bot que solo vigilara el budget se quedaría callado ante el
otro límite sin decir nada.

Medir cuesta algo cuando hay un contenedor: su memoria es una ida y vuelta al host por
cada sandbox. Así que `log only` no pregunta nunca, y todos los demás modos preguntan
por una sesión en vez de listarlas todas.

En `/chat` no hay ningún argumento de ruido, así que los números se envían siempre —
el cliente los dibuja bajo la entrada y decide qué mostrar. Tres cosas que muestra y
que un pie de canal no:

- **El tope propio del agent primero**, y el de la organización solo pasado el 80%. El
  de la organización para todos los agents a la vez y pertenece a otra persona; el
  propio del agent es el que puede subir quien lo está mirando.
- **Entrada y salida por separado**, bajo cada respuesta además de bajo la entrada.
  Tienen precios que se diferencian en un orden de magnitud, así que un total no puede
  decir si un turno salió caro por un contexto largo o por una respuesta larga — y la
  tira solo describe el *último* turno, lo que en una conversación larga esconde qué
  respuesta se llevó el dinero. Solo en turnos en vivo: el uso se mide cuando termina
  un run y no se guarda por mensaje, así que una conversación recargada no muestra
  ninguno.
- **Los propios ficheros**, en un panel junto a la transcripción que lee
  `GET /conversations/{id}/workspace`. Se relee cuando termina un turno y no con un
  temporizador, y está completamente ausente — no vacío — para un agent que no guarda
  ficheros, que son la mayoría. Dice de quién son estos ficheros, porque bajo alcance
  `agent` un workspace se comparte y encontrar un fichero que nunca creaste se lee
  como una fuga hasta que algo en pantalla lo explica. Un fichero es una baldosa, y
  abrir uno abre el mismo visor que usa la pantalla Workspaces — una imagen, un PDF,
  un reproductor de audio y vídeo,
  markdown como vista previa o como fuente, y siempre una descarga — leyendo
  `…/workspace/file` para el texto y `…/workspace/raw` para los bytes. A través de la
  *conversación* y no del id del workspace, deliberadamente: eso es lo que mantiene
  estos ficheros alcanzables para alguien con quien se compartió el chat.

### Anular quién comparte el workspace { #overriding-who-shares-the-workspace }

En Slack, `thread_ts` se pliega dentro del id del chat — así que un hilo *es* una
conversación, y un agent cuyo spec dice `conversation` obtiene un workspace por hilo.
En un canal concurrido eso son cincuenta contenedores y un `429` para la
quincuagésima primera persona que responda. La vinculación puede decir `channel` en su
lugar, y todos los hilos de ese canal comparten uno.

Las opciones son las mismas que las del spec (`run`, `conversation`, `channel`,
`user`, `agent`), más «como diga el agent», que es la opción por defecto y no guarda
nada. El control está en la vinculación dentro del Builder, y solo aparece para un
agent que guarde ficheros.

El alcance `user` es lo que lleva un workspace *entre* superficies: una persona que
empieza en el chat web y continúa en Slack es una `ChannelIdentity` enlazada a una
cuenta, así que encuentra los mismos ficheros. `conversation` y `channel`
deliberadamente no lo hacen — esos nombran un lugar, y un lugar no sigue a alguien a
otra plataforma.

### Qué puede consultar el agent sobre el canal { #what-the-agent-may-look-up-about-the-channel }

Un bot que responde en `~support` conoce las palabras que alguien escribió y nada más.
No sabe que el canal se llama `~support`, ni quién está en él, ni para qué se creó, ni
qué se dijo en él hace diez minutos — así que *«¿a quién le pregunto sobre
facturación?»* y *«resume lo que decidimos arriba»* son preguntas que solo puede
responder adivinando.

Cuatro herramientas cambian eso, y cada una se concede **por vinculación**, bajo
*Where this agent is available*:

| Herramienta | Responde | Slack | Telegram | Mattermost | Teams |
|---|---|:-:|:-:|:-:|:-:|
| `get_channel_info` | Nombre, propósito, tema, tamaño | ✅ | ✅ | ✅ | ✅ |
| `list_channel_members` | Quién está aquí | ✅ | solo admins | ✅ | ✅ |
| `search_channels` | Qué otros canales existen | ✅ | — | ✅ | — |
| `read_channel_history` | Mensajes recientes del canal o de este hilo con `scope="thread"` | ✅ | — | ✅ | — |
| `send_channel_file` | Enviar un fichero solicitado del workspace | ✅ | ✅ | ✅ | ✅ |

Para conectar la cuenta, envíe al bot un mensaje directo con solo `link`, sin barra. Abra el enlace privado tras iniciar sesión en AgenticOS y vuelva al canal. Si los ficheros anteriores del hilo requieren conexión, el bot lo explica sin descargarlos y vuelve a intentarlo tras conectar. Las imágenes del canal usan el doble de resolución CSS; los diagramas Mermaid horizontales muy anchos reciben una disposición vertical solo en el PNG.

Las imágenes solicitadas mediante `generate_image` se adjuntan a las respuestas en las cuatro plataformas de chat, además de los gráficos y diagramas. Las imágenes guardadas se leen dentro de la organización de la ejecución, se limitan a 8 MiB y 16 megapíxeles y se convierten a PNG; las imágenes ausentes, no válidas o demasiado grandes generan un aviso de entrega fallida. Teams también ajusta las imágenes a sus propios límites. Crear un fichero normal en el espacio de trabajo sigue sin enviarlo: requiere `send_channel_file`. El contexto del hilo conserva las fechas originales; las expresiones de tiempo relativo se refieren a esas fechas, no al momento en que el agente entra en el hilo.

Solo se adjuntan ficheros registrados como generados por las herramientas de la propia ejecución. El nombre de una herramienta o el texto de su resultado no autorizan el acceso a imágenes de otra conversación. Rechazar la generación no produce un aviso de imagen no entregada. Los hilos existentes actualizan el texto una vez, sin volver a descargar los adjuntos anteriores.

Por vinculación y no por agent, porque una organización puede vincular un agent a dos
servidores de Mattermost y tres workspaces de Slack — y *«¿puede leer lo que se dijo
en este canal?»* tiene una respuesta distinta en el interno y en el del cliente. Un
interruptor en el Toolbox del agent tendría una sola respuesta para los cinco, y por
eso no existe tal interruptor: publicar rechaza un spec que lleve `channel_tools`, y
el run ensambla la vinculación a partir de la fila que admitió el mensaje, igual que
añade el prompt de esa vinculación.

No se concede nada por defecto. Lo que una plataforma no puede responder no se ofrece:
Telegram no le da a un bot ningún directorio de chats que buscar ni forma de leer
mensajes que no se le enviaron, y `getChatAdministrators` es todo lo que puede listar
— así que una lista de miembros de Telegram es una lista de administradores y lo dice.

Tres cosas que merece la pena saber antes de concederlas:

- **La pertenencia del bot es toda la frontera de permisos.** Todas las llamadas van
  con el token propio del bot, así que el agent ve exactamente lo que ve el bot. No
  hay ninguna lista de permitidos nuestra que pueda desincronizarse de la de la
  plataforma.
- **El modelo no indica un ID de canal ni de hilo.** `read_channel_history` usa `scope="channel"` por defecto para los mensajes recientes del canal actual; `scope="thread"` lee solo el hilo vinculado. Ningún alcance accede a otro canal. El resultado es una ventana reciente limitada, no una prueba de que no existan mensajes anteriores. El contexto automático sigue limitado al hilo.
- **`read_channel_history` es la que merece la pena poner tras una puerta.** Es una
  lectura, así que no pregunta por defecto, pero mete los mensajes de otras personas
  en una transcripción de run que alguien lee semanas después. Una anulación de
  `tool_approval` en la vinculación es la forma de hacer que pregunte.

El contexto automático incluye nombre, propósito y una lista visible limitada, marcados como datos y no instrucciones. Las herramientas concedidas por vinculación permiten consultas adicionales y envío explícito de ficheros; retirar una herramienta no elimina el contexto automático.

## Un bot cuyos mensajes inician un workflow { #a-bot-whose-messages-start-a-workflow }

Un workflow con un trigger **Channel message** recibe los mensajes de un bot en lugar
de su agente. Solo le llegan los mensajes que el agente habría respondido, y de ellos
solo los que encajan con el filtro del trigger: un mensaje que nombra a otro agente
por `@handle` sigue yendo a ese agente, y uno que el filtro deja fuera lo responde el
agente del bot como hasta ahora. El workflow responde con su propio paso **Send a
message**, en el hilo del que vino el mensaje. Ver
[Desde un mensaje en un canal](workflows.md#from-a-channel-message).

## Elegir { #choosing }

```mermaid
flowchart TD
    A{a site of your own?} -->|no| L{a link will do?}
    L -->|yes| H[a hosted page]
    L -->|no| T[Slack, Teams, Telegram or Mattermost]
    A -->|yes| U{your own interface?}
    U -->|yes| WS[the raw WebSocket]
    U -->|no| P{visitors signed in to your product?}
    P -->|yes| J["the widget, <code>jwt</code> mode"]
    P -->|no| PB["the widget, <code>public</code> mode"]
    A -->|another system entirely| API["the REST API"]
```

- Un sitio propio, sin cuentas → **widget, modo `public`**.
- Dentro de tu producto, por usuario → **widget, modo `jwt`**.
- Una interfaz enteramente propia → **WebSocket**.
- Ningún sitio propio, y con un enlace basta → **una página alojada**.
- Donde el equipo ya habla → **Slack, Teams, Telegram o Mattermost**.
- Otro sistema completamente distinto → la API REST (`POST /api/v1/agents/{id}/run`).

!!! success "Los cuatro primeros son un mismo objeto"

    Un widget, un cliente de socket y una página alojada son tres formas de alcanzar
    el mismo embed, con un único conjunto de rechazos entre medias — así que «quién
    puede hablar con este agent» tiene exactamente una respuesta, por cual de los
    tres llegue alguien.

## Resumen { #recap }

- **Un runner detrás de cada superficie.** El chat web, una página alojada, un
  widget, la API, Slack, Teams, Telegram y Mattermost llegan todos al mismo código, así que
  la gobernanza no es algo que un llamante pueda esquivar.
- Un bot responde como **un agent**, y una mención se ejecuta como **el remitente** —
  nunca como el bot.
- Lo que un **desconocido** puede hacer, lo puede hacer a un ritmo: las superficies
  públicas llevan límites por llamante y por dirección contados en Redis.
- Lo que se registra es **lo que escribió la persona**, no el prompt ensamblado a su
  alrededor.
- Una **delegación es un panel**, no una pausa — y engancharle un handler cambia el
  transporte, así que solo se engancha donde existe un sumidero.

Una publicación que solo contiene la mención del bot o espacios se ignora después de eliminar la mención, antes de reclamar la entrega o iniciar una ejecución. Esto evita enviar un mensaje de usuario vacío que la API del modelo omitiría, rechazando después la respuesta anterior del asistente como prefill. Un mensaje con solo adjuntos sigue pasando por el procesamiento de archivos.

## Instrucciones predeterminadas actuales de los canales {#current-channel-defaults}

El formato y las pautas de conversación proceden del despliegue actual en cada ejecución. El prompt del vínculo guarda solo añadidos propios. La migración elimina copias exactas reconocidas de instrucciones anteriores y conserva el texto propio que las rodea; el texto modificado o desconocido permanece intacto. Vaciar el prompt propio conserva las pautas de la plataforma y el contexto del canal. No se reescriben cadenas antiguas de prompts durante la ejecución.

Al revertir la migración, se restauran las pautas de canal guardadas de la versión anterior y se conservan los añadidos propios, incluso en vínculos creados tras actualizar. Las demás superficies permanecen intactas.

## Notificaciones mediante canales vinculados { #linked-notifications }

En **Ajustes → Notificaciones** elige un destino privado por plataforma
y organización: Slack, Telegram, Mattermost, Teams, Discord, SMS e iMessage utilizan el bot
vinculado existente. Correo y push conservan su configuración. Las preferencias
de eventos y selecciones de rutina se comprueban al enviar, junto con permisos
del recurso y estado del caso compartido. Vincular la cuenta de chat no activa
por sí solo las entregas.

Slack recibe bloques de texto plano con un enlace al caso existente. Abrirlo
utiliza la autorización normal de consola. Reconocer o resolver el caso detiene
avisos externos pendientes en todos los canales. El estado de envío registra
aceptación por la plataforma; el caso conserva su propio reconocimiento.

Un [HTTP 429 de Slack](https://docs.slack.dev/apis/web-api/rate-limits/) confirmado
permite un reintento acotado tras `Retry-After`. El cliente desactiva los
[reintentos del SDK](https://docs.slack.dev/tools/python-slack-sdk/web/), por lo que
timeouts y resultados desconocidos permanecen como fallos visibles en vez de
envíos duplicados. La [política de notificaciones](governance.md) explica
revocaciones, avisos de aprobación limitados y migraciones.

## iMessage mediante un Mac de la empresa { #imessage }

Un Mac de la empresa con Mensajes y [BlueBubbles](https://docs.bluebubbles.app/server/developer-guides/rest-api-and-webhooks) proporciona el transporte. Manténgalo conectado y con la sesión iniciada, conceda los permisos de macOS que solicite BlueBubbles y active su REST API. En **Canales**, registre **iMessage** con la dirección HTTP del puente y su contraseña de servidor como token del bot. La contraseña se cifra en la bóveda de la organización; la dirección no admite credenciales, parámetros ni fragmentos.

Asocie el agente publicado, edite el canal y seleccione **Registrar webhook de iMessage** después de guardar los cambios. Cambiar la dirección o la contraseña revoca la credencial anterior del callback; vuelva a registrarlo. AgenticOS registra únicamente `new-message` en `/api/v1/imessage/{bot_id}/webhook`. Genera una credencial independiente para el callback y la registra mediante autenticación HTTP Basic. Configure HTTPS para redes no fiables. Si AgenticOS tiene una dirección privada, el administrador de BlueBubbles debe permitir expresamente que los webhooks de la API accedan a direcciones locales. Al eliminar el webhook solo se eliminan los callbacks de este bot en este despliegue.

Los textos y archivos usan el transporte AppleScript de BlueBubbles. Las confirmaciones opcionales con reacciones necesitan su Private API. Un remitente privado vinculado recibe una conversación independiente del canal. Vincular una identidad no autoriza al bot o puente a leer el historial privado anterior de la consola; los grupos conservan conversaciones independientes y requieren la mención habitual del agente. Las aprobaciones usan las preguntas numeradas existentes: responda con el número mostrado. Los ecos propios, reacciones y eventos del sistema no inician otro turno. Los adjuntos se descargan únicamente por GUID desde el puente configurado, con el límite de carga establecido.

Seleccione la conversación privada vinculada en **Ajustes → Notificaciones** para activar la entrega. Un grupo nunca es un destino privado. La aceptación del puente registra el envío, no la entrega por Apple ni la confirmación del destinatario. Un puente desconectado produce un error visible; los envíos inciertos no se repiten automáticamente.

[Apple Messages for Business](https://www.apple.com/legal/privacy/data/en/messages-for-business/) es un servicio empresarial independiente. Su [configuración con un proveedor](https://learn.microsoft.com/en-us/dynamics365/customer-service/administer/configure-apple-messages-for-business-channel) requiere una cuenta de Messages for Business y un proveedor de mensajería; este adaptador de Mac no configura esa integración.


Los formularios del webhook SMS tienen un límite de 64 KiB y diez segundos de lectura antes de verificar la firma. Las respuestas numéricas SMS e iMessage reservan el identificador del proveedor antes de procesarse para que un duplicado no alterne dos veces una selección pendiente.


El correo entrante se lee como un flujo limitado a 25 MiB antes de verificar la firma, incluso sin un Content-Length fiable.


Antes de aceptar correo de forma duradera, cada organización tiene un máximo de 4096 trabajos de entrega retenidos y 256 MiB de contenido, incluidas entregas completadas y fallidas. La admisión se serializa entre workers; los Message-ID duplicados siguen siendo idempotentes. Al alcanzar la cuota se devuelve HTTP 429 antes de almacenar otro contenido; la limpieza de retención o la eliminación de trabajos libera capacidad.