Signet

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

ComponentWhat it is
BrokerMulti-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.
DaemonA 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 UIA 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 serverA 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.
AgentA 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

ConceptMeaning
UserIdentified by email. Created on first sign-in.
DeviceOne per machine running the daemon. Has an Ed25519 keypair; the private key never leaves the machine.
InvitationAn emailed, single-use, expiring link. How a trust edge is born.
Trust edgeA 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.
DispatchOne 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.

Because the daemon persists across sessions, dispatches are received even when no Claude session is open. Anything sent while no daemon is running waits in the broker's offline queue and lands when a daemon next comes up.

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

  1. Sign in: the web UI via Clerk; the CLI/daemon via dispatch login (a device-code flow).
  2. Run your daemon: the install one-liner once, or let Claude Code auto-spawn it.
  3. Invite: in Contacts (or dispatch invite <email>), invite a teammate.
  4. They accept, choosing the scopes they grant you: tools, MCP servers, approval mode.
  5. Compose: pick them as recipient, describe the task, send.
  6. 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)

  1. Push the repo to GitHub; create a Railway project from it (Nixpacks auto-detects Python and uses the Procfile).
  2. Add the PostgreSQL plugin and wire DATABASE_URL.
  3. Set DISPATCH_JWT_SECRET (openssl rand -hex 32).
  4. Settings → Networking → Generate Domain. /health is the healthcheck.

The broker schema is idempotent and runs on every startup, no migration step.

Environment

VarWhereNotes
DISPATCH_JWT_SECRETBroker32+ random chars. Rotating it invalidates all tokens.
DATABASE_URLBrokerPostgres URL. Auto-set by Railway's plugin.
DISPATCH_PUBLIC_URLBrokerPublic base URL; falls back to the Railway domain.
CLERK_*BrokerEnable Clerk (Google) sign-in for the web UI.
RESEND_API_KEYBrokerReal email for invitation links; dev mode returns them in the API response.
ANTHROPIC_API_KEYDaemonThe recipient runs the agent on their own key.
DISPATCH_WORKSPACEDaemonAgent working directory. Default ./workspace.
DISPATCH_KEY_BACKENDDaemonfile 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.


Layer 1 · dispatch/broker server

"Is there an accepted trust edge? In scope? Under the rate limit?" Anything else gets a 403 before it goes anywhere.

Layer 2 · dispatch/daemon your machine

"Is the signature valid? Key pinned? Nonce fresh and unseen?" Verified entirely on the recipient's machine, else rejected.

Layer 3 · you human

"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.

Accepting a dispatch is not blanket approval: under a manual edge, each tool call still needs 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

VerbPathPurpose
POST/auth/device → /approve → /tokenDevice-code flow for the CLI (dispatch login).
POST/devices/enrollRegister a device public key.
POST / GET/invitationsSend / list invitations.
GET/trustMy contacts (accepted edges + scopes).
PATCH / DELETE/trust/{id}Edit scopes (trustor only) / revoke.
POST/dispatch · /dispatch/{id}/cancelCreate / cancel a dispatch.
GET/dispatch/{id}, /dispatchesRecord / history.
GET/install.sh · /healthThe 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).
Want the full threat model? Ask for it and we'll send the design document.

Troubleshooting

The failures you'll actually hit, and what they mean.


SymptomWhat to check
ModuleNotFoundError: dispatchBroker: run with --app-dir src. Daemon: python -m dispatch.daemon from the repo root, or use the installed dispatch-daemon.
database: down from /healthDATABASE_URL unset or unreachable.
Daemon handshake failed / 1008Token invalid/expired, or the broker has a different DISPATCH_JWT_SECRET than issued the token.
POST /dispatch → 403No accepted trust edge to that recipient. Invite them and have them accept first.
POST /dispatch → 503Your own daemon is offline; it has to sign.
SignatureRejectedSignature/nonce/freshness failed, or the sender device's key changed since it was pinned.
Recipient's inbox emptyTheir daemon must be connected and they must be signed in to the broker as the same user.