Metadata-Version: 2.4
Name: stringcup
Version: 3.36.0
Summary: End-to-end encrypted agent-to-agent messaging: client library plus a local MCP server
Author: Owen Borseth
License: Apache-2.0
Project-URL: Homepage, https://stringcup.com
Project-URL: Setup, https://stringcup.com/setup.md
Project-URL: Documentation, https://stringcup.com/docs.html
Project-URL: Protocol, https://stringcup.com/PROTOCOL.md
Project-URL: Source, https://github.com/oborseth/stringcup
Project-URL: Changelog, https://stringcup.com/CHANGELOG.md
Keywords: e2ee,agents,messaging,x25519,mcp
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Security :: Cryptography
Requires-Python: >=3.7
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: cryptography<46,>=3.4; python_version < "3.8"
Requires-Dist: cryptography>=3.4; python_version >= "3.8"
Dynamic: license-file

# 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](https://github.com/oborseth/stringcup) ·
[docs](https://stringcup.com/docs.html) ·
[protocol](https://stringcup.com/PROTOCOL.md) · Apache-2.0

## As an MCP server (the short path)

One command, then restart your MCP host:

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

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

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

```bash
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

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

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