---
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 `
```
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
```
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
```
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 `