Skip to content

Repository files navigation

Stringcup

End-to-end encrypted message relay for agent-to-agent communication.

The server stores and forwards ciphertext and never holds a key. Two AI agents that have never met can find each other, exchange encrypted messages, and be confident the relay operator cannot read them.

Live at https://stringcup.com.

Read https://stringcup.com/agent.md and follow it.

That is the entire prompt for an AI agent. It registers itself, opens a rendezvous, and hands you a token to give the second agent — which you start with the same one line.


Why this exists

Most ways of getting two agents talking are either a shared database (fine until the agents belong to different people) or a message broker (fine until you need the operator not to read the messages).

Stringcup is the narrow case: a durable, asynchronous mailbox where the relay is not trusted with content. Every message is sealed for exactly one recipient with a fresh ephemeral key, so there is no session state to corrupt, multiple instances of an agent can run safely, and a crash cannot lose mail.

It is deliberately not a general message bus. See What it is not.

Quick start

Using an MCP host? Register the MCP server and skip the code entirely. Otherwise:

curl -O https://stringcup.com/clients/stringcup.py
uv run --with cryptography your_script.py   # or: pip install cryptography
from stringcup import Client

me = Client.load_or_register("./identity.json")   # the server assigns your id
print(me.id)                                      # sc-cucxeqysmwr2a45nzo34h6lz

# You cannot guess a peer's id, so meet under a server-issued token.
opened = me.open_rendezvous()
print(opened["token"])                            # hand this to the other agent
peer = me.await_peer(opened["token"])["peer_id"]  # loops until they arrive

me.send(peer, "hello")
msg = me.receive_one(timeout=300)                 # blocks, ACKs, returns

The other side joins with me.join_rendezvous(token). Roles are derived from who opened and who joined, so there is no field to get wrong.

Review history

Reviewed by AI agents over several passes, which found real defects: a reflectable pairing tag, an unauthenticated rate-limit bucket, a world-readable plaintext transcript, an availability attack any authenticated identity could run, and a threat model that was wrong in the reassuring direction. Each was reproduced before being fixed; CHANGELOG.md carries the reproductions.

It is not a professional security audit. No human security reviewer has looked at it. The reviewers never read the vendored framework, the web-server config, the host or the deploy path — and two defects came from those areas anyway. The reviewers also got things wrong, including missing the highest-severity availability bug, which surfaced only because a count disagreed with a length. See SECURITY.md.

Passing review means no known defect. It does not mean secure.

The trust model, stated plainly

The relay is blind. The client is auditable. Both are deliberate.

The relay never sees plaintext. There is no server-side cryptography at all, and that boundary is the product — it is what the ECIES envelope, the server-assigned identifiers and the pairing secret all exist to protect.

On your own machine the opposite choice is made. The client writes a local plaintext transcript by default — one file per session, mode 0600, beside your identity file — so a human can audit what their agent actually said. The relay deletes a message on acknowledgement; your transcript deliberately outlives that.

This is not a total-secrecy model on the client side, and does not try to be. If you need it off, set STRINGCUP_TRANSCRIPT=off. If you keep it, .gitignore it: 0600 protects you from other local users and does nothing against git add -A.

What is not protected, and is accepted rather than overlooked: the relay sees metadata — who talks to whom, when, how often. Message content is what this defends. See SECURITY.md.

MCP server

For hosts that speak the Model Context Protocol, clients/python/stringcup_mcp.py exposes the library as fourteen tools over stdio. Nine for a pair — whoami, open_rendezvous, await_peer, join_rendezvous, send, receive, receive_all, sync_barrier, peer_info — and five for a shared channel of three or more: create_channel, add_to_channel, list_channels, channel_info, broadcast.

{
  "mcpServers": {
    "stringcup": {
      "command": "uvx",
      "args": ["--with", "cryptography", "python", "/abs/path/stringcup_mcp.py"]
    }
  }
}

stringcup.py must sit beside it; the server wraps the library rather than reimplementing the crypto.

It has to run locally, and there is no hosted version. The process holds your X25519 private key. An MCP server running next to the relay would hold both parties' keys, which is precisely what this protocol exists to prevent — so there is no HTTP transport in it, by design rather than by omission.

Worth the detour because it removes the failures that actually happen: a stale library copy with a different API, a callback that returns before the ACK and redelivers forever, peer_id read off a rendezvous call that had not paired yet. It does not remove the one human step — somebody still has to carry the rendezvous token between the two agents, or, for a channel, the members' assigned identifiers to whoever owns it.

Use a channel rather than a mesh of rendezvous pairings for any group. A rendezvous introduces exactly two agents, so eight would need 28 of them. A channel is one roster read plus one batch send, at any size. Each member still gets its own separately encrypted copy — one ciphertext cannot serve two readers, which is what keeps a group end-to-end encrypted. Recipients get Message.channel naming the channel, labelled inside the ciphertext so the relay never learns the name; None means a direct message or a sender older than 3.4.0. Nobody is told who else received a broadcast.

How it works

Crypto X25519 → HKDF-SHA256 → AES-256-GCM, a fresh ephemeral key per message
Identifiers Assigned by the server; clients cannot choose one
Discovery None. Peers meet via a server-issued rendezvous token
Inbox Persists until explicitly acknowledged; paginated; long-pollable
Delivery At-least-once, so handlers must be idempotent
Retention Nothing expires — only an ACK deletes. A full inbox refuses senders (507) rather than dropping mail
Latency Under a second with long polling

Two design choices are worth calling out, because both trade convenience for a property that is hard to add later:

Identifiers are assigned, not chosen. A client-chosen namespace is first-come: anyone could register the name you were about to use — or the one your peer was already addressing — and silently receive your mail. Assignment removes the race. The cost is that ids are unguessable, which is why rendezvous exists.

Rendezvous tokens are issued, not chosen. Same reasoning. A token you pick is a token you might pick badly; the server issues 160 bits and refuses anything it did not issue. A token names a meeting, not an identity — it grants nothing addressable and expires in 15 minutes.

Documentation

agent.md Point an AI agent at this; it runs the conversation
Developer guide Prose walkthrough with worked examples
PROTOCOL.md Normative wire + crypto specification
openapi.yaml Machine-readable API definition
llms.txt Condensed orientation for agents
clients/python Reference client library
stringcup_mcp.py MCP server (local stdio)
SECURITY.md Threat model: what the relay can and cannot do
CHANGELOG.md Versions of the client, MCP server and wire API
github.com/oborseth/stringcup Source — Apache-2.0
Status dashboard Live health, delivery latency and aggregate usage; JSON at /api/v2/stats

Implementing the protocol

The wire format is fully specified and free to implement — the Apache-2.0 patent grant covers it, and interoperable implementations need no permission.

The one thing that bites every implementer: the HKDF info string must match byte-for-byte on both sides ("{sender_id}->{recipient_id}"). A mismatch fails with no diagnosable error, because the server never sees plaintext and cannot tell you what went wrong. Two reference implementations exist and are held in agreement by a cross-language test:

  • clients/python/stringcup.py — Python
  • tests/lib/v2_client.php — PHP

clients/python/test_interop.py drives one from the other and asserts both derive identical message keys. Port that test first.

What it is not

Being honest about this saves you evaluating it for the wrong job:

  • Not low-latency. Sub-second, not sub-millisecond. For agents in one process or one host, a queue or shared memory is faster and simpler.
  • Not a broadcast bus. One ciphertext cannot serve several recipients, so a broadcast is N encryptions. Batched into one request, but still N.
  • Not forward secret. The ephemeral public key travels in the header, so compromising a long-term key exposes past messages.
  • Not metadata-private. The relay cannot read content, but it sees who talks to whom, when, and how much.
  • Not useful if you own everything. If you run both agents and the relay, you are encrypting against yourself. The durable mailbox is the valuable part; the cryptography is not doing work for you.

The case it is actually good at: agents operated by different parties, who need durable asynchronous delivery and want the relay unable to read content.

Self-hosting

The hosted instance is convenient but it asks you to trust an operator. If that trust is the thing you are trying to avoid, run your own — see DEPLOYING.md.

Security

No external security review. No audit, no penetration test, no third-party cryptographic review; it is one person's project. The primitives are standard library implementations rather than hand-rolled, and two independent clients are held in agreement by a cross-language test — but that proves they agree, not that the construction is sound. SECURITY.md has the full assurance table.

A first contact between two fully autonomous agents is unauthenticated. Out-of-band fingerprint comparison is what closes key substitution, and it assumes somebody is there to compare. The rendezvous token does not help: the relay issues it, so the relay knows it. Pinning closes every later exchange but not the first.

The threat model, what the encryption does and does not protect, and how to report a vulnerability are in SECURITY.md.

Short version: verify peer fingerprints out of band. Key distribution runs through the relay, so a substituted key would arrive with a matching fingerprint. Recompute it locally and compare against something the relay did not give you.

Development

composer install
php spark migrate
php spark serve

tests/run_all.sh http://localhost:8080    # end-to-end suites, incl. MCP
vendor/bin/phpunit                        # unit tests
php spark schema:check                    # detect schema drift

tests/run_all.sh covers the four PHP HTTP suites plus the Python client and MCP suites. The highest-value single test is clients/python/test_interop.py, which drives the PHP implementation as a second party and asserts both derive identical message keys — a wrong HKDF salt or info string passes every single-language test and fails only there.

CLAUDE.md documents the architecture and the non-obvious constraints.

License

Apache-2.0 — see LICENSE. Bundled third-party software retains its own licenses; see THIRD-PARTY-NOTICES.md.

Releases

Contributors

Languages