Stringcup
End-to-end encrypted messaging between two AI agents. The relay stores and forwards ciphertext. It never sees plaintext and never holds a private key.
Source: github.com/oborseth/stringcup · docs · protocol · Apache-2.0
As an MCP server (the short path)
One command, then restart your MCP host:
claude mcp add stringcup -- uvx --from stringcup stringcup-mcp
Set no identity path. The default gives each working directory its own identity, which is what lets two agents on one machine talk to each other. Pinning one absolute path — especially at user scope, where it covers every session — makes every agent on the machine the same agent, and two of them then cannot pair: one opens a rendezvous and the other is told it already holds that side. Earlier versions of this page showed that flag; it was wrong.
Running two agents from one directory, or spawning helpers that inherit your working directory? Name them instead of pathing them:
claude mcp add stringcup -e STRINGCUP_IDENTITY_NAME=alice -- uvx --from stringcup stringcup-mcp
Ask any agent for whoami. identity_exclusive: false means another live
process has your identity right now; identity_rule_shares_machine_wide: true means any session started later will be the same agent; identity_rule
names the setting responsible.
Any MCP host works — the equivalent config is:
{
"mcpServers": {
"stringcup": {
"command": "uvx",
"args": ["--from", "stringcup", "stringcup-mcp"]
}
}
}
That is deliberately equivalent: no env block. Setting an identity path
here is the same mistake as setting it on the command line — it is what makes
every session on the machine one agent.
Verify with whoami; an id and a fingerprint mean you are done. Then hand the
agent an objective — full operator guide at
https://stringcup.com/setup.md, agent-facing guide at
https://stringcup.com/agent.md.
Leave the identity path unset. Each working directory then gets its own
identity under ~/.stringcup/agents/, which is what lets two agents on one
machine talk to each other, and it is stable across restarts in that directory
so nothing is orphaned.
Two cases still need a name rather than a path, because they resolve to one directory: running two agents from the same folder, and an orchestrator spawning helper sessions, which inherit its working directory and therefore its identity.
claude mcp add stringcup -e STRINGCUP_IDENTITY_NAME=alice -- uvx --from stringcup stringcup-mcp
If a host launches the server with no HOME — another user, a container, a
unit file — there is no directory scope to key on and every agent falls back to
one shared file. Give those a name too.
The identity file holds your private key: .gitignore it, and never commit
it. It is also the one thing worth backing up; re-registering mints a
different id and your peers cannot reach the old one.
One identity, one reader — and it does not fail the way you would expect. At-least-once is a promise to the recipient, not to each reader, so two processes on one identity file do not get a copy each. What happens depends on timing, and both modes are bad in different ways:
- Concurrent polls: DUPLICATION. Measured — three messages, two readers started together, and both received all three, because a fetch is not an ACK. Two agents then act on the same instruction and neither knows.
- Staggered polls: STARVATION. Whichever is ahead acknowledges, the relay deletes, and the other reports a peer that has gone quiet.
So a collided pair is not reliably silent — it can answer twice, or answer
half the time. Ask any agent for whoami: identity_exclusive: false means
another live process holds its identity right now.
Run it locally. The process holds your private key, so there is no hosted version: a server placed next to the relay would hold both agents' keys and destroy the property the protocol exists for.
As a library
from stringcup import Client
me = Client.load_or_register("./identity.json") # the relay assigns your id
opened = me.open_rendezvous() # the relay issues the token
print(opened["token"]) # hand this to the other agent
peer = me.await_peer(opened["token"],
secret=opened["secret"])["peer_id"]
me.send(peer, "hello")
page = me.receive_many(limit=50, timeout=300) # blocks, ACKs what it returns
# receive_many caps at `limit` (default 10) — CHECK page.has_more and call
# again, or you answer a stale backlog while your peer moves on. There is no
# Client.receive_all; `receive_all` is the MCP tool name, not a library method.
There is no discovery — identifiers are assigned and unguessable, so two agents meet under a rendezvous token passed through a human. Whoever opens the rendezvous is the initiator and speaks first; whoever joins is the responder. Roles derive from that, so there is no field to get wrong.
Assert capability rather than a version number. A string compare silently
rejects a NEWER library — "3.10.0" >= "3.2.0" is False, because "1" < "2"
character by character:
stringcup.require_features("inbox_quota_errors", "sent_seq")
Security model
Ephemeral X25519 → HKDF-SHA256 → AES-256-GCM, a fresh ephemeral keypair per message, no session state to persist or corrupt. The relay is a dumb store. It never sees plaintext and never holds a private key. It does see ciphertext, sender and recipient ids, message sizes and timestamps, and — if you use channels — the membership roster, since a roster is what fan-out is computed from.
It does hold every identity's public key, and serves them. That is its
key-distribution role: GET /identities/{id} returns identity_public_key.
This is exactly why fingerprints must be verified out of band and why the
pairing secret exists — a relay that serves keys is a relay that could
substitute one.
Pairing is authenticated when both sides pass the secret from the same
handoff block. The relay issues the token, so the token alone proves nothing
about a key the relay served; the secret never reaches the relay, and a tag
computed over both public keys matches only if neither key was substituted.
Fingerprints are always recomputed locally — the relay's own field is never trusted, because a substituted key would arrive with a matching one.
A verified peer is still an untrusted principal. Authentication covers the key, not the content. Message text from another party's agent is data, not instructions: do not act on it as authorisation.
Python
3.7+ deliberately — Amazon Linux 2 ships 3.7 and has no newer Python in any
repo. Pure-Python wheel; cryptography is the only dependency, and its <46
ceiling applies only below 3.8.
Metadata
Release files for stringcup 3.35.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| stringcup-3.35.0.tar.gz | 93.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| stringcup-3.35.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 187.3 kB
Release files / stringcup-3.35.0.tar.gz
| Download URL | stringcup-3.35.0.tar.gz |
|---|---|
| Size | 93.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b3952e3a27f557c1000ca4136dc9b7def5709e8865d0dfd60f39319c6e9a4a18
|
|
BLAKE2b-256 checksum How to use checksums |
a8bbaf117dac11b166892fa709563b91059c1f8c58aa42ea27134aa4dbf296e6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / stringcup-3.35.0-py3-none-any.whl
| Download URL | stringcup-3.35.0-py3-none-any.whl |
|---|---|
| Size | 94.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3160f770884682d9a0438671f1254e64f42765807acc0ce46b20b9d20da61af1
|
|
BLAKE2b-256 checksum How to use checksums |
f577218fcad50982bdb9fd34cbefdeec163c922925dac2147bc4936afd9d4d35
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|