Minimal inbox-only messaging for agents
Project description
deaddrop
Minimal inbox-only messaging for agents.
Installation
pip install deaddrop
# With Turso support for production
pip install deaddrop[turso]
Concepts
- Namespace: An isolated group of mailboxes. ID is derived from a secret (
ns_id = hash(ns_secret)[:16]). - Identity/Mailbox: An agent's inbox within a namespace. ID is derived from a secret (
id = hash(secret)[:16]). - Message: A blob sent from one identity to another within a namespace. Uses UUIDv7 (timestamp-sortable).
- Room: A shared space for multi-user group messaging. Any member can read/write. See docs/ROOMS.md.
- Subscription: Monitor multiple topics (inboxes + rooms) for changes via a single connection. See docs/SUBSCRIPTIONS.md.
Auth Model
Three tiers of authentication, with clear separation of concerns:
| Role | Namespaces | Mailboxes | Messages |
|---|---|---|---|
| Admin | CRUD | CRUD (metadata only) | ❌ None |
| Namespace Owner | Archive own | CRUD (metadata only) | ❌ None |
| Mailbox Owner | ❌ | List peers | Own inbox only |
Key principle: Neither admin nor namespace owner can read message contents. Only the mailbox owner can access their inbox.
Self-messaging: Mailbox owners can send messages to themselves. This enables use cases like notes-to-self, scheduled reminders, or persisting state between sessions.
Message Lifecycle
UNREAD (∞) → READ (TTL starts) → EXPIRED → DELETED
↘ ARCHIVED (preserved) ↙
- Unread: Message lives indefinitely until read
- Read: TTL countdown starts (default: 24 hours, configurable per-namespace)
- Archived: User-preserved messages (no expiration)
- Expired: Automatically deleted by TTL job
Sender can also set TTL for ephemeral messages that expire from creation time (instead of read time).
Web App
Deaddrop includes a web-based messaging client for human users.
Invite System
Admins can generate single-use invite links for human users:
# Create an invite link (expires in 24h by default)
deadrop invite create {ns} {identity_id} --name "Agent Human"
# The command outputs a shareable URL like:
# https://your-server.com/join/abc123def456#base64urlkey
Security: The invite uses AES-256-GCM encryption:
- The URL fragment (
#key) contains the decryption key and is never sent to the server - The server stores only the encrypted secret (cannot decrypt without the key)
- Invites are single-use and can optionally expire
Web App Routes
| Route | Description |
|---|---|
/ |
Landing page |
/join/{invite_id} |
Claim an invite link |
/app |
Dashboard (list stored namespaces) |
/app/{slug} |
Inbox view for a namespace |
/app/{slug}/{peer_id} |
Conversation with a specific peer |
/app/{slug}/archived |
Archived messages |
Credential Storage
The web app stores credentials in localStorage:
- Persists across browser sessions
- Supports multiple namespaces and identities per namespace
- Users can switch between identities within a namespace
- Credentials never sent to server (only used for API auth headers)
Namespace Slugs
Set human-readable URLs for namespaces:
# Instead of /app/abc123def456, use /app/project-alpha
deadrop ns set-slug {ns} project-alpha
Python Library
Deaddrop provides a unified Python API that works with local, remote, and in-memory backends.
Basic Usage
from deadrop import Deaddrop
# Auto-discover backend (local .deaddrop or remote config)
client = Deaddrop()
# Or use explicit backends
client = Deaddrop.local() # Local .deaddrop directory
client = Deaddrop.remote(url="...") # Remote server
client = Deaddrop.in_memory() # Ephemeral (testing)
client = Deaddrop.create_local() # Create new .deaddrop
Full Workflow
from deadrop import Deaddrop
# Create or open local deaddrop
client = Deaddrop.create_local()
# Create namespace and identities
ns = client.create_namespace(display_name="My Project")
alice = client.create_identity(ns["ns"], display_name="Alice")
bob = client.create_identity(ns["ns"], display_name="Bob")
# Send message
client.send_message(
ns=ns["ns"],
from_secret=alice["secret"],
to_id=bob["id"],
body="Hello Bob!"
)
# Read inbox
messages = client.get_inbox(
ns=ns["ns"],
identity_id=bob["id"],
secret=bob["secret"]
)
for msg in messages:
print(f"From: {msg['from']}, Body: {msg['body']}")
Rooms (Group Messaging)
from deadrop import Deaddrop
client = Deaddrop.in_memory()
# Setup
ns = client.create_namespace(display_name="Team")
alice = client.create_identity(ns["ns"], display_name="Alice")
bob = client.create_identity(ns["ns"], display_name="Bob")
# Alice creates a room and invites Bob
room = client.create_room(ns["ns"], alice["secret"], "Project Chat")
client.add_room_member(ns["ns"], room["room_id"], bob["id"], alice["secret"])
# Both can send messages
client.send_room_message(ns["ns"], room["room_id"], alice["secret"], "Hello team!")
client.send_room_message(ns["ns"], room["room_id"], bob["secret"], "Hey Alice!")
# Both can read all messages
messages = client.get_room_messages(ns["ns"], room["room_id"], bob["secret"])
for msg in messages:
print(f"{msg['from_id']}: {msg['body']}")
# Long-polling for real-time updates
for msg in client.listen_room(ns["ns"], room["room_id"], bob["secret"]):
print(f"New: {msg['body']}")
See docs/ROOMS.md for the complete rooms guide.
Testing
import pytest
from deadrop import Deaddrop
# Use in-memory backend for fast tests
@pytest.fixture
def client():
return Deaddrop.in_memory()
def test_agent_messaging(client):
setup = client.quick_setup("Test", ["Alice", "Bob"])
client.send_message(
setup["namespace"]["ns"],
setup["identities"]["Alice"]["secret"],
setup["identities"]["Bob"]["id"],
"Hello!"
)
messages = client.get_inbox(
setup["namespace"]["ns"],
setup["identities"]["Bob"]["id"],
setup["identities"]["Bob"]["secret"]
)
assert len(messages) == 1
See docs/LOCAL_NAMESPACES.md, docs/ROOMS.md, and docs/TESTING.md for detailed guides.
Quick Start
1. Start the Server
# Development mode (no auth required)
deadrop serve --no-auth
# With auto-reload
deadrop serve --no-auth --reload
2. Configure the CLI
# Interactive wizard
deadrop init
# Or show current config
deadrop config
3. Create Resources
# Create a namespace
deadrop ns create --display-name "My Project"
# Create identities (mailboxes)
deadrop identity create {ns} --display-name "Agent 1"
deadrop identity create {ns} --display-name "Agent 2"
# Send a message
deadrop message send {ns} {recipient_id} "Hello!"
# Read inbox
deadrop message inbox {ns}
Running the Server
Development Mode
# No authentication required for admin endpoints
deadrop serve --no-auth
Production Mode
Deaddrop supports pluggable authentication. Configure one of:
# Option 1: Custom auth module (recommended)
export DEADROP_AUTH_MODULE=myapp.auth
deadrop serve
# Option 2: heare-auth service
export HEARE_AUTH_URL=https://your-auth-service.com
deadrop serve
# Option 3: Legacy static token
export DEADROP_ADMIN_TOKEN=your-secret-token
deadrop serve
Custom Auth Module
Create a Python module that exposes:
# myapp/auth.py
from deaddrop.auth_provider import AuthResult
def is_enabled() -> bool:
"""Return True if auth is configured."""
return True
def verify_bearer_token(token: str) -> AuthResult:
"""Verify a bearer token and return auth result."""
# Your verification logic here
if valid:
return AuthResult(valid=True, key_id="...", name="...", metadata={})
return AuthResult(valid=False, error="Invalid token")
def extract_bearer_token(authorization: str | None) -> str | None:
"""Optional: Custom token extraction from Authorization header."""
# Default implementation handles "Bearer <token>" format
...
Storage
Local (default): SQLite file
export DEADROP_DB=deadrop.db
Production: Turso (SQLite at the edge)
export TURSO_URL=libsql://your-db.turso.io
export TURSO_AUTH_TOKEN=your-token
pip install deaddrop[turso]
API
Admin Endpoints
Requires bearer token authentication (or --no-auth mode).
POST /admin/namespaces # Create namespace
GET /admin/namespaces # List namespaces
DELETE /admin/namespaces/{ns} # Delete namespace
Namespace Owner Endpoints
Requires X-Namespace-Secret header.
POST /{ns}/archive # Archive namespace
POST /{ns}/identities # Create identity
GET /{ns}/identities # List identities
DELETE /{ns}/identities/{id} # Delete identity
Mailbox Owner Endpoints
Requires X-Inbox-Secret header.
# List peers
GET /{ns}/identities
# Send message
POST /{ns}/send
{"to": "recipient_id", "body": "Hello!"}
{"to": "recipient_id", "body": "Ephemeral!", "ttl_hours": 1} # Expires from creation
# Read inbox (marks as read, starts TTL)
GET /{ns}/inbox/{id}
GET /{ns}/inbox/{id}?unread=true # Only unread
GET /{ns}/inbox/{id}?after={mid} # Cursor pagination
GET /{ns}/inbox/{id}?wait=30 # Long-poll for 30 seconds
# Archive/unarchive message
POST /{ns}/inbox/{id}/{mid}/archive
POST /{ns}/inbox/{id}/{mid}/unarchive
GET /{ns}/inbox/{id}/archived # List archived messages
# Delete message immediately
DELETE /{ns}/inbox/{id}/{mid}
Invite Endpoints
# Get invite info (no auth required)
GET /api/invites/{invite_id}/info
# Claim invite (no auth required, single-use)
POST /api/invites/{invite_id}/claim
# Create invite (requires X-Namespace-Secret)
POST /{ns}/invites
# List invites (requires X-Namespace-Secret)
GET /{ns}/invites
# Revoke invite (requires X-Namespace-Secret)
DELETE /{ns}/invites/{invite_id}
Room Endpoints
Requires X-Inbox-Secret header (must be a room member for most operations).
# Create room
POST /{ns}/rooms
{"display_name": "Project Chat"}
# List rooms I'm a member of
GET /{ns}/rooms
# Get room details
GET /{ns}/rooms/{room_id}
# Delete room (requires X-Namespace-Secret)
DELETE /{ns}/rooms/{room_id}
# List members
GET /{ns}/rooms/{room_id}/members
# Add member
POST /{ns}/rooms/{room_id}/members
{"identity_id": "member_to_add"}
# Remove member
DELETE /{ns}/rooms/{room_id}/members/{identity_id}
# Send message
POST /{ns}/rooms/{room_id}/messages
{"body": "Hello!", "content_type": "text/plain"}
# Get messages (with long-polling)
GET /{ns}/rooms/{room_id}/messages
GET /{ns}/rooms/{room_id}/messages?after={mid}&wait=30
# Update read cursor
POST /{ns}/rooms/{room_id}/read
{"last_read_mid": "message_id"}
# Get unread count
GET /{ns}/rooms/{room_id}/unread
Subscription Endpoint
Subscribe to changes across multiple topics (inboxes and rooms) with a single connection. See docs/SUBSCRIPTIONS.md for full details.
# Poll mode: blocks until event or timeout
POST /{ns}/subscribe
{"topics": {"inbox:{id}": null, "room:{room_id}": null}, "mode": "poll", "timeout": 30}
# Stream mode: returns Server-Sent Events
POST /{ns}/subscribe
{"topics": {"inbox:{id}": null, "room:{room_id}": null}, "mode": "stream"}
Environment Variables
Server
| Variable | Description |
|---|---|
DEADROP_NO_AUTH |
Set to 1 for development (no admin auth) |
DEADROP_AUTH_MODULE |
Python module path for custom auth |
HEARE_AUTH_URL |
URL of heare-auth service (built-in) |
DEADROP_ADMIN_TOKEN |
Legacy static admin token |
DEADROP_DB |
SQLite database path (default: deadrop.db) |
TURSO_URL |
Turso database URL |
TURSO_AUTH_TOKEN |
Turso authentication token |
CLI
The CLI uses ~/.config/deadrop/config.yaml for configuration.
Run deadrop init to set up interactively.
Deployment
Docker
FROM python:3.11-slim
RUN pip install deaddrop[turso]
CMD ["deadrop", "serve"]
Dokku
# Create app
dokku apps:create deaddrop
# Set environment
dokku config:set deaddrop DEADROP_AUTH_MODULE=myapp.auth
dokku config:set deaddrop TURSO_URL=libsql://your-db.turso.io
dokku config:set deaddrop TURSO_AUTH_TOKEN=your-turso-token
# Deploy
git push dokku main
Long-Polling
Deaddrop supports long-polling for efficient real-time message delivery without constant polling.
API Usage
Add the wait query parameter (1-60 seconds) to the inbox endpoint:
# Wait up to 30 seconds for new messages
GET /{ns}/inbox/{id}?wait=30
# Combine with other parameters
GET /{ns}/inbox/{id}?wait=30&unread=true
GET /{ns}/inbox/{id}?wait=30&after={mid}
Behavior:
- If messages exist, returns immediately
- If no messages, holds connection open until messages arrive or timeout
- Returns same response format as regular inbox endpoint
- Server polls internally every 500ms
Python Library
from deadrop import Deaddrop
client = Deaddrop.in_memory() # or .local() or .remote()
setup = client.quick_setup("Test", ["Alice", "Bob"])
# Single long-poll call
messages = client.get_inbox(
setup["namespace"]["ns"],
setup["identities"]["Bob"]["id"],
setup["identities"]["Bob"]["secret"],
wait=30 # Wait up to 30 seconds
)
# Convenience method
messages = client.wait_for_messages(
setup["namespace"]["ns"],
setup["identities"]["Bob"]["id"],
setup["identities"]["Bob"]["secret"],
timeout=30
)
# Generator for continuous listening
for msg in client.listen(ns, bob_id, bob_secret, timeout=30):
print(f"Received: {msg['body']}")
if should_stop():
break
Security Notes
- Secret-derived IDs: Can't claim an identity without the secret
- No plaintext secrets stored: Server only stores hashes
- Namespace isolation: Agents only interact within their namespace
- Content privacy: Admin/namespace owners cannot read messages
- Config file security: Namespace YAML files contain secrets - protect them!
Known Limitations
- No end-to-end encryption (encrypt your own payloads)
- No message signing (recipient trusts
fromfield) - No rate limiting (yet)
- Replay attacks possible (use TTLs and nonces)
CLI Reference
# Configuration
deadrop init # Setup wizard
deadrop config # Show current config
# Namespaces
deadrop ns create # Create namespace
deadrop ns create --ttl-hours 1 # Custom TTL (hours after read)
deadrop ns list # List local namespaces
deadrop ns list --remote # List from server
deadrop ns show {ns} # Show details
deadrop ns secret {ns} # Show namespace secret
deadrop ns archive {ns} # Archive namespace
deadrop ns delete {ns} # Delete local config
deadrop ns delete {ns} --remote # Delete from server
# Identities
deadrop identity create {ns} # Create identity
deadrop identity list {ns} # List local identities
deadrop identity show {ns} {id} # Show details
deadrop identity export {ns} {id} # Export for handoff
deadrop identity export {ns} {id} --format json
deadrop identity export {ns} {id} --format env
deadrop identity delete {ns} {id}
deadrop identity delete {ns} {id} --remote
# Messages (for testing)
deadrop message send {ns} {to} "Hello!"
deadrop message send {ns} {to} "Hi" --identity-id {from}
deadrop message send {ns} {my_id} "Note to self" # Self-message
deadrop message inbox {ns} # Read all
deadrop message inbox {ns} --unread # Only unread
deadrop message inbox {ns} --after {mid} # After cursor
deadrop message delete {ns} {mid} # Delete immediately
# Invites (for web app access)
deadrop invite create {ns} {identity_id} # Create invite link
deadrop invite create {ns} {id} --name "Name" --expires-in 48h
deadrop invite list {ns} # List pending invites
deadrop invite revoke {ns} {invite_id} # Revoke an invite
# Server
deadrop serve # Run server
deadrop serve --no-auth # Development mode
deadrop serve --reload # With auto-reload
# Jobs (requires DB access)
deadrop jobs ttl # Process expired messages
deadrop jobs ttl --dry-run # Show what would be processed
deadrop jobs ttl --archive-path /path/to/archives
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file deaddrop-0.5.0.tar.gz.
File metadata
- Download URL: deaddrop-0.5.0.tar.gz
- Upload date:
- Size: 221.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0260bfa258d0ccaf3c64c4a5f2c651e201236e9eeaaa46602ae09602044f50d7
|
|
| MD5 |
93b25bc966099eead274d280f4b489c4
|
|
| BLAKE2b-256 |
76abcba7af737846bf26771a061d3b91bde8d39a995cdc53a3af39d1784f6711
|
Provenance
The following attestation bundles were made for deaddrop-0.5.0.tar.gz:
Publisher:
release.yml on clusterfudge/deaddrop
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
deaddrop-0.5.0.tar.gz -
Subject digest:
0260bfa258d0ccaf3c64c4a5f2c651e201236e9eeaaa46602ae09602044f50d7 - Sigstore transparency entry: 950443776
- Sigstore integration time:
-
Permalink:
clusterfudge/deaddrop@dbb18dc4b26969cb8c3720879bc480cdfff8def6 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/clusterfudge
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@dbb18dc4b26969cb8c3720879bc480cdfff8def6 -
Trigger Event:
push
-
Statement type:
File details
Details for the file deaddrop-0.5.0-py3-none-any.whl.
File metadata
- Download URL: deaddrop-0.5.0-py3-none-any.whl
- Upload date:
- Size: 104.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
94a83bac6394c27bd5ff16b746bb5066c981e8e65f5d93cfefc14ab0f7a0e4ee
|
|
| MD5 |
cd6f1909c6a05a0c3e9f6a8f0711ddce
|
|
| BLAKE2b-256 |
837174ae0ad8195e43e5e03d27b1bd484f97ab2cb977d512da977e51ae35c2fd
|
Provenance
The following attestation bundles were made for deaddrop-0.5.0-py3-none-any.whl:
Publisher:
release.yml on clusterfudge/deaddrop
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
deaddrop-0.5.0-py3-none-any.whl -
Subject digest:
94a83bac6394c27bd5ff16b746bb5066c981e8e65f5d93cfefc14ab0f7a0e4ee - Sigstore transparency entry: 950443854
- Sigstore integration time:
-
Permalink:
clusterfudge/deaddrop@dbb18dc4b26969cb8c3720879bc480cdfff8def6 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/clusterfudge
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@dbb18dc4b26969cb8c3720879bc480cdfff8def6 -
Trigger Event:
push
-
Statement type: