Tema
Usuarios identificados
Por defecto, el chat de un agente es anónimo: el navegador se inventa una identidad de conversación y nadie sabe quién escribe. Si tu aplicación ya tiene usuarios con sesión, el agente puede saber quién es cada uno. Tu backend firma un token corto (un JWT) para ese usuario, el widget o el SDK lo mandan en cada petición y ur comprueba la firma antes de hacer nada.
Con eso:
- la conversación es de ese usuario, y otro no la puede leer aunque conozca su
subjectId; - las tools saben quién pregunta y pueden reenviar su token a tu API;
- puedes dar tools y un prompt distintos a quien ha iniciado sesión y a quien no;
- el prompt puede llevar sus datos (su nombre, su email, cualquier claim del token).
Qué hace ur y qué no
ur no autentica a nadie ni firma tokens. No tiene login para tus usuarios ni guarda sus contraseñas: quien sabe quién es el usuario es tu aplicación, que ya tiene su sesión. ur solo comprueba que el token lo ha firmado tu aplicación, que es para este agente y que no ha caducado, y se fía de lo que diga.
El recorrido de una petición:
- El usuario inicia sesión en tu aplicación, como siempre.
- El navegador le pide un token a tu backend (por ejemplo,
GET /ur-tokencon la cookie de sesión de tu aplicación). - Tu backend comprueba la sesión y firma un JWT con el id del usuario en
sub, válido unos minutos. - El widget o el SDK lo mandan a
uren la cabeceraAuthorization: Bearer. urverifica la firma, la audiencia, el emisor y la caducidad. Si algo no cuadra, responde401sin abrir la conversación ni llamar al modelo.
La clave de firmar no sale de tu backend
La clave privada (o el secreto HS256) vive solo en tu backend. El navegador no firma nada: le pide a tu backend un token ya firmado, y tu backend decide si hay sesión. Una clave en el JavaScript de tu web la puede leer cualquiera, y con ella se puede firmar un token a nombre de cualquier usuario.
Las claves públicas se pegan en el panel. ur no las descarga de una URL (JWKS) ni las descubre de un proveedor OpenID Connect: si usas uno, copia su clave pública al emisor.
Crear el emisor
Un emisor es la aplicación que firma los tokens: con qué clave se verifican y qué tienen que decir. Se crea en Ajustes → Sistema → Emisores de JWT, y solo lo puede hacer un administrador. Varios agentes pueden usar el mismo, y también una inferencia que pide JWT en vez de un token de acceso.
| Campo | Qué es |
|---|---|
| Nombre | Para reconocerlo en el panel. |
| Modo | Clave pública (recomendado) o Secreto compartido (HS256). No se cambia después: para pasar de uno a otro, crea otro emisor. |
| Audiencia (aud) | Obligatoria. El token tiene que llevarla. Usa un valor propio de este uso, por ejemplo ur-soporte, para que un token que tu aplicación firma para otra cosa no valga aquí. |
| Issuer (iss) | Opcional. Si lo rellenas, el token tiene que llevar exactamente ese valor. |
| Vida máxima del token | De 1 minuto a 24 horas; por defecto, 1 hora. Un token que dure más (exp − iat) se rechaza aunque esté bien firmado. |
| Qué se guarda del usuario en sus conversaciones | Solo el sub (por defecto), los claims que elijas o todos. Ver Qué se guarda del usuario. |
Con clave pública
Tu backend firma con su clave privada y ur guarda solo la pública. Así, aunque alguien leyera la base de datos de ur, no podría firmar tokens. Para generar un par de claves ES256:
bash
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out privada.pem
openssl pkey -in privada.pem -pubout -out publica.pemprivada.pem va a tu backend, tratado como cualquier otro secreto (una variable de entorno, tu gestor de secretos). En el emisor pegas el contenido de publica.pem.
genpkey, no ecparam
openssl ecparam -genkey escribe la clave privada en otro formato (BEGIN EC PRIVATE KEY) que algunas librerías no leen para firmar, jose entre ellas. genpkey la escribe en PKCS#8 (BEGIN PRIVATE KEY), que leen todas.
Se acepta una clave pública en PEM, un certificado X.509 en PEM o una JWK. El algoritmo lo decide el tipo de clave:
| Clave | Algoritmo |
|---|---|
| EC P-256 | ES256 |
| EC P-384 | ES384 |
| Ed25519 | EdDSA |
| RSA de 2048 bits o más | RS256 |
Cada clave tiene un kid: el que escribas al pegarla, el de la JWK si lo trae o, si no, su huella (el thumbprint de RFC 7638). Si el token lleva kid en la cabecera, se verifica solo con esa clave; sin kid, se prueban todas.
Un emisor admite hasta cinco claves, para rotar sin cortes: añade la nueva, haz que tu backend firme con ella y quita la vieja cuando hayan caducado los tokens que firmó.
Si pegas una clave privada por error, se rechaza y no se guarda.
Con secreto compartido (HS256)
Al crear el emisor, ur genera un secreto aleatorio de 32 bytes y lo enseña una sola vez. Cópialo a tu backend en ese momento: después el panel solo dice que existe.
Úsalo tal cual, como texto. No lo decodifiques de base64: el secreto es la cadena entera, y con los bytes decodificados la firma no coincide.
Rotar el secreto genera uno nuevo, y el anterior deja de valer en el acto: hasta que tu backend tenga el nuevo, sus tokens dan 401.
Es más simple de montar, pero el mismo secreto que verifica también firma: quien lo consiga, en tu backend o en ur, puede firmar tokens a nombre de cualquiera. Por eso es mejor la clave pública.
Probar un token
La ficha del emisor trae Cómo firmar el token, con el código de ejemplo para su modo. Y en el listado de emisores, cada uno tiene su botón Probar un token (▶): pegas un JWT y te dice si pasaría y, si no, por qué (la firma, la audiencia, el emisor, que ha caducado, que no lleva exp, que dura demasiado, un kid que no conoce…). Se prueba contra lo guardado, así que guarda antes los cambios del emisor. Úsalo mientras montas el backend: por el canal público el motivo no se da nunca.
Pedir el usuario en el agente
En la ficha del agente, sección Autenticación, eliges quién puede hablar con él y el Emisor de los tokens. Hay tres modos (en la API y en esta documentación, por su valor):
| Modo | Sin token | Con un token válido | Con un token que no pasa |
|---|---|---|---|
Abierto (off, por defecto) | Anónimo | Se ignora: anónimo | Se ignora: anónimo |
Parcial (optional) | Anónimo | Identificado | 401 |
Obligatorio (required) | 401 | Identificado | 401 |
En Parcial, un token que no pasa no cae a anónimo: si la sesión de tu aplicación ha caducado, el cliente tiene que enterarse en vez de seguir hablando como otro.
El token se comprueba en /chat, /history y /start. /config, que el cliente pide al arrancar, sigue siendo anónimo y dice el modo en authMode (con off no viene).
- Un emisor que usa algún agente no se puede borrar.
- Pasar el agente a Abierto conserva el emisor elegido, para volver sin elegirlo otra vez.
- Como el resto de la ficha, el cambio tarda hasta 30 segundos en verse en
/embed/*.
Firmar el token en tu backend
Tu backend expone una ruta, detrás de su propia sesión, que devuelve un token recién firmado para el usuario que la pide. Lo que tiene que llevar:
| Claim | |
|---|---|
sub | Obligatorio. Quién es el usuario en tu aplicación: su id, de 1 a 256 caracteres. Mejor un id que no cambie que un email: si cambia el sub, cambia la conversación. |
aud | Obligatorio. La audiencia del emisor (o una lista que la incluya). |
iss | Si el emisor lo pide, exactamente ese valor. |
exp | Obligatorio. Un token sin caducidad se rechaza. Unos 10 minutos está bien: el cliente pide otro cuando caduca. |
iat | Recomendado. La vida del token (exp − iat) no puede pasar de la máxima del emisor; sin iat, se cuenta desde el momento de la petición. |
nbf | Opcional. |
| Los tuyos | Lo que quieras que lean las tools: email, plan, tenantId… |
ur deja 60 segundos de margen de reloj en exp, nbf e iat. En la cabecera, alg tiene que ser el del emisor: el algoritmo lo fija el emisor, y el del token solo se compara con él. Si mandas typ, que sea JWT. El token entero no puede pasar de 8192 caracteres.
ts
import { SignJWT, importPKCS8 } from 'jose';
// El contenido de privada.pem, desde el entorno o tu gestor de secretos.
const privateKey = await importPKCS8(process.env.UR_JWT_PRIVATE_KEY!, 'ES256');
export function signUrToken(user: { id: string; email: string }): Promise<string> {
return new SignJWT({ email: user.email })
.setProtectedHeader({ alg: 'ES256', kid: 'mi-app-2026' }) // el kid de la clave en el emisor
.setSubject(user.id)
.setAudience('ur-soporte')
.setIssuer('https://mi-app.example.com') // solo si el emisor pide un iss
.setIssuedAt()
.setExpirationTime('10m')
.sign(privateKey);
}
// Con Express: la ruta va detrás de tu sesión, como cualquier otra.
app.get('/ur-token', requireLogin, async (req, res) => {
res.type('text/plain').send(await signUrToken(req.user));
});ts
import { SignJWT } from 'jose';
// El secreto que enseñó ur al crear el emisor, como texto: sin decodificarlo.
const secret = new TextEncoder().encode(process.env.UR_JWT_SECRET!);
export function signUrToken(user: { id: string; email: string }): Promise<string> {
return new SignJWT({ email: user.email })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setAudience('ur-soporte')
.setIssuedAt()
.setExpirationTime('10m')
.sign(secret);
}php
<?php
use Firebase\JWT\JWT;
function signUrToken(array $user): string
{
$now = time();
$payload = [
'sub' => (string) $user['id'],
'email' => $user['email'],
'aud' => 'ur-soporte',
'iss' => 'https://mi-app.example.com', // solo si el emisor pide un iss
'iat' => $now,
'exp' => $now + 600,
];
// privada.pem (PKCS#8); el último argumento es el kid de la clave en el emisor.
return JWT::encode($payload, file_get_contents('/ruta/segura/privada.pem'), 'ES256', 'mi-app-2026');
}
// En tu ruta /ur-token, detrás de tu sesión:
header('Content-Type: text/plain');
echo signUrToken($currentUser);php
<?php
use Firebase\JWT\JWT;
function signUrToken(array $user): string
{
$now = time();
$payload = [
'sub' => (string) $user['id'],
'email' => $user['email'],
'aud' => 'ur-soporte',
'iat' => $now,
'exp' => $now + 600,
];
// El secreto que enseñó ur al crear el emisor, tal cual.
return JWT::encode($payload, getenv('UR_JWT_SECRET'), 'HS256');
}Los ejemplos de Node usan jose y los de PHP, firebase/php-jwt, pero vale cualquier librería de JWT. Si no hay sesión, que la ruta conteste un error (un 401, por ejemplo): el cliente de abajo lo trata como «sin token».
Pasarlo desde el cliente
Con el SDK
La opción getUserToken es una función que devuelve el token:
ts
const client = new UrAgentClient({
agent: '<id-del-agente>',
apiBase: 'https://<tu-backend-ur>',
getUserToken: async () => {
const res = await fetch('/ur-token', { credentials: 'same-origin' });
return res.ok ? res.text() : null; // sin sesión: habla anónimo
},
});- Se llama antes de cada petición a
/chat,/historyy/start, nunca en/config. Lo que devuelva va comoAuthorization: Bearer <token>, sin los espacios ni saltos de línea de los extremos. - Si devuelve vacío,
nulloundefined, la petición va anónima: vale conoptionaly da401conrequired.getConfig().authModedice cuál de los dos tiene el agente. - Cambiar de token (iniciar o cerrar sesión) es cambiar de conversación: ver La conversación es del usuario.
Se llama en cada petición. Si firmar te cuesta, reutiliza el token durante parte de su vida:
ts
let cached: { token: string; at: number } | null = null;
async function getUserToken(): Promise<string | null> {
// Los tokens duran 10 minutos: cada uno se reutiliza durante 8.
if (cached && Date.now() - cached.at < 8 * 60_000) {
return cached.token;
}
const res = await fetch('/ur-token', { credentials: 'same-origin' });
if (!res.ok) {
return null;
}
cached = { token: await res.text(), at: Date.now() };
return cached.token;
}
// Al cerrar sesión en tu aplicación: cached = null.Vacía esa caché al cerrar sesión, o el chat seguiría hablando como el usuario anterior. Y que no devuelva un token ya caducado: el SDK solo reintenta con un token distinto del que falló.
Con el widget
El widget recibe la misma función en su propiedad getUserToken. Es solo propiedad de JavaScript, no atributo: una función no cabe en un atributo.
html
<script src="https://<tu-backend-ur>/widget.js" data-agent="<id-del-agente>"></script>
<script>
document.querySelector('ur-agent-chat').getUserToken = async () => {
const res = await fetch('/ur-token', { credentials: 'same-origin' });
return res.ok ? res.text() : null;
};
</script>Asígnala justo después del <script> del widget. El elemento se crea en cuanto se ejecuta ese script y pide el historial cuando le llega la configuración, así que asignada ahí la primera petición ya lleva el token. Asignada más tarde, vale desde la petición siguiente. Si importas el widget como módulo, asígnala antes de poner agent o de insertar el elemento.
Sin token, en un agente que lo exige, el widget no recupera el historial y al escribir pinta el error en el chat.
Errores y reintento
Un token que no pasa recibe 401 con el motivo en error y la cabecera WWW-Authenticate:
http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
Content-Type: application/json
{ "error": "token_expired" }error | Cuándo | Qué hacer |
|---|---|---|
token_missing | El agente es required y no llegó token. | Que el usuario inicie sesión. |
token_invalid | La firma, la audiencia, el emisor, el algoritmo, el kid, el sub, una vida demasiado larga, la falta de exp… También una cabecera Authorization que no es Bearer <token>. | Revisa tu backend con Probar un token. |
token_expired | Ha caducado. | Pedir otro. El SDK ya lo hace una vez. |
El motivo es grueso a propósito: decirle a un cliente público si falló la firma o la audiencia solo ayuda a quien prueba tokens.
El 401 llega antes que todo lo demás: no se abre conversación, no se gasta turno, no pasa por los filtros de seguridad y no llama al modelo. Lo único que ya ha contado es el límite de peticiones por IP, que es lo que frena a quien prueba tokens a lo bruto.
Lo que hace el SDK:
- Ante
token_expired, vuelve a llamar agetUserTokeny repite la petición una vez con el token nuevo. Si devuelve el mismo token, o ninguno, no repite. - Ante
token_missingotoken_invalidno reintenta: repetir daría lo mismo. - Lo que queda llega a
onError(message, info), coninfo.statusy, en un401,info.code.loadHistory()rechaza con unUrAgentClientHttpError(constatusycode), ycreateChatStoredeja el motivo enerrorCode.start()no falla hacia fuera.
ts
client.onError((message, info) => {
if (info?.status === 401) {
// info.code: token_missing | token_invalid | token_expired
pedirQueInicieSesion(); // tu UI
return;
}
console.warn(message);
});La conversación es del usuario
Con un usuario identificado, la conversación ya no se identifica solo por agente, subjectId y canal: también por el usuario (su emisor y su sub). Eso quiere decir que:
- Otro usuario que mande el mismo
subjectIdcae en otra conversación, vacía. Conocer unsubjectIdya no da acceso a nada. - Un usuario identificado nunca se queda con una conversación anónima. Si alguien empieza anónimo y después inicia sesión (agente en
optional), con el token empieza otra; al cerrar sesión, vuelve a la anónima. - El mismo usuario, en el mismo navegador, retoma su conversación al volver, también si estaba cerrada por el límite de turnos.
- El límite de mensajes por hora y el de ráfaga se cuentan por usuario, no por la huella del navegador.
Cada navegador guarda su propio subjectId, así que en otro dispositivo el usuario empieza otra conversación. Todavía no hay forma de listar las conversaciones de un usuario para retomar una desde otro sitio.
En Conversaciones del agente, las de un usuario identificado llevan su sub junto al canal, y la cabecera de la conversación dice además de qué emisor era el token. El buscador también encuentra por sub. Si el emisor guarda más datos del token (ver Qué se guarda del usuario), la cabecera los enseña en Datos del token.
Qué se guarda del usuario
Por defecto, de un usuario identificado ur solo guarda su sub. En el emisor puedes elegir guardar más con cada conversación, para verlo en Conversaciones:
- Solo el sub (por defecto).
- Los que elijas: los claims que pongas, separados por comas (
email, name). Un claim con espacio de nombres, comohttps://tu-app.com/roles, se escribe entero. - Todos los claims: todo lo que lleve el token.
El token en sí no se guarda nunca: mientras no caduca, quien lo tuviera podría hablar como ese usuario. Se guardan los datos del último mensaje de cada conversación. Los ve quien pueda ver las conversaciones del agente, aunque no sea administrador. Si después reduces lo que se guarda (o borras el emisor), lo que sobra se borra en el momento de todas sus conversaciones; si lo amplías, solo se nota desde el siguiente mensaje de cada usuario.
Sus datos en las instrucciones
En Parcial y en Obligatorio, las Instrucciones para usuarios identificados (sección Autenticación del agente) pueden llevar datos del token de quien pregunta: {{jwt.email}}, {{jwt.name}} o cualquier otro claim. En cada turno, ur los cambia por los de ese usuario y añade el texto al final del prompt del agente, en un bloque aparte.
text
Hablas con {{jwt.name}} ({{jwt.email}}), cliente de la tienda {{jwt.org.name}}.
Sus roles: {{jwt.roles}}.Con un token cuyo payload es {"sub": "u_8124", "name": "Ana Pérez", "email": "ana@example.com", "org": {"name": "Bilbao"}, "roles": ["admin", "ventas"]}, el modelo lee:
text
Hablas con Ana Pérez (ana@example.com), cliente de la tienda Bilbao.
Sus roles: admin, ventas.- Cómo se escriben.
{{jwt.<claim>}}, con dos llaves a cada lado (los espacios dentro valen:{{ jwt.email }}). Con un punto se entra en un objeto ({{jwt.org.id}}) o en una lista, por su posición ({{jwt.roles.0}}), y{{jwt}}es el payload entero en JSON. El nombre del claim, como en la tool HTTP: letras, números y_. Un claim con espacio de nombres (https://tu-app.com/roles) no se puede usar aquí. - Cómo sale cada valor. Un texto, tal cual y en una sola línea: los saltos de línea se cambian por espacios, para que un valor no pueda abrir una sección nueva del prompt. Un número o un booleano, como tal. Una lista de valores sueltos, separada por comas. Un objeto (o una lista con objetos), en JSON. Un claim que el token no trae, en blanco; si todo el texto se queda en blanco, no se añade nada.
- Solo con un usuario identificado. En Parcial, un anónimo no recibe este texto, sino las instrucciones para anónimos, que no pueden llevar datos del usuario. Un token que no pasa no llega nunca al modelo:
/embedcontesta401antes. En el chat de pruebas del panel, los datos salen de la identidad de prueba; sin ella, el chat habla como anónimo. - Por qué no en el prompt del agente. Ese prompt es el mismo para todos y el proveedor del modelo lo cachea. Los datos de cada usuario van detrás, en un bloque aparte, así que la caché sigue funcionando. Un
{{jwt.email}}en el prompt del agente se mandaría tal cual. - Adónde van. Al proveedor del modelo, en cada turno, aunque el emisor no guarde ninguno con la conversación (Qué se guarda del usuario habla de la base de datos de
ur). El token, nunca: no tiene placeholder. - Mal escrito no se guarda.
{{jwt.e-mail}},{jwt.email}o el{{$user.claims.email}}de la tool HTTP: el panel lo avisa junto al campo y la API contesta400. Cualquier otra cosa entre llaves ({{nombre}}) se manda tal cual. - La fecha y la hora también.
{{now}},{{time}}y sus versiones con zona ({{now.Europe/Madrid}}) valen aquí como en el prompt del agente (La fecha y la hora), también en las instrucciones para anónimos: las del inicio de la conversación. Se ponen antes que los datos del usuario, así que un claim que traiga{{now}}se manda tal cual.
Los valores los firma tu aplicación, pero alguno lo escribe el propio usuario (su nombre, por ejemplo). Preséntalos como datos y no como órdenes: «El nombre que ha dado el usuario es {{jwt.name}}», mejor que «{{jwt.name}}» suelto en mitad de las instrucciones.
Usarlo en tus tools
Si escribes tus propias tools sobre @nykk/ur-core, el usuario del turno sale de getEndUser(runConfig):
ts
interface EndUser {
issuerId: string | null; // el emisor; null en una identidad de prueba hecha solo de claims
sub: string; // quién es (también en claims.sub)
claims: Record<string, unknown>; // el payload entero del token
token: string | null; // el JWT tal cual, para reenviarlo a tu API
verified: boolean; // false solo con una identidad de prueba del panel
}Devuelve null si el turno es anónimo, si el agente no pide usuario o si la tool corre fuera de un agente (una inferencia —también una que pide JWT para entrar: el token solo abre la puerta—, o Probar tool sin identidad de prueba). El usuario no llega al modelo: solo lo leen las tools.
ts
import { tool } from '@langchain/core/tools';
import { getEndUser, type ToolInstanceConfig, type ToolTemplate } from '@nykk/ur-core';
import { z } from 'zod';
export const myOrdersTool: ToolTemplate = {
id: 'my_orders',
name: 'My Orders',
description: 'Los últimos pedidos del usuario identificado.',
scope: 'app',
// Sin usuario no sirve: no se engancha a un agente en `off` ni a una inferencia.
requiresEndUser: () => true,
functions: [
{
suffix: '',
description: 'Los últimos pedidos del usuario que pregunta.',
parametersSchema: z.object({}),
factory: (instance: ToolInstanceConfig) =>
tool(
async (_input, runConfig) => {
const user = getEndUser(runConfig);
if (!user) {
return 'Nadie ha iniciado sesión: para ver sus pedidos, el usuario tiene que hacerlo.';
}
// Una identidad de prueba del panel trae claims que alguien ha escrito a mano.
if (!user.verified) {
return 'Identidad de prueba: no se consultan pedidos reales.';
}
return JSON.stringify(await listOrders(user.sub)); // tu código
},
{ name: instance.name, description: instance.description, schema: z.object({}) },
),
},
],
};verifiedsolo esfalsecon las identidades de prueba del panel. Una tool que decide permisos con el usuario tiene que mirarlo: en Probar agente, quien tenga acceso al agente puede escribir los claims que quiera.requiresEndUsermarca una plantilla que no sirve sin usuario. Una instancia suya no se puede enganchar a un agente enoffni a una inferencia, y en un agente enoptionalel modelo no la ve en los turnos anónimos. Puede depender de la configuración de la instancia:requiresEndUser: (config) => ….
Con la tool de petición HTTP
La plantilla http_request manda a tu API quién es el usuario sin escribir código: un claim del token en la ruta, la query, una cabecera o el cuerpo, o el token entero, que tu API verifica por su cuenta. El interruptor Reenviar el token del usuario añade Authorization: Bearer <token> a la petición.
Una instancia que usa al usuario solo se engancha a agentes en Parcial u Obligatorio, y en un turno sin usuario no sale ninguna petición: el modelo lee que el usuario tiene que iniciar sesión. Dónde se admite cada valor y el resto de reglas, en Tool de petición HTTP.
Modo parcial
Con el agente en Parcial (optional), los anónimos y los identificados hablan con el mismo agente, y puedes separar lo que recibe cada uno.
Tools solo para usuarios identificados. En la lista de tools del agente, cada una se puede marcar Solo usuarios identificados. En un turno anónimo, el modelo no la ve. Si aun así la llama (porque la vio antes en la conversación, por ejemplo), no se ejecuta: el modelo recibe que el usuario tiene que iniciar sesión. Las tools que necesitan usuario por sí mismas (una http_request que manda al usuario, una plantilla con requiresEndUser) van siempre marcadas. La marca vale en cualquier modo: en Abierto no hay usuario nunca, así que una tool marcada no la ve nadie.
Instrucciones por audiencia. Dos textos que se añaden al final del prompt del agente, según quién pregunte en cada turno: Instrucciones para usuarios identificados e Instrucciones para anónimos. Hasta 20 000 caracteres cada uno. Van detrás del prompt, en un bloque aparte, así que la caché del prompt del proveedor sigue funcionando. Las de los anónimos solo se aplican en Parcial, el único modo en que los hay. Las de los identificados, también en Obligatorio, y pueden llevar sus datos: ver Sus datos en las instrucciones.
Por ejemplo:
- Para anónimos: «Si te piden algo de su cuenta, diles que inicien sesión en la web.»
- Para usuarios identificados: «Puedes consultar sus pedidos con la tool
mis_pedidos.»
En Parcial, la vista previa del prompt se puede ver como Anónimo o como Identificado: enseña el prompt y las tools que recibe el modelo en cada caso. En Obligatorio, se ve siempre como Identificado. Los datos del usuario salen sin sustituir: en la vista previa no hay token.
Probar en el panel
Probar un token, en la fila del emisor (Ajustes → Sistema → Emisores de JWT): si un JWT pasaría y por qué no.
Probar agente: si el agente pide usuario, el chat de pruebas tiene una Identidad de prueba. En ella pegas:
- un JWT con Verificar la firma, que se verifica contra el emisor del agente, como en el canal real (si no pasa, dice por qué);
- un JWT sin verificar: se leen sus claims sin mirar la firma ni la caducidad, y el token se reenvía tal cual, así que el backend que lo reciba lo verificará igualmente;
- unos claims en JSON (con
sub): un usuario inventado, sin token, así que una tool que reenvía el token del usuario no hace la petición y lo dice.
Las dos últimas llegan a las tools con
verified: false. Sin identidad, pruebas como anónimo, también en un agente Obligatorio (el canal real le contestaría401).La conversación del chat de pruebas sigue siendo tuya: cambiar de identidad no abre la de ese usuario ni empieza otra, así que el modelo ve lo que hablaste con la identidad anterior. Para empezar de cero, reinicia la conversación.
Probar tool tiene la misma Identidad de prueba. Para verificar la firma hay que elegir el emisor, porque una tool no tiene agente del que sacarlo.
Qué dice la API
GET /api/agents, con un access token, dice de cada agente si pide usuario y qué token acepta: lo justo para que tu backend sepa firmarlo. Nunca claves ni secretos.
json
{
"id": "0190c2a4-…",
"name": "Soporte",
"identifier": "soporte",
"auth": {
"mode": "optional",
"issuer": {
"issuer": "https://mi-app.example.com",
"audience": "ur-soporte",
"algorithms": ["ES256"]
}
},
"tools": [
{ "name": "calculadora", "requiresEndUser": false },
{ "name": "mis_pedidos", "requiresEndUser": true }
]
}Con mode: "off", issuer es null. Una tool con requiresEndUser: true solo existe para un usuario identificado.
Desde el MCP, get_agent y list_agents enseñan además el nombre del emisor y sus claves por kid, algoritmo y huella, nunca el secreto. El modo y el emisor de un agente se cambian en el panel: el MCP todavía no lo permite.