What is Signet?
Signet is a peer-to-peer courier for agentic work. One person describes a task in natural language and names a recipient; the recipient's machine runs it with a local AI agent, but only if the two people have an explicit, scoped, revocable trust relationship, the dispatch is cryptographically signed by the sender's device, and the recipient approves it.
A dispatch never runs on a machine unless all three hold:
- an accepted trust edge exists from sender to recipient,
- the dispatch carries a valid signature from a device of that sender,
- it stays within the scope that edge grants, and destructive tool calls get a per-call human approval.
Components
| Component | What it is |
|---|---|
Broker | Multi-tenant FastAPI service, Postgres-backed. Issues identity, routes dispatches, enforces trust policy, relays events. Never holds a signing key; never touches a recipient's filesystem. |
Daemon | A small background process each user runs on their own machine. Holds that machine's Ed25519 device key, signs outgoing dispatches, verifies incoming ones, and runs the agent. |
Web UI | A React app served both by the broker and by each daemon on 127.0.0.1: manage contacts and per-edge permissions, compose, watch sent dispatches, act on your inbox. |
MCP server | A thin in-session client Claude Code launches per session. Holds no broker connection, key, or executor. It ensures a daemon is running and drives it over the daemon's loopback API. |
Agent | A Claude Agent SDK session the daemon opens per accepted dispatch, on a clean base. Transient: created on accept, gone when the task ends. |
Every user is both a sender and a recipient; everyone runs a daemon.
Core concepts
| Concept | Meaning |
|---|---|
User | Identified by email. Created on first sign-in. |
Device | One per machine running the daemon. Has an Ed25519 keypair; the private key never leaves the machine. |
Invitation | An emailed, single-use, expiring link. How a trust edge is born. |
Trust edge | A directed from_user → to_user relationship, created when the recipient accepts an invitation. Per-direction: B accepting A's invite lets A dispatch to B, nothing more. |
Dispatch | One task. Signed by the sender's device, authorized by an edge, executed by the recipient's daemon. |
Quickstart
The trust layers need a local daemon that holds your device key, signs and verifies, runs the agent, and holds the approval prompts. There is always exactly one daemon per machine. The two setups differ only in how the daemon gets started.
A) Via Claude Code, the low-friction default
Install the Signet plugin for Claude Code. It bundles the /dispatch skill and an
MCP server that Claude Code launches each session. On startup it checks for a running
daemon and, if there isn't one, spawns it detached.
# 1. Install the package so the plugin's commands are on your PATH:
pipx install git+https://github.com/kaan7305/dispatch.git
# 2. Sign in from the terminal (opens one browser tab to approve):
dispatch login --broker https://your-broker
# 3. Add the marketplace and install the plugin (one-time), in Claude Code:
/plugin marketplace add kaan7305/dispatch
/plugin install dispatch@dispatch
You don't need a separate Anthropic API key for in-session mode. The agent runs on
your existing Claude Code login. Restart your session; the MCP server exposes
dispatch_read, dispatch_act, dispatch_send,
dispatch_invite, and dispatch_trust.
B) Always-on daemon, for background use
After signing in to the broker, the page shows a one-line installer:
curl -fsSL https://your-broker/install.sh | bash -s -- <your-token>
To set your Anthropic API key in the same step (the recipient runs the agent on their
own key), pass it as a second argument. The installer sets up pipx if
needed, saves broker + token to ~/.dispatch/config.json, and starts the
daemon. On first run it generates this machine's Ed25519 device keypair and enrolls
the public key with the broker. Every later run is just:
dispatch-daemon
Using it
- Sign in: the web UI via Clerk; the CLI/daemon via
dispatch login(a device-code flow). - Run your daemon: the install one-liner once, or let Claude Code auto-spawn it.
- Invite: in Contacts (or
dispatch invite <email>), invite a teammate. - They accept, choosing the scopes they grant you: tools, MCP servers, approval mode.
- Compose: pick them as recipient, describe the task, send.
- Watch: the full event stream appears live in your Sent card and their Inbox card.
Revoke a contact any time. It cancels anything in flight on that edge and refuses new dispatches immediately.
Running the broker
The broker needs Postgres. Run it locally for development or deploy it.
A) Locally
docker run -d --name dispatch-pg \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=dispatch -p 5432:5432 postgres:16
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # set DISPATCH_JWT_SECRET
uvicorn --app-dir src dispatch.broker.app:app --env-file .env --host 0.0.0.0 --port 8000
Open http://localhost:8000. For a teammate on another machine:
cloudflared tunnel --url http://localhost:8000.
B) Railway (managed)
- Push the repo to GitHub; create a Railway project from it (Nixpacks auto-detects Python and uses the Procfile).
- Add the PostgreSQL plugin and wire
DATABASE_URL. - Set
DISPATCH_JWT_SECRET(openssl rand -hex 32). - Settings → Networking → Generate Domain.
/healthis the healthcheck.
The broker schema is idempotent and runs on every startup, no migration step.
Environment
| Var | Where | Notes |
|---|---|---|
DISPATCH_JWT_SECRET | Broker | 32+ random chars. Rotating it invalidates all tokens. |
DATABASE_URL | Broker | Postgres URL. Auto-set by Railway's plugin. |
DISPATCH_PUBLIC_URL | Broker | Public base URL; falls back to the Railway domain. |
CLERK_* | Broker | Enable Clerk (Google) sign-in for the web UI. |
RESEND_API_KEY | Broker | Real email for invitation links; dev mode returns them in the API response. |
ANTHROPIC_API_KEY | Daemon | The recipient runs the agent on their own key. |
DISPATCH_WORKSPACE | Daemon | Agent working directory. Default ./workspace. |
DISPATCH_KEY_BACKEND | Daemon | file to store the device key in a 0600 file instead of the OS keychain. |
The trust model
Trust is enforced in three independent places. A compromised broker cannot forge a trusted sender, because Layer 2 happens entirely on the recipient's machine against a key the broker can't substitute.
"Is there an accepted trust edge? In scope? Under the rate limit?" Anything else gets a 403 before it goes anywhere.
"Is the signature valid? Key pinned? Nonce fresh and unseen?" Verified entirely on the recipient's machine, else rejected.
"Does the human approve this specific tool call?" Manual or auto, per the scope the recipient set.
The layers are deliberately redundant. Layer 2 is the backstop: even if someone owned the broker outright, they couldn't forge a trusted sender, because the signature check runs against a key the broker never held.
How an edge is born
An emailed, single-use invitation. The device key is pinned when the recipient first accepts. If it ever changes, the edge stops working until you re-verify.
What travels
The task text, the event stream you watched, and the result, relayed by the broker. The files the agent reads stay on the recipient's machine, except what their own agent sends to their model provider, under their own key, same as any Claude Code session.
Scopes & MCP sharing
What an edge permits is set by the recipient (the trustor) and is changeable any time, revocable outright.
{
"tools": ["Read", "Glob", "Grep"], // subset of Read/Write/Edit/Bash/Glob/Grep
"mcp": [], // MCP servers a dispatch may use: names or ["*"]
"paths": ["~/work"], // file-path allowlist ([] = no path limit)
"approval": "manual", // "manual" = approve every tool call | "auto"
"max_dispatches_per_day": 50,
"expires_at": null
}
New edges default to least privilege: read-only tools, no MCP, manual approval.
Letting dispatches use your MCP tools
A dispatch can use the recipient's own MCP servers (their Notion, search, domain tools) without exposing the rest of the machine. No manual setup:
-
Auto-discovery. Your installed MCP servers are discovered automatically
from your Claude config (
~/.claude.json: user, per-project, and each project's.mcp.json). The dispatch control plane is never exposable. - Pick at invite time. When someone accepts your invite, or when you edit an edge, you choose which of your servers that sender may use: "Allow all", or a per-server Allow/Don't.
A dispatch is handed only the servers its edge scoped. Unscoped servers are never
launched, attempted, or even visible to the task. And the task runs on a clean base,
so the edge's mcp grant is the entire reachable surface, gated per-call
by the approval layer.
The dispatch CLI
A thin terminal client for the broker, installed as the dispatch command
alongside dispatch-daemon. It reads the broker URL + JWT the daemon saved
to ~/.dispatch/config.json. Once your daemon is set up there's nothing
else to configure.
# Setup / lifecycle:
dispatch login [--broker URL] # device-code sign-in; saves config
dispatch update # update the package (+ plugin if changed)
dispatch tray # launch the menu-bar tray (hosts the daemon)
# Broker-backed:
dispatch whoami # who am I + which broker
dispatch contacts # trust edges + scopes
dispatch send <recipient> '<task>' # create a dispatch (your daemon signs it)
dispatch sent # dispatches I've sent
dispatch inbox # dispatches addressed to me
dispatch status <id> # one dispatch: status + event trace
dispatch cancel <id> # cancel an in-flight dispatch
dispatch invite <email> # invite someone to dispatch to you
dispatch set-scope <edge> [--tools …] # edit an edge's permissions
dispatch revoke <edge> # revoke an edge (cancels in-flight)
# Resolved by THIS machine's daemon (loopback API, not the broker):
dispatch accept <id> | decline <id> # decide on an inbound dispatch
dispatch approvals # tool calls awaiting allow/deny
dispatch approve <id> <request_id> # allow one pending tool call
dispatch deny <id> <request_id> # deny one pending tool call
Add --json to any command for machine-readable output. The CLI never
holds a signing key: send needs your daemon online to sign, and
accept/approve hit your daemon's loopback API. The daemon
ignores decisions relayed by the broker, so a compromised broker can't fabricate your
approval.
dispatch approve/deny, or a click in the local UI.
Claude Code skill
Drive Signet from inside Claude Code with natural language ("dispatch this to
Edward", "what's in my dispatch inbox?", "accept that dispatch") or the
/dispatch slash command.
The skill is pure instructions; the real work runs through the dispatch
CLI it calls. It ships with the plugin, or symlink it manually:
mkdir -p ~/.claude/skills
ln -sfn "$PWD/skills/dispatch" ~/.claude/skills/dispatch
After that, Claude auto-invokes it on the trigger phrases above and walks you through the send / inbox / accept flows, always confirming before it sends or accepts. Tool-call approvals stay in the daemon's local approval UI: the skill points you there rather than approving on your behalf.
API & signing
The broker's surface, and what a signature actually covers.
HTTP
| Verb | Path | Purpose |
|---|---|---|
POST | /auth/device → /approve → /token | Device-code flow for the CLI (dispatch login). |
POST | /devices/enroll | Register a device public key. |
POST / GET | /invitations | Send / list invitations. |
GET | /trust | My contacts (accepted edges + scopes). |
PATCH / DELETE | /trust/{id} | Edit scopes (trustor only) / revoke. |
POST | /dispatch · /dispatch/{id}/cancel | Create / cancel a dispatch. |
GET | /dispatch/{id}, /dispatches | Record / history. |
GET | /install.sh · /health | The daemon installer · liveness + DB check. |
WebSockets
-
/agent/connect: the daemon. Broker → daemon:new_dispatch,sign_request,cancel_dispatch. Daemon → broker:signed,dispatch_status,dispatch_event. Approval frames are deliberately not on this socket. Layer 3 resolves on the daemon's own loopback API. /inbox: the recipient's browser; streams inbox + per-dispatch events./dispatch/{id}/watch: the sender's browser; streams events.
Signing
The signed canonical payload (deterministic sorted-key JSON) covers:
instruction, sender_device, recipient_user,
target_device, nonce, created_at.
The sender's daemon signs it (Ed25519); the recipient's daemon rebuilds the identical
bytes and verifies against the sender device's public key, pinned on first sight in
~/.dispatch/pins.json, so the broker can't swap a key later. Replay is
caught by a durable per-(device, nonce) guard that persists across restarts; a 30-day
freshness window bounds staleness while still letting a dispatch wait in the offline
queue for a recipient who's away.
Security model
What the design guarantees, and its limitations.
What the design does guarantee
- No dispatch runs without an accepted, in-scope, unexpired trust edge.
- A dispatch is cryptographically tied to a device of the named sender; a compromised broker cannot forge a sender or swap a pinned key.
- The agent is confined to the edge's tools and (when set) path allowlist.
- Revocation is immediate and cancels in-flight work.
Known limitations
- Signing proves origin, not authorship. Because composing happens in the browser and the daemon signs what's relayed, a fully-compromised broker could get a sender's daemon to sign a task the sender didn't type. Closing this needs a trusted compose surface: composing in the daemon.
- Key pinning is TOFU. A broker compromised before a device's first contact could seed a wrong key. Pin-at-trust-establishment would harden it.
- Bash is not path-checkable. The paths allowlist is enforced on Read/Write/Edit/Glob/Grep; granting Bash grants shell, full stop.
- No end-to-end encryption. The broker relays plaintext instructions. E2E (libsodium sealed boxes) is deferred.
- The replay-nonce guard is durable, so the 30-day freshness window doesn't widen replay exposure, but a signed dispatch stays valid for delivery up to 30 days, by design (offline queue).
Troubleshooting
The failures you'll actually hit, and what they mean.
| Symptom | What to check |
|---|---|
ModuleNotFoundError: dispatch | Broker: run with --app-dir src. Daemon: python -m dispatch.daemon from the repo root, or use the installed dispatch-daemon. |
database: down from /health | DATABASE_URL unset or unreachable. |
| Daemon handshake failed / 1008 | Token invalid/expired, or the broker has a different DISPATCH_JWT_SECRET than issued the token. |
POST /dispatch → 403 | No accepted trust edge to that recipient. Invite them and have them accept first. |
POST /dispatch → 503 | Your own daemon is offline; it has to sign. |
SignatureRejected | Signature/nonce/freshness failed, or the sender device's key changed since it was pinned. |
| Recipient's inbox empty | Their daemon must be connected and they must be signed in to the broker as the same user. |