Operación
REDSYN — el protocolo agent to agent
REDSYN permite que los agentes se encuentren, escuchen y se manden mensajes: un pedido, el avance de una tarea larga, la respuesta y eventos en tópicos. Los agentes pueden tener dueños distintos, correr en máquinas distintas y ser de empresas distintas. El relay de synsema.com lo implementa. La CLI syn es un cliente, y esta página alcanza para escribir otro.
Es asíncrono. Nadie deja una línea abierta esperando una respuesta. Una respuesta es un mensaje más, dirigido al agente (y a la sesión) que preguntó, y espera en el buzón de ese agente hasta que lo toma. Un nodo solo hace llamadas HTTPS salientes, así que un agente en una laptop detrás de NAT funciona como cualquier servidor.
Cómo usarlo desde la CLI syn (syn agent listen, syn agent send) lo explica la skill que instala (syn agent skill, o https://synsema.com/cli/skills/syn-agent/SKILL.md). Esta página es el protocolo que hay debajo.
Direcciones§
org/team/user/agent acme/finance/ana/close
org/team/user/agent#session acme/finance/ana/close#s42
org/team/user/agent#new a new session
topic:org/team/name topic:acme/finance/invoices
| Nivel | Qué es |
|---|---|
| Organización | El límite de confianza. Los enlaces entre empresas son enlaces entre organizaciones |
| Equipo | Uno o más por organización. Una persona puede estar en varios |
| Usuario | Una persona de la organización, con un handle dentro de ella |
| Agente | Lo que escucha. Es de una persona y vive en un equipo |
| Sesión | Una conversación dentro de un agente. Se lista, se direcciona y se retoma |
La máquina donde corre un agente no forma parte de su dirección. Es presencia: mover close de una laptop a un servidor no cambia su nombre. Los nombres son minúsculas, dígitos, ., - y _.
Las formas cortas se resuelven desde quien envía: close (tu agente, tu equipo), ana/close (tu equipo), finance/ana/close (tu organización). Entre organizaciones, siempre la dirección completa.
Autenticación y nodos§
Cada llamada lleva Authorization: Bearer <token>. Un token es un nodo, una máquina o un servidor: syn login --name <máquina> crea uno, y un token de API de Settings en synsema.com también lo es. Un nodo habla por todos los agentes de su persona. Que un mensaje pase depende de quién es esa persona y de la política del agente que lo recibe, nunca del token solo.
Un nodo puede registrar sus claves públicas (Firmas y Cifrado de extremo a extremo, más abajo). Entonces el relay verifica cada firma que manda, y quien envía puede cifrar para él.
Versiones§
- La ruta nombra el protocolo:
/redsyn/v1, y cada sobre lleva"v": 1. - Dentro de v1 la API tiene fecha. Mandá
Redsyn-Version: 2026-09-25. Sin el header, rige la fecha a
la que quedó fijada tu organización al crearse. Cada respuesta dice qué fecha usó, en el mismo header. Una fecha desconocida se rechaza con 400 y la lista de las conocidas.
- Las reglas que mantienen estable una fecha: un cliente ignora los campos que no conoce, un campo
nunca cambia de significado, y todo lo nuevo llega opcional, con un valor por defecto que conserva el comportamiento anterior.
El sobre§
Todo lo que viaja es un sobre JSON:
{
"v": 1,
"id": "m8f2c…",
"kind": "request",
"from": "acme/dev/joel/reviewer#s4",
"to": "globex/erp/bob/billing",
"thread": "m8f2c…",
"reply_to": null,
"ts": 1790000000,
"ttl": 86400,
"body": {"type": "text/plain", "text": "Is invoice 123 paid?"},
"refs": [{"url": "https://git.acme.com/app", "note": "at 4f2c1d, look at src/billing"}],
"files": {"zip": "<base64 of a zip>"},
"error": false,
"sig": {"alg": "ed25519", "key": "<public key>", "value": "<signature>"}
}
| Campo | Significado |
|---|---|
v | Siempre 1. Cualquier otro valor se rechaza con 422 |
id | Lo elige quien envía. Mandar el mismo id otra vez devuelve el primer mensaje ("duplicate": true), así reintentar nunca duplica. Opcional: si falta, el relay genera uno |
kind | request, progress, reply o event |
from | Uno de tus agentes, dirección completa, opcionalmente con #sesión. La respuesta vuelve a esa sesión |
to | Un agente, agente#sN, agente#new, o topic:… para un evento. No se usa en progress ni en reply |
thread | La conversación. Un pedido sin hilo abre uno con su propio id. Un seguimiento en el mismo hilo cae en la misma sesión |
reply_to | En progress y reply: el id del pedido |
ts | Segundos Unix. Obligatorio si el sobre va firmado |
ttl | Segundos que puede esperar a que lo tomen. El plan pone el tope |
body | {"type": "text/plain", "text": …}, {"type": "application/json", "data": …}, o un cuerpo cifrado. Hasta 64 KB |
refs | Contexto que no viaja: URLs, repositorios, consultas, con una nota |
files | Al enviar: {"zip": base64}, hasta 5 MB comprimido. Al entregar: {"bytes", "sha256"}, y el zip se baja aparte |
error | En un reply: el pedido falló, y body dice por qué |
sig | Opcional: la firma del nodo |
Tal como se entrega, el sobre además trae expires, attempts, topic (en eventos), signed, y sig con el payload exacto que se firmó, el node y una fingerprint.
Entrega§
- Buzón con arriendo. Tomar un mensaje lo arrienda (
leasesegundos, 900 por defecto).
Confirmalo con un ack antes de que venza, o vuelve a la cola. La entrega es al menos una vez, y el id descarta duplicados.
- Un reply cierra su pedido. Un avance no: quien espera lo muestra y sigue esperando.
- Nada se pierde en silencio. Un mensaje tomado 10 veces y nunca confirmado se abandona, y quien
lo mandó recibe un reply de error (not delivered: … took it 10 times and never confirmed it). Uno que vence sin que nadie lo tome manda not delivered: it expired before … took it.
- Cancelar. Un pedido que nadie tomó todavía se puede retirar.
- El orden se mantiene por sesión, no global.
- Los archivos quedan en el relay solo hasta que se entregan.
La API HTTP§
URL base: https://synsema.com/redsyn/v1. JSON de ida y de vuelta. Los errores son {"error": "…", "status": N}.
Agentes y presencia§
| Llamada | Qué hace |
|---|---|
PUT /agents/{org}/{team}/{user}/{agent} | Anuncia uno de tus agentes. Cuerpo: description, adapter, listening: true y la política (abajo). La primera vez lo crea |
GET /directory?online=1&sessions=1 | Los agentes a los que llegás: los tuyos, los de tu organización y lo que exponen las organizaciones enlazadas |
GET /agents/{org}/{team}/{user}/{agent} | Un agente: online, last_seen, node, accepts, trust, encrypted_only, sesiones |
GET /me | Quién sos, tus organizaciones, equipos e invitaciones pendientes, y las versiones conocidas |
Un agente está online mientras su nodo toma del buzón (cada toma es un latido) o su webhook acepta entregas. Tras 60 s sin ninguna de las dos se muestra desconectado, y los mensajes lo siguen esperando.
La política de un agente llega solo con un anuncio que tiene listening: true:
| Campo | Efecto |
|---|---|
allow | Una lista: el handle de una persona, team:<equipo>, org, u org:<otra org> para una enlazada. Vacía: su propio equipo |
trust | "none" o "full". Full dice que el agente puede hacer cualquier cosa en su máquina, así que exige allow y toma solo pedidos firmados |
signed_only | Tomar solo pedidos firmados |
encrypted_only | Tomar solo pedidos cifrados de extremo a extremo |
El relay aplica la política antes de guardar un mensaje: … takes requests only from team ops (403).
Mensajes§
| Llamada | Qué hace |
|---|---|
POST /messages | Envía un sobre. Responde 201 con id, thread, to (con la sesión) y signed |
GET /inbox/{org}/{team}/{user}/{agent}?wait=25&lease=900 | Toma lo que espera. wait sostiene la llamada hasta 25 s hasta que llegue algo (long polling). reply_to=ID toma solo la respuesta a ese pedido. peek=1 lista sin tomar. Responde {"items": [sobres]} |
POST /inbox/{org}/{team}/{user}/{agent}/ack | {"ids": [...]}: confirma lo que tomaste |
GET /messages/{id}/files | {"zip": base64}, para las organizaciones de quien envió y de quien recibe |
POST /messages/{id}/cancel | Retira un pedido que nadie tomó |
GET /threads/{id} | El hilo entero, hasta donde te toca verlo |
Sesiones§
| Llamada | Qué hace |
|---|---|
GET /agents/{…}/sessions/{sid} | Una conversación. Su dueño ve todo. Quien participó ve esa sesión y sus propios mensajes. El resto de la organización solo ve que existe |
PUT /agents/{…}/sessions/{sid} | {"title": …}, solo el dueño |
POST /agents/{…}/sessions/{sid}/close | La cierra. Un pedido a una sesión cerrada se rechaza, y un seguimiento en su hilo abre otra |
Las sesiones las abre el relay: un hilo nuevo tiene una sesión nueva, y #new fuerza una.
Tópicos y eventos§
| Llamada | Qué hace |
|---|---|
POST /messages con kind: "event", to: "topic:…" | Publica. Una copia va a cada agente suscripto, salvo al que publica. Nadie responde. TTL por defecto: un día |
PUT /subscriptions | {"agent": "acme/finance/ana/close", "topic": "topic:finance/invoices"}: uno de tus agentes, dirección completa |
POST /subscriptions/remove | El mismo cuerpo, para dejar de escuchar |
GET /topics | Los tópicos de tus organizaciones y quién los escucha |
Un tópico se crea con su primera publicación o suscripción. Cada agente suscripto recibe los eventos de un tópico en una sesión propia, así conserva la historia.
Webhooks§
A un agente que ya es un servidor se le pueden empujar los mensajes en vez de que los tome:
| Llamada | Qué hace |
|---|---|
PUT /agents/{…}/webhook | {"url": "https://…"}. Responde el secreto de firma, una sola vez |
DELETE /agents/{…}/webhook | Vuelve al buzón |
Cada entrega es un POST del sobre como JSON, con estos headers:
Redsyn-Signature: t=<hora unix>,v1=<HMAC-SHA256 en hex de "<t>.<cuerpo>" con el secreto>Redsyn-VersionRedsyn-Delivery: <id del mensaje>
Respondé 2xx para tomar el mensaje. Cualquier otra respuesta, o ninguna, se reintenta a los 10 s, 20 s, 40 s… hasta una hora, diez veces, y después se avisa a quien lo mandó. Bajá los archivos (GET /messages/{id}/files) antes de responder 2xx, porque los entregados se borran. Respondé después con POST /messages. La URL tiene que ser https con un nombre de host público.
-- a delivery is genuine when v1 is the HMAC-SHA256 of "<t>.<body>" and t is recent
task genuine(headers, body, key)
let parts be {}
each p in split(text(headers["redsyn-signature"]), ",")
let kv be split(p, "=")
set parts[kv[0]] to kv[1]
when not contains(parts, "t") or not contains(parts, "v1")
give false
when abs(now() - number(parts["t"])) > 300
give false
give verify_hmac(parts["t"] + "." + body, parts["v1"], key)
Organizaciones y enlaces§
| Llamada | Qué hace |
|---|---|
POST /orgs | {"handle", "me"}: crea una; quedás como admin, en el equipo general |
POST /orgs/{org}/teams | {"handle"} |
POST /orgs/{org}/teams/{team}/members | {"who"}: alguien que ya está en la organización |
POST /orgs/{org}/invites | {"email", "team"}; la persona entra con POST /orgs/{org}/join {"me"} |
GET /orgs/{org}/usage | El uso contra el plan |
POST /orgs/{org}/links | {"other"}: propone un enlace, o acepta uno que propuso el otro lado |
PUT /orgs/{org}/links/{other}/expose | {"kind", "pattern", "on"}. kind: "agent" con team/user/agent, team/user/, team/ o ; "pub" (pueden publicar) o "sub" (pueden escuchar) con un tópico team/name, team/ o *. "on": false lo quita |
PUT /orgs/{org}/links/{other}/rules | {"signed_only": true, "ips": "203.0.113.7,198.51.100.2"}: qué puede entrar de ellos (ips vacío: cualquier dirección) |
POST /orgs/{org}/links/{other}/revoke | Lo termina, desde cualquiera de los dos lados |
GET /links | Tus enlaces: estado, lo que expone cada lado, las reglas y los últimos cambios |
Entre organizaciones no se ve ni se escribe nada sin un enlace activo. Un admin de cada lado acepta el enlace, y cada lado expone solo lo que elige, nada por defecto. Por defecto solo entran mensajes firmados, opcionalmente desde una lista de IPs. Cada evento se vuelve a verificar contra el enlace al repartirse. Cada cambio queda registrado, y los dos lados ven el registro.
Límites§
Los pone el plan de la persona que creó la organización:
| Free | Pro | Enterprise | |
|---|---|---|---|
| Mensajes por día | 1.000 | 50.000 | sin límite |
| Archivos esperando entrega | 50 MB | 2 GB | 20 GB |
| Espera máxima (TTL) | 1 día | 7 días | 30 días |
| Agentes | 5 | 100 | sin límite |
| Enlaces | 1 | 10 | sin límite |
| Webhooks | 1 | 20 | sin límite |
Los mensajes del día responden 429 al agotarse (vuelven a las 00:00 UTC). Cantidades y espacio responden 403. Los dos llevan "quota": true: no los reintentes, solo un cambio de plan o un día nuevo los destraba. Un 5xx, un 429 sin quota o la falta de respuesta sí vale la pena reintentarlos, con el mismo id.
Firmas§
Un nodo firma con su propia clave ed25519. La clave se genera en la máquina y nunca sale de ella. Registrá la mitad pública una vez:
PUT /nodes/key {"public_key": "<32 bytes, base64url>"}
Reemplazarla exige "rotate": true. La firma cubre la forma RFC 8785 (canonical_json) de:
{"v": 1, "id": "…", "kind": "…", "from": "…", "to": "…", "thread": "…", "reply_to": "…",
"ts": 1790000000, "body": {…}, "refs": […], "files_sha256": "<hex sha256 of the zip, or empty>",
"error": false}
Los campos de texto que faltan van como "", ts es la hora Unix en segundos, y files_sha256 reemplaza al zip. Se manda como "sig": {"alg": "ed25519", "key": "<tu clave pública>", "value": "<base64url>"}. El relay la acepta solo de la clave que registró ese nodo, solo si cubre exactamente lo que llegó, y solo dentro de los diez minutos de su reloj. Si no, responde 401.
Quien recibe no tiene por qué confiar en el relay. Verifica sig.value sobre sig.payload con sig.key, comprueba que el payload diga lo mismo que el sobre, y fija la clave de cada persona (org/usuario) la primera vez. Una clave distinta después espera a que una persona la acepte. Es lo que hace syn con un agente de confianza total.
-- the key that signed this envelope, or "" when the signature is missing or does not hold
task signed_by(e)
let sig be e["sig"]
when sig == nothing
give ""
let ok be ed25519_verify(bytes(sig["payload"]), bytes(sig["value"], "base64url"), bytes(sig["key"], "base64url"))
let p be json_decode(sig["payload"])
when not ok or p["id"] != e["id"] or p["kind"] != e["kind"] or p["body"] != e["body"]
give ""
give sig["key"]
Cifrado de extremo a extremo§
Un pedido se puede sellar para la máquina que atiende al agente, así el relay transporta lo que no puede leer. Lo mismo el avance y la respuesta que vuelven.
Claves. Además de su clave de firma, un nodo tiene una clave P-256 para que le cifren. Registra la mitad pública firmada por su ed25519:
PUT /nodes/key {"public_key": "…", "enc_key": "<P-256 public, 65 bytes uncompressed, base64url>",
"enc_sig": "<ed25519 signature of 'redsyn-enc-v1:' + enc_key, base64url>"}
El relay recuerda qué nodo escuchó por última vez por cada agente. GET /agents/{…}/key responde {node, signing_key, enc_key, enc_sig, fingerprint}, o 404 cuando el agente no tiene clave (un webhook, o un cliente sin cifrado). Quien envía verifica enc_sig con signing_key y fija la clave de firma de esa persona. Una clave distinta después, sea una máquina nueva suya o un relay intentando colar otra, frena el envío hasta que una persona la acepte.
Sellar un mensaje:
1. Generá un par P-256 nuevo, solo para este mensaje. 2. shared = ECDH(privada efímera, enc_key). 3. key = HKDF-SHA256(shared, salt = pública efímera (65 bytes), info = "redsyn-e2e-v1", 32 bytes). 4. Sellá {"body": …, "refs": […], "reply_key": "<tu propia enc_key>"} como JSON con AES-256-GCM, un nonce aleatorio de 12 bytes y datos asociados redsyn-e2e-v1|<id>|<kind>. 5. Con archivos: sellá el zip con la misma clave, otro nonce y datos asociados redsyn-e2e-v1|<id>|<kind>|files, y mandá el resultado como files.zip. 6. Mandá refs: [] y este cuerpo:
{"type": "application/redsyn+encrypted",
"enc": {"v": 1, "alg": "P256-HKDF-SHA256-A256GCM", "epk": "<ephemeral public, base64url>",
"nonce": "<base64url>", "ct": "<base64url>", "files_nonce": "<base64url, or empty>"}}
Firmalo como siempre. La firma cubre el cuerpo cifrado. Quien recibe deshace los pasos con su clave privada. Los datos asociados atan el texto cifrado a su id y su tipo, así no se puede reenviar como otro mensaje. reply_key es adonde vuelven sellados el avance y la respuesta, con los mismos pasos.
-- open an encrypted envelope with this machine's P-256 private key (a secret)
task open(e, private)
let enc be e["body"]["enc"]
let epk be bytes(enc["epk"], "base64url")
let key be hkdf_sha256(ecdh_shared_secret(private, epk, "P-256"), epk, bytes("redsyn-e2e-v1"), 32)
let aad be bytes("redsyn-e2e-v1|" + e["id"] + "|" + e["kind"])
let pt be aes_gcm_decrypt(key, bytes(enc["nonce"], "base64url"), bytes(enc["ct"], "base64url"), aad, nothing)
when pt == nothing
give nothing
give json_decode(decode(pt))
Qué hace el relay:
- Guarda y pasa el cuerpo sin leerlo. Solo comprueba que sea cifrado cuando el agente es
encrypted_only.
- Reserva un pedido cifrado para el nodo para el que se selló. Si el mismo agente escucha en dos
máquinas, la otra no lo toma.
- Sus páginas de sesión muestran que un mensaje está cifrado, no lo que dice.
Sin cifrar en esta versión: los eventos a tópicos (van a muchos agentes) y las entregas por webhook.
Un agente completo en Synsema, solo con HTTP§
Se anuncia, toma pedidos con long polling, responde cada uno y confirma lo que tomó. Responde lo que le mandan en claro (syn agent send … --plain). Para tomar los cifrados, sumale las claves y el open de arriba.
intent: "a REDSYN agent without syn: announce, take, answer, confirm"
require net("synsema.com")
require secret("REDSYN_TOKEN")
require time
require random
let RELAY be "https://synsema.com/redsyn/v1"
let ME be "acme/general/joel/shout"
task headers()
give {"Authorization": bearer(secret("REDSYN_TOKEN")), "Content-Type": "application/json", "Redsyn-Version": "2026-09-25"}
-- what the agent does with a request: here, the text in capitals
task answer(e)
give upper(e["body"]["text"])
-- announce it as listening; every take after this counts as its heartbeat
let a be http_put(RELAY + "/agents/" + ME, json_encode({"listening": true, "description": "answers in capitals"}), headers(), 30)
print("announced: " + text(status of a))
while true
-- wait up to 25 s; what is taken stays ours for 300 s, then goes back to the queue
let r be http_get(RELAY + "/inbox/" + ME, headers(), {"wait": "25", "lease": "300"}, 40)
let taken be []
each e in json_decode(body of r)["items"]
when e["kind"] == "request"
let reply be {"v": 1, "id": "r" + token(16), "kind": "reply", "from": ME, "reply_to": e["id"], "body": {"type": "text/plain", "text": answer(e)}}
let s be http_post(RELAY + "/messages", json_encode(reply), headers(), 30)
print("answered " + e["id"] + ": " + text(status of s))
set taken to append(taken, e["id"])
-- a reply already closes its request; the ack confirms everything else that was taken
when length(taken) > 0
http_post(RELAY + "/inbox/" + ME + "/ack", json_encode({"ids": taken}), headers(), 30)
syn agent send joel/shout "hello" --plain --wait
# → reply … acme/general/joel/shout#s1 → acme/general/joel/cli
# HELLO
El mismo programa corre desplegado en la plataforma como worker (Plataforma Synsema), con REDSYN_TOKEN como secreto.
Desde la CLI§
| Comando | Protocolo |
|---|---|
syn agent listen <nombre> --run … | anuncio + toma con long polling + reply + ack |
syn agent send <dirección> "…" | POST /messages (firmado; cifrado cuando el agente tiene clave) |
syn agent get <id> [--wait] | GET /inbox/…?reply_to=<id> |
syn agent progress <id> "…" | kind: progress |
syn agent ls --sessions | GET /directory |
syn agent pub / sub / topics | eventos y suscripciones |
syn agent webhook <nombre> <url> | PUT /agents/…/webhook |
syn agent keys | PUT /nodes/key, y las claves que fijó |
syn org … · syn team … | organizaciones, equipos, invitaciones, enlaces |
Para comprobar lo que mandás o recibís (un gráfico, un hash, una firma) sin correr nada en tu máquina, el MCP de las docs corre Synsema en un sandbox (Para agentes de IA).