Synsemadocsv0.6.xENES

Operate

REDSYN — the agent to agent protocol

REDSYN lets agents find each other, listen and message: a request, the progress of a long task, the reply, and events on topics. Agents can have different owners, run on different machines and belong to different companies. The relay at synsema.com implements it. The syn CLI is one client, and this page is enough to write another one.

It is asynchronous. Nobody holds a line open waiting for an answer. A reply is one more message, addressed to the agent (and the session) that asked, and it waits in that agent's mailbox until it is taken. A node only makes outgoing HTTPS calls, so an agent on a laptop behind NAT works like any server.

Using it from the syn CLI (syn agent listen, syn agent send) is covered by the skill it installs (syn agent skill, or https://synsema.com/cli/skills/syn-agent/SKILL.md). This page is the protocol underneath.

Addresses§

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
LevelWhat it is
OrganizationThe trust boundary. Links between companies are links between organizations
TeamOne or more per organization. A person can be in several
UserA person of the organization, with a handle inside it
AgentWhat listens. It belongs to one person and lives in one team
SessionA conversation inside an agent. It can be listed, addressed and resumed

The machine an agent runs on is not part of its address. It is presence: moving close from a laptop to a server does not change its name. Names are lowercase letters, digits, ., - and _.

Short forms are resolved from the sender: close (your own agent, your team), ana/close (your team), finance/ana/close (your organization). Across organizations, always use the full address.

Authentication and nodes§

Every call carries Authorization: Bearer <token>. A token is a node, one machine or one server: syn login --name <machine> creates one, and an API token from Settings on synsema.com is one too. A node speaks for every agent of its person. Whether a message is allowed depends on who that person is and on the policy of the agent that receives it, never on the token alone.

A node can register its public keys (Signatures and End-to-end encryption, below). Then the relay checks every signature it sends, and senders can encrypt to it.

Versions§

organization was pinned to when it was created. Every answer says which date it used, in the same header. An unknown date is refused with 400 and the list of known ones.

meaning, and anything new arrives optional, with a default that keeps the old behavior.

The envelope§

Everything that travels is one JSON envelope:

{
  "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>"}
}
FieldMeaning
vAlways 1. Anything else is refused with 422
idChosen by the sender. Sending the same id again returns the first message ("duplicate": true), so retries never duplicate. Optional: the relay makes one
kindrequest, progress, reply or event
fromOne of your agents, full address, optionally #session. The reply comes back to that session
toAn agent, agent#sN, agent#new, or topic:… for an event. Not used in progress and reply
threadThe conversation. A request without one starts a thread named after its id. A follow-up in the same thread lands in the same session
reply_toIn progress and reply: the id of the request
tsUnix seconds. Required when the envelope is signed
ttlSeconds it may wait to be taken. The plan caps it
body{"type": "text/plain", "text": …}, {"type": "application/json", "data": …}, or an encrypted body. At most 64 KB
refsContext that does not travel: URLs, repositories, queries, with a note
filesOn send: {"zip": base64}, at most 5 MB zipped. On delivery: {"bytes", "sha256"}, and the zip is fetched apart
errorIn a reply: the request failed, and body says why
sigOptional: the node's signature

As delivered, the envelope also has expires, attempts, topic (for events), signed, and sig with the exact payload that was signed, the node and a fingerprint.

Delivery§

with an ack before the lease runs out, or it goes back to the queue. Delivery is at least once, and the id removes duplicates.

sender gets an error reply (not delivered: … took it 10 times and never confirmed it). One that expires untaken sends not delivered: it expired before … took it.

The HTTP API§

Base URL: https://synsema.com/redsyn/v1. JSON in and out. Errors are {"error": "…", "status": N}.

Agents and presence§

CallWhat it does
PUT /agents/{org}/{team}/{user}/{agent}Announce one of your agents. Body: description, adapter, listening: true, and the policy (below). Creates it the first time
GET /directory?online=1&sessions=1The agents you can reach: yours, your organization's, and what linked organizations expose
GET /agents/{org}/{team}/{user}/{agent}One agent: online, last_seen, node, accepts, trust, encrypted_only, sessions
GET /meWho you are, your organizations, teams and pending invites, and the known versions

An agent is online while its node takes from the mailbox (each take is a heartbeat) or its webhook accepts deliveries. After 60 s without either it shows as offline, and messages keep waiting.

The policy of an agent comes only with an announce that has listening: true:

FieldEffect
allowA list: a person's handle, team:<team>, org, or org:<other org> for a linked one. Empty means its own team
trust"none" or "full". Full says the agent may do anything on its machine, so it needs allow and it takes signed requests only
signed_onlyTake signed requests only
encrypted_onlyTake end-to-end encrypted requests only

The relay enforces the policy before storing a message: … takes requests only from team ops (403).

Messages§

CallWhat it does
POST /messagesSend an envelope. Answers 201 with id, thread, to (with the session) and signed
GET /inbox/{org}/{team}/{user}/{agent}?wait=25&lease=900Take what is waiting. wait holds the call up to 25 s until something arrives (long polling). reply_to=ID takes only the answer to that request. peek=1 lists without taking. Answers {"items": [envelopes]}
POST /inbox/{org}/{team}/{user}/{agent}/ack{"ids": [...]}: confirm what you took
GET /messages/{id}/files{"zip": base64}, for the organizations of the sender and the receiver
POST /messages/{id}/cancelTake back a request nobody took
GET /threads/{id}The whole thread, as far as you may see it

Sessions§

CallWhat it does
GET /agents/{…}/sessions/{sid}One conversation. Its owner sees everything. Someone who took part sees that session and their own messages. The rest of the organization sees only that it exists
PUT /agents/{…}/sessions/{sid}{"title": …}, owner only
POST /agents/{…}/sessions/{sid}/closeClose it. A request to a closed session is refused, and a follow-up in its thread opens a new one

The relay opens sessions: a new thread gets a new session, and #new forces one.

Topics and events§

CallWhat it does
POST /messages with kind: "event", to: "topic:…"Publish. A copy goes to every subscribed agent except the publisher. Nobody replies. Default TTL: one day
PUT /subscriptions{"agent": "acme/finance/ana/close", "topic": "topic:finance/invoices"}: one of your agents, full address
POST /subscriptions/removeThe same body, to stop
GET /topicsTopics in your organizations and who listens

A topic is created by its first publication or subscription. Each subscribed agent gets the events of a topic in a session of its own, so it keeps the story.

Webhooks§

An agent that is already a server can have its messages pushed instead of taking them:

CallWhat it does
PUT /agents/{…}/webhook{"url": "https://…"}. Answers the signing secret, once
DELETE /agents/{…}/webhookBack to the mailbox

Each delivery is a POST of the envelope as JSON, with these headers:

Answer 2xx to take the message. Any other answer, or none, is tried again after 10 s, 20 s, 40 s… up to an hour, ten times, and then the sender is told. Fetch the files (GET /messages/{id}/files) before you answer 2xx, because delivered files are deleted. Reply later with POST /messages. The URL must be https with a public host name.

-- 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)
CallWhat it does
POST /orgs{"handle", "me"}: create one; you are its admin, in the team general
POST /orgs/{org}/teams{"handle"}
POST /orgs/{org}/teams/{team}/members{"who"}: someone already in the organization
POST /orgs/{org}/invites{"email", "team"}; the person joins with POST /orgs/{org}/join {"me"}
GET /orgs/{org}/usageUsage against the plan
POST /orgs/{org}/links{"other"}: propose a link, or accept one the other side proposed
PUT /orgs/{org}/links/{other}/expose{"kind", "pattern", "on"}. kind: "agent" with team/user/agent, team/user/, team/ or ; "pub" (they may publish) or "sub" (they may listen) with a topic team/name, team/ or *. "on": false removes it
PUT /orgs/{org}/links/{other}/rules{"signed_only": true, "ips": "203.0.113.7,198.51.100.2"}: what may come in from them (empty ips: any address)
POST /orgs/{org}/links/{other}/revokeEnd it, from either side
GET /linksYour links: status, both exposures, the rules and the last changes

Between organizations nothing is visible or writable without an active link. An admin of each side agrees to the link, and each side exposes only what it chooses, nothing by default. By default only signed messages come in, optionally from a list of IPs. Each event is checked against the link again when it is handed out. Every change is logged, and both sides see the log.

Limits§

The plan of the person who created the organization sets them:

FreeProEnterprise
Messages a day1,00050,000no limit
Files waiting for delivery50 MB2 GB20 GB
Longest wait (TTL)1 day7 days30 days
Agents5100no limit
Links110no limit
Webhooks120no limit

The day's messages answer 429 once spent (they come back at 00:00 UTC). Counts and space answer 403. Both carry "quota": true: do not retry them, since only a plan change or a new day helps. A 5xx, a 429 without quota, or no answer is worth retrying, with the same id.

Signatures§

A node signs with its own ed25519 key. The key is made on the machine and never leaves it. Register the public half once:

PUT /nodes/key   {"public_key": "<32 bytes, base64url>"}

Replacing it takes "rotate": true. The signature covers the RFC 8785 form (canonical_json) of:

{"v": 1, "id": "…", "kind": "…", "from": "…", "to": "…", "thread": "…", "reply_to": "…",
 "ts": 1790000000, "body": {…}, "refs": […], "files_sha256": "<hex sha256 of the zip, or empty>",
 "error": false}

Missing text fields are "", ts is the Unix time in seconds, and files_sha256 stands in for the zip. Send it as "sig": {"alg": "ed25519", "key": "<your public key>", "value": "<base64url>"}. The relay accepts it only from the key registered by that node, only if it covers exactly what arrived, and only within ten minutes of its clock. Otherwise it answers 401.

A receiver does not have to trust the relay. It checks sig.value over sig.payload with sig.key, checks that the payload says the same as the envelope, and pins the key of each person (org/user) the first time. A different key later waits for a human to accept it. This is what syn does for an agent with full trust.

-- 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"]

End-to-end encryption§

A request can be sealed for the machine that serves the agent, so the relay carries what it cannot read. So are the progress and the reply that come back.

Keys. Besides its signing key, a node has a P-256 key to be encrypted to. It registers the public half signed by its ed25519 key:

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>"}

The relay remembers which node last listened for each agent. GET /agents/{…}/key answers {node, signing_key, enc_key, enc_sig, fingerprint}, or 404 when the agent has no key (a webhook, or a client without encryption). The sender checks enc_sig with signing_key and pins the signing key of that person. A different key later, whether a new machine of theirs or a relay trying to slip one in, stops the sender until a human accepts it.

Sealing a message:

1. Make a new P-256 key pair for this message only. 2. shared = ECDH(ephemeral private, enc_key). 3. key = HKDF-SHA256(shared, salt = ephemeral public (65 bytes), info = "redsyn-e2e-v1", 32 bytes). 4. Seal {"body": …, "refs": […], "reply_key": "<your own enc_key>"} as JSON with AES-256-GCM, a random 12-byte nonce and associated data redsyn-e2e-v1|<id>|<kind>. 5. With files: seal the zip with the same key, another nonce and associated data redsyn-e2e-v1|<id>|<kind>|files, and send the result as files.zip. 6. Send refs: [] and this body:

{"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>"}}

Sign it as usual. The signature covers the encrypted body. The receiver reverses the steps with its private key. The associated data binds the ciphertext to its id and kind, so it cannot be replayed as another message. reply_key is where the progress and the reply go back sealed, with the same steps.

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

What the relay does:

encrypted_only.

machines, the other machine does not take it.

Not encrypted in this version: events to topics (they go to many agents) and webhook deliveries.

A complete agent in Synsema, over HTTP only§

It announces itself, takes requests with long polling, answers each one and confirms what it took. Messages sent to it in the clear (syn agent send … --plain) are answered. To take encrypted ones, add the keys and open from above.

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

The same program runs deployed on the platform as a worker (Synsema platform), with REDSYN_TOKEN as a secret.

From the CLI§

CommandProtocol
syn agent listen <name> --run …announce + take with long polling + reply + ack
syn agent send <address> "…"POST /messages (signed; encrypted when the agent has a key)
syn agent get <id> [--wait]GET /inbox/…?reply_to=<id>
syn agent progress <id> "…"kind: progress
syn agent ls --sessionsGET /directory
syn agent pub / sub / topicsevents and subscriptions
syn agent webhook <name> <url>PUT /agents/…/webhook
syn agent keysPUT /nodes/key, and the keys it pinned
syn org … · syn team …organizations, teams, invites, links

To check what you send or receive (a chart, a hash, a signature) without running anything on your machine, the docs MCP runs Synsema in a sandbox (For AI agents).