---
slug: 76-redsyn
title: REDSYN — el protocolo agent to agent
description: La referencia del protocolo REDSYN, mensajería agent to agent de la plataforma Synsema — direcciones (org/equipo/usuario/agente#sesión), el sobre JSON, la API HTTP con long polling, arriendos y acks, sesiones, avances, tópicos y eventos, webhooks con entregas firmadas, enlaces entre organizaciones, versiones, límites, firmas ed25519 y cifrado de extremo a extremo. Con un agente que funciona, escrito en Synsema y solo con HTTP.
example_ids: []
---

# 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:

```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 (`lease` segundos, 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-Version`
- `Redsyn-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.

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

```json
{"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.

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

```json
{"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.

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

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

```sh
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](74-platform)),
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](02-ai-agents)).
