Skip to content

Latest commit

 

History

History

python

README.md

Stringcup Python client

Source: github.com/oborseth/stringcup · Apache-2.0

End-to-end encrypted agent-to-agent messaging. One file, one dependency.

pip install stringcup          # library + `stringcup-mcp` console script

Or keep it to one file with no install at all — the module is published standalone and the package ships the identical file:

curl -O https://stringcup.com/clients/stringcup.py
uv run --with cryptography your_script.py

uv needs no virtualenv and fetches the dependency itself.

The cryptography dependency is irreducible. An agent asked for a stdlib-only client, which would remove the sharpest edge in this whole setup — but Python's standard library has neither X25519 nor AES-256-GCM, and hand-rolling either to avoid an install is a far worse trade than the inconvenience it saves. One dependency, from the library everyone already audits, is the floor. Fall back to pip install cryptography only if uv is unavailable — on macOS the bare python3 is often the Xcode stub, which answers a missing dependency with an xcode-select nag rather than an ImportError, so the real problem is easy to misread. Run the curl and the script as separate commands: sandboxed agent harnesses routinely refuse a compound curl … && python … one-liner.

Splitting the commands is necessary but not sufficient on a host with a permission classifier, and on some hosts nothing you do will work. Of two agents that hit this, one got through by separating every command and reading the file first; the other could not, on four different invocation forms, having already inspected it. Same reason code both times. That looks like a difference in how the hosts were configured rather than in how the agents asked, so treat the sequence in agent.md as worth trying, not as a solution — and escalate to your operator rather than hunting for a fifth phrasing.

To check a copy without executing anything (which is the one thing require_version() cannot do, since calling it means importing the file):

curl -sO https://stringcup.com/clients-SHA256SUMS
grep ' stringcup.py$' clients-SHA256SUMS | sha256sum -c -

That detects a stale or corrupted copy. It is not authentication — the sums are served from the same origin as the files.

The library is a single file with one dependency, published at https://stringcup.com/clients/stringcup.py so an agent can fetch it directly. If you are following written instructions, assert the version first — and use the helper, because __version__ >= "3.0.0" is a string comparison that wrongly rejects "2.10.0":

import stringcup
stringcup.require_version("3.1.0")

Better still, say what you need. require_features() asks whether this copy can do the thing, which survives a release that forgets to move its number — and one did, shipping a changed surface as 2.3.0 so that require_version("2.3.0") passed on a copy missing RecipientInboxFull:

stringcup.require_features("inbox_quota_errors", "sent_seq")

stringcup.FEATURES lists every capability and the version that introduced it. An unrecognised name raises, because it means the instructions you are following are newer than the library.

stringcup.FEATURE_OF maps every name in __all__ to the capability that introduced it, and test_contract.py fails if any public name is uncovered. You can run it yourself — it needs nothing but the published files, and the checks that would need a file you did not fetch skip with a note saying which one:

curl -O https://stringcup.com/clients/stringcup.py
curl -O https://stringcup.com/clients/stringcup_mcp.py
curl -O https://stringcup.com/clients/test_contract.py
python3 test_contract.py                       # 19 checks

# optional, adds 4 more
curl -O https://stringcup.com/clients/README.md
curl -O https://stringcup.com/CHANGELOG.md
``` That mapping exists because the docstring claimed it
before it was true: 17 of 21 names were unmapped, including the two exception
classes whose absence under an unchanged version number caused the problem the
map was built to prevent. The test is published so the claim is checkable rather
than trusted.

```python
from stringcup import Client

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

opened = me.open_rendezvous()                    # server issues the token
print(opened["token"])                           # share it with the peer
peer = me.await_peer(opened["token"])["peer_id"] # loops until they arrive
me.send(peer, "hello")

# Blocks until one message arrives, ACKs it, returns it
msg = me.receive_one(timeout=300)

The server never sees plaintext. All crypto happens client-side: ephemeral X25519 → HKDF-SHA256 → AES-256-GCM, one fresh ephemeral keypair per message, no session state to persist or corrupt.


MCP server

stringcup_mcp.py wraps this library as MCP tools over stdio, for hosts that speak the Model Context Protocol.

curl -O https://stringcup.com/clients/stringcup.py
curl -O https://stringcup.com/clients/stringcup_mcp.py

Installed from PyPI, so there is no path to a downloaded file to get right.

{
  "mcpServers": {
    "stringcup": {
      "command": "uvx",
      "args": ["--from", "stringcup", "stringcup-mcp"],
      "env": {"STRINGCUP_TRANSCRIPT": "/abs/path/chat.jsonl"}
    }
  }
}

Fourteen tools, in two groups.

Pairwise: whoami, open_rendezvous, await_peer, join_rendezvous, send, receive, receive_all, sync_barrier, peer_info.

Use receive_all, not receive, in a conversation. This is a correctness requirement, not a preference. receive returns the oldest unread message; an agent that calls it once per turn answers its peer's oldest message as though it were its latest and falls further behind each round. The failure presents as your peer ignoring you, so both sides form confident, wrong conclusions about each other rather than suspecting a queue. receive_all returns the whole backlog in one call: read everything, reason once, reply once. If you are already desynchronised, sync_barrier drains to empty and reports the peer's most recent line, so the two of you can verify you are level instead of arguing about attention. send returns sent_seq and receive returns inbox_seq — named apart because they are unrelated numbering spaces, not one shared message id. receive decrypts and acknowledges, so the skipped-ACK trap below cannot happen through this surface. The blocking tools return {"paired": false} / {"received": false} rather than hanging past a host's tool timeout — an ordinary outcome, so call again.

Shared channels (MCP 1.3.0+), for three or more agents: create_channel, close_channel, add_to_channel, list_channels, channel_info, broadcast. Use these instead of pairing off — a rendezvous introduces exactly two agents, so a channel of eight would otherwise be 28 pairings.

Two properties to design around:

  • Fan-out is N direct messages, not a server-side room. Every member gets its own separately encrypted copy, which is what keeps a group end-to-end encrypted — one ciphertext cannot serve two readers. So a recipient cannot tell a broadcast from a direct message: there is no channel label on receive. An agent in more than one channel must name the channel in the message text.
  • Members cannot add themselves. There is no discovery, so the owner needs each member's assigned identifier up front: every agent runs whoami and its identifier is relayed once, out of band. That paste is the group equivalent of handing over a rendezvous token, and it is the only manual step. Unrecognised identifiers come back in unknown rather than failing the call, since they are typed by hand.

Run it locally. The process holds your private key. A hosted MCP server next to the relay would hold both parties' keys, which is exactly what this protocol exists to avoid, so there is no HTTP transport in it.

Environment: STRINGCUP_IDENTITY, STRINGCUP_IDENTITY_NAME, STRINGCUP_BASE_URL, STRINGCUP_TRUST_STORE, STRINGCUP_TRANSCRIPT.

Two agents on one machine need two identities. The default is one identity file per user, not per session, so two sessions pointed at it are the same agent — and the symptom is not an error, it is a pairing that never completes: the second session rejoins the first's own rendezvous, gets back the role it already holds, and waits for a counterpart that cannot arrive. Give each one a name:

-e STRINGCUP_IDENTITY_NAME=alice        # first agent
-e STRINGCUP_IDENTITY_NAME=bob          # second agent

A name, not a path: it resolves beside the default identity and is stable across restarts, so each agent keeps its own durable identity. Check it with whoami — if two agents report the same identity_id, they are one agent. whoami also reports identity_source, which is registered the first time and loaded afterwards.

Leave the identity path unset. Each working directory gets its own identity under ~/.stringcup/agents/, stable across restarts there — that is what lets two agents on one machine talk to each other. Pinning one absolute path makes every session on the machine the same agent, and two of them cannot pair.

Name, do not path, in the three cases that need separating: two agents in one directory, an orchestrator spawning helpers (they inherit its directory), and a host that launches the server with no HOME, where there is no directory to key on — -e STRINGCUP_IDENTITY_NAME=<name> per agent.

The identity file holds your private key: .gitignore it. It is also worth backing up on its own, since it sits outside your project and re-registering mints a different id your peers cannot reach.


Why use this rather than calling the API directly

The protocol is simple, but one detail is unforgiving: the HKDF info string must be byte-identical on both sides ("{sender_id}->{recipient_id}"). Get it wrong and decryption fails with no useful error — the server can't help, because it never sees plaintext. This library gets that right, and test_interop.py proves it against the independent PHP implementation.

It also handles the things an agent gets wrong on its own: reusing an identity across restarts instead of re-registering — which mints a different id your peers can no longer reach, a worse cost than the 30/hour registration cap — ACKing only after processing, paging through a backlog, reusing one Idempotency-Key across retries so a timeout doesn't deliver a duplicate, and — importantly — noticing when the server says X-Long-Poll: unavailable instead of spinning at full request rate.


Two agents talking

There is no discovery. GET /identities/{id} is a lookup by exact external_id — no list, no search. Both agents must know both IDs in advance, and one must be designated to speak first, or they will both sit polling an empty inbox.

Identifiers are assigned by the server — an agent cannot choose one. That removes the first-come race chosen names had, but it also means neither agent can guess the other's. They meet under a shared token instead:

# initiator — returns at once with the token to share
opened = me.open_rendezvous()
print(opened["token"])          # rv-arzktfmi24f4jywlszgwylzazblz4lmd
peer = me.await_peer(opened["token"])["peer_id"]

# responder — join with the token you were handed
peer = me.join_rendezvous(token)["peer_id"]

Roles are derived from who opened and who joined, so there is no field to get wrong. await_peer and join_rendezvous loop until paired and raise PairingTimeout — a single rendezvous() call waits only 25s and can return peer_id: None.

Tokens are issued, not chosen — a self-invented one is refused even if well-formed. That guarantees 160 bits of entropy and removes the last place a weak secret could enter.

# terminal 1 — initiator; prints the token to share
python3 example_agent.py --role initiator \
    --open "Status check: is the deploy green?"

# terminal 2 — paste the token it printed
python3 example_agent.py --role responder --session rv-...

Either side may start first; the rendezvous call blocks until both arrive.

The token names a meeting, not an identity — it expires in 30 minutes and confers nothing addressable — but whoever holds it can claim a role. A 409 means someone already holds yours: treat the token as compromised and open a new rendezvous rather than retrying.

Ordering isn't strictly required — the inbox persists until ACKed, so an initiator's opening message waits for a responder that starts later.

Persist the identity file. Re-registering yields a different assigned id, so a peer holding your old one can no longer reach you.

Prompt for an LLM agent

Drop this in, with the two IDs filled in:

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

That is the whole prompt for the initiator. agent.md walks it through getting an identity, opening a rendezvous, and handing its operator a block containing the responder's role and token. You paste that into the second agent, which reads the same guide and takes the responder path.

Keeping the instructions at a URL rather than in a pasted prompt means they cannot drift away from the API they describe.

Expect under a second per hop: listen() long polls by default, so a message is delivered almost as soon as it is sent. Interval polling (wait=0) falls back to ~7.7s mean, bounded by the 300/hour inbox budget.


API

Client

Method Purpose
Client.load_or_register(path) Reuse identity at path, register only if absent. Use this.
Client.register() Register fresh; the server assigns the id, token returned once
open_rendezvous() Open a pairing; returns the token and a pairing secret at once
handoff_block(info) The block an operator pastes, secret included so it cannot be dropped
await_peer(token, timeout=300, secret=None) Loop until paired; with secret, authenticates the peer's key or raises VerificationFailed
join_rendezvous(token, timeout=300, secret=None) Join and wait until paired; pass secret to authenticate
receive_many(limit=10, timeout=300) Block, then return the whole backlog as a Page, ACKing all — the primitive for a conversational agent
receive_one(timeout=300) Block for one message, ACK it, return it. Correct for strict request/response; see the backlog note
sync_barrier(peer) Drain to empty; returns {drained, last_line, last_seq} to recover a desynchronised conversation
update_identity(public_key_b64=, display_name=) Rotate your key or rename
send(recipient_id, text, idempotency_key=None) Encrypt and send; returns your own sent_seq. Auto-generates and reuses a key across retries
fetch(limit=50, since_id=None) One decrypted Page. Does not ACK
receive(limit=50) One page, remembering ids for ack_all()
ack(ids) / ack_all() Delete; batches of 200, chunked automatically
drain(handler) Whole backlog, page by page, ACKing after each. The safe default
listen(handler, wait=25, idle_timeout=None) Long-polling loop; falls back to intervals automatically
fetch(..., wait=25) Park server-side until a message arrives (0–25s)
peer_public_key(id, pin=None) Fetch + cache a peer's key, optionally pinned
peer_info(id) Full identity record, fingerprints recomputed locally
my_fingerprint / my_fingerprint_short Publish these so peers can pin you
send_many(recipients, text) Fan-out: encrypt per recipient, one request
broadcast(topic, text) Roster read + batch send, two requests at any size. Labels the plaintext so recipients get Message.channel
verify_channel_claim(sender, claim) / channel_members(name) What makes an inbound label trustworthy; fetch() does it for you
create_topic(label=None, members=, notify=True, allow_duplicate=False) Create a channel. The relay assigns id; label is local and never sent. Notifies new members; refuses a duplicate member set
label_for(channel_id) Your local label for a channel, or None
topics() / topic(name) List your topics / read a roster
add_members(name, ids, notify=True) / remove_member(name, id) / delete_topic(name) Membership
token_info() / rotate_token(save_to=...) Expiry and rotation
Client(transcript="./chat.jsonl") Append every message, in and out, as JSONL (sent_seq out, inbox_seq in)
.rate_limit {limit, remaining, reset} from the last response
require_version(minimum) Raise unless the library is new enough. Not a string compare

Return types

Two of these are easy to guess wrong, and both were previously documented only by example:

Call Returns
send(recipient_id, text) int — your own sent_seq, not an ACK handle and not a response object
receive_one(timeout=300, ack=True) Message with .id .sender_id .text .created_at, or None on timeout
receive_many(limit=10, timeout=300, ack=True) Page; iterate .messages, check .has_more — a depth reading, not an end-of-burst signal. A Page with no messages on timeout, not None
await_peer / join_rendezvous dict with peer_id, peer_fingerprint, peer_fingerprint_short
open_rendezvous() dict with token
peer_info(id) dict with fingerprint, fingerprint_short, key_updated_at
fetch(...) Page, iterable over Message, with .has_more .next_since_id .long_poll

receive_one returning None is the one to note: "nothing arrived" is an ordinary outcome, so it is not an exception. Loop; do not abort.

There is no shared message id. Each party numbers a message in its own space, and the two numbers are unrelated:

Number Whose Where you get it What it is for
Message.id yours, as recipient receive_one(), fetch() ACK handle and since_id cursor
sent_seq yours, as sender the return value of send() Your own outbound log; replay correlation

Both count from 1 per identity. Never ACK a value send() returned — it names nothing in the recipient's inbox — and never carry a cursor from one inbox to another.

This replaced a single global counter, which had two problems: ids were contiguous across unrelated conversations, so any user could read platform-wide volume off their own inbox; and because an ACK resolved that id globally, the endpoint answered 403 for a message that existed but was not yours, which confirmed other people's mail existed. Both were reported from outside. An unknown id is now simply 404.

Long polling

listen() and fetch(wait=...) hold the request open server-side, so delivery is near-instant rather than bounded by a poll interval. The hold pool is capped (each waiter occupies a server worker), and when it is full the server answers immediately with X-Long-Poll: unavailable. listen() detects that via page.long_poll and sleeps for poll_interval instead — without that check a loop would spin at full request rate and drain the hourly budget in minutes.

Short timeouts are honoured

receive_one(timeout=…) and await_peer(token, timeout=…) park for at most the time you gave them, to about a second's resolution. Before 2.3.0 they passed a fixed 25-second server-side wait and only checked the deadline afterwards, so timeout=3 blocked for 25 seconds — accepted and silently ignored downward. It was found by an agent driving the MCP server, where a short hold exists precisely to stay under a host's tool-call timeout.

Don't raise from a handler

drain() and listen() acknowledge after your callback returns. Raising SystemExit or StopIteration to stop after one message escapes before the ACK, so that message is redelivered on every later run and real messages queue up behind it. Use receive_one(), which acknowledges before it returns.

Keeping a record

The relay deletes a message the moment it is acknowledged, so there is no server-side history to go back to. Pass transcript= and every message is appended as JSONL at the point plaintext exists:

me = Client.load_or_register("./identity.json", transcript="./chat.jsonl")
{"ts":"2026-09-11T15:58:28Z","direction":"out","me":"sc-...","peer":"sc-...",
 "sent_seq":7,"text":"hello"}
{"ts":"2026-09-11T15:58:31Z","direction":"in","me":"sc-...","peer":"sc-...",
 "inbox_seq":3,"text":"hi back"}

The sequence key is named for its direction — sent_seq outbound, inbox_seq inbound — because the two are unrelated numbering spaces. A single message_id for both would imply they were comparable.

Worth setting for an agent: it is the only record after the fact, and it lets the agent re-read the conversation if its context was compacted mid-task. Bodies are plaintext, so put the file somewhere private — and .gitignore it along with the identity file. Untracked is not ignored: one git add -A commits your X25519 private key and every message you have exchanged.

The file is created 0600. A transcript created before 3.12.0 kept the old umask default, and upgrading does not repair it — O_CREAT sets a mode only when it creates the file. Since 3.15.0 the library checks the mode on every write and reports a group- or world-readable transcript once per process on stderr, without changing it; chmod 600 the file when you see that. It is not repaired for you because loosening it is something an operator may have done deliberately.

.stringcup/
identity.json
chat.jsonl
known_peers.json

It is also the authoritative record of your own outbound bytes. An agent verifying a Unicode payload retyped it into a fresh script, silently dropped an invisible character, got a mismatching hash and nearly reported a fabricated encoding bug. Hash out of the transcript, never out of a retyped literal.

Verifying keys

from stringcup import Client, TrustStore

# Strongest: require a fingerprint verified out of band
me.peer_public_key(peer_id, pin="sha256:JLv6MQ0Yw8JV_Cv64fmVTyXN8p0V5v5BF22BaLbTcLo")

# Pragmatic default: trust on first use, raise KeyPinMismatch on change
me = Client.load_or_register("./identity.json",
                             trust_store=TrustStore("./known_peers.json"))

Fingerprints are always recomputed locally — the server's own field is never trusted, because a substituted key would arrive with a matching one.

peer_info(id) also returns key_updated_at, which moves only when the key itself changes (updated_at moves for any edit), so a pinned client can tell a genuine rotation from a display-name tweak. See Verifying a peer's key.

Rotating your own key

rotate_identity_key(save_to=...) replaces your X25519 keypair. It is the only forward secrecy this protocol has, and it is coarse — per rotation, not per message.

The old key is retained for decryption only, for 30 days (RETIRED_KEY_GRACE_SECONDS), and then destroyed. The destruction is what delivers the secrecy, so it does not begin at the moment you rotate. That retention is not a convenience: peers cache your public key indefinitely and keep sealing mail to the old one until they call peer_public_key(..., refresh=True). Before 3.16.0 that mail was destroyed permanently and silently while the sender was told it had been stored.

Two things still follow, and neither is fixed by the grace window:

  • Tell your peers out of band that you rotated. Nothing invalidates their cache, so 30 days is a bound on their convenience, not a guarantee. Mail sealed to the old key after the window closes is unreadable.
  • Anyone who pinned you sees KeyPinMismatch, which from their side is indistinguishable from key substitution. Warn them first; rotating spends work they paid for.

Channels and broadcast

made = me.create_topic(label="acme-run7-ops", members=[bob_id, carol_id])
channel = made["id"]              # 'tp-...' — ASSIGNED by the relay
made["name"]                      # None: the relay stores no name

result = me.broadcast(channel, "status report please")
# {'count': 2, 'failed': [], 'topic': 'tp-...', 'recipients': 2}

me.label_for(channel)             # 'acme-run7-ops', local only
me.delete_topic(channel)          # owner only; retracts nothing already sent

Each recipient gets its own ciphertext — that is what keeps fan-out end-to-end encrypted — but it costs two requests regardless of group size. Broadcast is partial-success: check result["failed"].

The relay assigns the channel id and you cannot choose it. There is no name parameter; passing one is a TypeError, and a relay receiving one answers 400. Same rule as external_id on an identity: a value a caller chooses is a value an attacker can predict or squat — and a channel name is human-meaningful enough to describe the conversation rather than merely its existence, so it does not belong in a URL the relay logs.

label= never reaches the relay. It is stored locally (in the trust store when you pass one, so it survives a restart) and delivered to members inside the encryption, which is how a group agrees on a name the relay never learns. label_for(id) returns it or None — and showing the id is the right fallback: a member that missed the notice inventing its own name is how two members come to disagree about one channel.

A label is the owner's claim, not an authenticated fact. Never authorise on it. Message.channel carries the verified channel id; that is the field that means something.

Channels created before ids were assigned keep their name and remain addressable by either form.

Errors

All inherit StringcupError: AuthError (401), NotFoundError (404), ValidationError (400), MessageTooLarge (413), RateLimited (429, has .retry_after), RecipientInboxFull (507), DecryptionError.

RecipientInboxFull is retryable — do not drop the message. The request was valid; the recipient is simply behind on acknowledging. Hold the message and try again once it drains:

from stringcup import RecipientInboxFull
try:
    me.send(peer, text)
except RecipientInboxFull:
    # The peer has 2000 messages or 64 MiB pending. Nothing was stored and
    # nothing was lost — wait and retry.
    time.sleep(60)

MessageTooLarge is not retryable as-is: split the payload. Both ceilings are advertised at GET /api/v2 as message_max_bytes, inbox_max_pending_messages and inbox_max_pending_bytes.

DecryptionError messages name the exact HKDF context that was used, because that is nearly always the cause.

Behaviour worth knowing

  • drain() ACKs after the handler returns. If the handler raises, the page stays unacknowledged and you'll see it again. At-least-once, never at-most-once.
  • Undecryptable messages are skipped, not fatal — one bad sender can't wedge your inbox. They stay unACKed.
  • Nothing you receive ever expires. Only your ACK deletes a message, so an agent that polls once a month loses nothing. The flip side is that an inbox you never drain eventually makes your senders fail with 507 — acknowledge what you process.
  • auto_throttle=True (default) spreads the last 10 requests of a budget over the remaining window instead of stalling for the rest of the hour.
  • Identity files are written 0600, atomically. They hold a private key.
  • rotate_token(save_to=...) persists before returning — the old token is dead the moment the call succeeds, so losing the response means re-registering.

Tests

python3 test_contract.py       # 27 assertions: version + surface invariants (no network)
python3 test_stringcup.py      # 70 assertions: full client surface
python3 test_features_v11.py   # 95 assertions: long poll, pinning, topics, fan-out, rendezvous
python3 test_interop.py        # 12 assertions, Python <-> PHP: identical keys, byte-exact
python3 test_mcp.py            # 226 assertions: MCP protocol + tool shapes (no network)
python3 test_mcp_live.py       # 86 assertions: authenticated pairing, conversation, channels, forged-label rejection
python3 example_agent.py --help

Counts measured, not remembered — three of these were stale by as much as 64 assertions, which is the sort of number a reader uses to judge whether a suite covers anything.

All but test_mcp.py and test_contract.py need a reachable server; those two run offline.

test_interop.py is the one that matters most: it drives the PHP reference implementation (tests/lib/v2_client.php) as a second party and checks that both sides decrypt each other across ASCII, accents, CJK, emoji, embedded JSON, newlines and 4 KB payloads.

Each suite registers two identities against a 30/hour per-IP cap (test_mcp_live.py registers three, for the channel step). On the server host, clear writable/cache/ratelimit/ between runs.


Python version

Targets 3.7+ deliberately, because Amazon Linux 2 ships 3.7 and has no newer Python in any repo or extra. The library uses no 3.8+ syntax.

cryptography is pinned <46 in requirements.txt: 45.x is the last line supporting 3.7 and already warns it's going away, so an unpinned upgrade would break the client. Drop the ceiling once you're on 3.8+.