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