parley

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 27, 2026 License: MIT Imports: 9 Imported by: 0

README

parley

An open protocol for two people's agents to talk.

One agent opens a channel and gets an invite link. Its owner sends that link to another person — by DM, email, however — and that person's agent joins. The two agents then exchange messages, end to end encrypted, until one of them judges the matter settled and closes the channel. A relay in the middle carries the bytes and learns nothing: it holds no key and never sees plaintext.

This repo is the Go reference implementation. The normative, language- independent protocol spec lives in parley-spec — implement against that. A small local program — an MCP server — speaks parley on an agent's behalf, so the same protocol works in Claude Code, in jaz, or in anything else that speaks MCP.

              parley   (this repo: spec + noise + session + relayhttp)
             /        \
     parley-relay      parley-mcp
   (relayhttp.Server)  (session + noise + relayhttp.Client)

Lifecycle

open → invite → join → handshake → say … say → close
  • open / join — one side opens a channel and shares the invite link; the other joins from it.
  • say — send one turn and wait for the reply. Because saying is a tool call that blocks, the agent keeps its place in the loop and the floor passes back and forth one turn at a time — no agent ever "never stops."
  • close — the only clean ending, carrying an outcome both sides keep.

How it's built

  • Identity is an X25519 static key; its SHA-256 is the node's stable id, and the two owners can read each other a short fingerprint to confirm no relay sits between them (trust on first use).
  • Handshake is Noise IKpsk1 (via flynn/noise): the joiner already holds the opener's key from the invite, and the invite's one-time secret is mixed in as the pre-shared key.
  • Frames are all the relay sees — a channel id, a sequence number, a type, and an opaque payload. Data payloads are ChaCha20-Poly1305 ciphertext with the sequence number bound to the nonce.
  • Messages are deliberately one of two kinds, Say or Close — the small set that makes "are the agents done?" decidable.

A received message is untrusted external input: it may begin a turn as text, but on its own it can never make the receiver run a tool or read memory.

Packages

package what it is
parley the spec: types and interfaces, stdlib only
noise the handshake and transport, via flynn/noise
relayhttp the reference relay: HTTP server and client, one wire format
session the client engine: the turn loop over any relay

Status

v0.2 — 2-party channels, non-blocking Send/Poll, presence, and a client-side replay guard. Messages carry an authenticated From so the model already generalizes to groups (a planned additive extension). Spec: parley-spec. Binaries: parley-relay, parley-mcp.

License

MIT

Documentation

Overview

Package parley defines an open protocol for two people's agents to talk.

One agent opens a channel and receives an invite link. Its owner sends that link to another person — by DM, email, however — and that person's agent joins. The two agents then exchange messages, end to end encrypted, until one of them judges the matter settled and closes the channel. A relay in the middle carries the bytes and learns nothing: it holds no key and never sees plaintext.

parley belongs to no host. The protocol is the types in this package plus the rules below. A single small program — an MCP server running beside an agent — speaks it and exposes Session as tools, so the same protocol works in Claude Code, in jaz, or in anything else that speaks MCP.

Actors

Two humans, each with an agent, and one relay. The opener's agent creates the channel; the joiner's agent accepts the invite. The two agents are equals — neither is a parent or a service of the other. The relay is untrusted infrastructure: it admits members and forwards frames, nothing more.

Lifecycle

open → invite → join → handshake → say … say → close

The opener calls Session.Open and gets an Invite. A human carries the invite to the joiner, whose agent calls Session.Join. The two complete a Noise handshake and the channel becomes Active. From there each side [Session.Send]s messages and [Session.Poll]s for the peer's, both without blocking: Send returns at once and Poll waits only as long as it is told, so the two agents move at independent tempos rather than one freezing while the other's owner thinks. Either side ends the exchange with Session.Close, the channel's only clean terminal state.

Identity and trust

A node is an X25519 static key pair. Its public half is its Identity; the SHA-256 of that key is its NodeID, which it cannot choose. The same key authenticates the node in the handshake, so identity and key agreement are one thing, not two. On first contact a node pins the peer's identity — trust on first use — and the two owners may read each other the Identity.Fingerprint out of band to confirm no relay sits in the middle. The private key never leaves the node and never reaches the relay.

An invite is a URL:

https://relay.example/i/<channel>#k=<opener-key>&s=<secret>

The relay host and ChannelID sit in the path, where the relay routes on them. The opener's public key and the one-time Secret sit in the URL fragment, which clients keep out of request lines — so the relay never logs the material that authenticates the two ends to each other.

Handshake

The joiner is the initiator of a Noise IKpsk1 handshake (see Pattern): it already holds the opener's static key from the invite, and the invite Secret is mixed in as the pre-shared key, so completing the handshake proves possession of the link cryptographically rather than by the relay's say-so. A HandshakePayload travels inside the handshake, binding the channel topic and each side's Capabilities into the authenticated transcript. Version is bound into the Noise prologue, so the two sides cannot be talked onto different rules.

Frames and messages

A Frame is the only thing that crosses the relay. It carries a channel, a sequence number, a type, and an opaque payload. Handshake frames carry Noise handshake messages; Data frames carry the AEAD-sealed bytes of a Message (see Transport). The relay routes by channel and reads none of it.

A Message is deliberately one of two kinds — a Say or a Close. That small set is what makes "are the agents done?" a decidable question rather than a guess about whether someone stopped talking.

The relay

The relay (see Relay) is a store-and-forward broker reachable over HTTP:

POST /c/{channel}            open a channel, register the expected join token
POST /c/{channel}/join       claim the second seat with the join token
POST /c/{channel}/frames     send a frame
GET  /c/{channel}/frames     poll for frames after a cursor, waiting up to ?wait ms
GET  /c/{channel}/members    how many seats are occupied (presence)

A channel seats exactly two; the relay refuses a third. It checks the JoinToken — derived from the invite Secret by a one-way function — in constant time, and so admits the joiner without ever learning the Secret or the key it protects.

The trust boundary

A received Message is untrusted external input. It may begin a turn as text, but it can never, by itself, make the receiving agent run a tool or read memory; that line is structural, enforced by the host's tool boundary, not by anything the peer sends. And because Close is an end-to-end Message rather than a relay signal, the relay can no more forge the end of a conversation than it can read one.

Index

Constants

View Source
const Pattern = "Noise_IKpsk1_25519_ChaChaPoly_SHA256"

Pattern is the Noise handshake parley speaks. The joiner is the initiator and already knows the opener's static key from the invite (IK); the invite Secret is mixed in as the pre-shared key (psk1), so possession of the link is part of the cryptographic transcript, not merely a relay check.

View Source
const Version = 1

Version is the parley protocol version. It is bound into the Noise handshake prologue, so two sides cannot be silently negotiated onto different rules.

Variables

This section is empty.

Functions

This section is empty.

Types

type Capabilities

type Capabilities struct {
	// Push reports that the node can take turns delivered as they arrive rather
	// than long-polling for them. When both sides set it the channel runs
	// push-first; otherwise it falls back to polling.
	Push bool `json:"push,omitempty"`
}

Capabilities is what a node will do on a channel, exchanged during the handshake. It is an extension point, not a trust grant: the structural boundary — that a peer cannot drive local tools — holds whatever is set here.

type ChannelID

type ChannelID [16]byte

A ChannelID names a channel on a relay. The opener picks it at random; it is public routing metadata and reveals nothing about the conversation.

func NewChannelID

func NewChannelID() (ChannelID, error)

NewChannelID mints a random channel id.

func (ChannelID) String

func (c ChannelID) String() string

type Frame

type Frame struct {
	Channel ChannelID
	Seq     uint64
	Type    FrameType
	Payload []byte
}

A Frame is the only thing the relay sees. It routes by Channel and learns no more: a Data frame's Payload is ciphertext whose key the relay does not hold. Seq orders frames within one sender's direction and, for Data, binds the AEAD nonce, so a given Seq is never sealed twice under the same key.

type FrameType

type FrameType uint8

A FrameType distinguishes the two things that cross a channel: the Noise handshake that establishes keys, and the encrypted data that follows it.

const (
	Handshake FrameType = iota + 1 // a Noise handshake message
	Data                           // an AEAD-sealed [Message]
)

type HandshakePayload

type HandshakePayload struct {
	Topic        string       `json:"topic,omitempty"`
	Capabilities Capabilities `json:"capabilities"`
}

A HandshakePayload rides inside the Noise handshake messages, so the topic and each side's Capabilities are authenticated by the handshake and cannot be altered by the relay.

type Identity

type Identity struct {
	Key PublicKey
}

Identity is a node's public identity. The matching private key never leaves the node and never crosses the relay; the handshake authenticates the node by proving possession of it.

func (Identity) Fingerprint

func (id Identity) Fingerprint() string

Fingerprint is the human-readable rendering of the node's NodeID.

func (Identity) ID

func (id Identity) ID() NodeID

ID returns the node's stable handle.

type Invite

type Invite struct {
	Relay   string
	Channel ChannelID
	Key     PublicKey
	Secret  Secret
}

An Invite bootstraps a channel. The opener creates it and a human carries it to the joiner out of band. Relay host and ChannelID travel in the URL path; the opener's key and the Secret travel in the fragment, so they never appear in the relay's request logs.

func ParseInvite

func ParseInvite(raw string) (Invite, error)

ParseInvite reads a link produced by Invite.URL.

func (Invite) URL

func (in Invite) URL() string

URL renders the invite as the link a human shares.

type JoinToken

type JoinToken string

A JoinToken proves possession of the link to the relay without revealing the Secret.PSK. The opener registers the expected token; the joiner presents it.

type Kind

type Kind uint8

A Kind is what a Message does. There are exactly two: a turn of talk and the end of it. Keeping the set this small is the point — it is what lets a node decide whether the exchange is over instead of guessing from silence.

const (
	Say   Kind = iota + 1 // one turn; the sender now waits for the peer
	Close                 // terminal; Text carries the outcome both sides keep
)

type Membership

type Membership struct {
	Channel ChannelID
	Token   string
}

Membership is a node's authenticated seat on a channel, issued by the relay when the channel is opened or joined and presented on every call thereafter. It carries nothing the relay could use to read frames.

type Message

type Message struct {
	Kind Kind
	Text string
	From NodeID
}

A Message is one unit of conversation. On the wire only Kind and Text travel, sealed inside a Data frame. From is filled in on receipt from the channel's authenticated context — never self-asserted by the sender, so it cannot be forged — and identifies the sender: the lone peer today, and any group member once channels grow past two. A received Message is always untrusted external input: it may open a turn as text, but on its own it can never make the receiver run a tool or read memory.

func (Message) MarshalBinary

func (m Message) MarshalBinary() ([]byte, error)

MarshalBinary encodes a Message as its Kind byte followed by Text. The enclosing AEAD payload delimits Text, so nothing inside needs framing.

func (*Message) UnmarshalBinary

func (m *Message) UnmarshalBinary(b []byte) error

UnmarshalBinary reverses Message.MarshalBinary.

type NodeID

type NodeID [sha256.Size]byte

A NodeID is a node's stable handle: the SHA-256 of its PublicKey. Equal NodeIDs are the same identity, and a node cannot choose its own.

func (NodeID) Fingerprint added in v0.2.0

func (n NodeID) Fingerprint() string

Fingerprint renders the NodeID as a short, human-readable string two people can read to each other to confirm, on first contact, that no relay sits between them.

type Peer

type Peer struct {
	ID          NodeID
	Fingerprint string
	Topic       string
	State       State
	Present     bool // the other seat is occupied on the relay
}

A Peer is the other end of a channel as one side sees it.

type PublicKey

type PublicKey [32]byte

A PublicKey is a node's X25519 static public key — the identity the Noise handshake authenticates. A node cannot forge another's.

type Relay

type Relay interface {
	// Open registers a new channel and returns the opener's seat. expect is the
	// [JoinToken] the relay will require of the joiner.
	Open(ctx context.Context, ch ChannelID, expect JoinToken) (Membership, error)

	// Join claims the second seat by presenting the [JoinToken] derived from the
	// invite [Secret]. The relay compares it in constant time and never learns
	// the [Secret] itself.
	Join(ctx context.Context, ch ChannelID, present JoinToken) (Membership, error)

	// Send forwards one frame to the channel's other member.
	Send(ctx context.Context, m Membership, f Frame) error

	// Recv returns frames for the caller after the given seq, waiting up to wait
	// for at least one. A wait of zero returns immediately with whatever is
	// buffered, possibly nothing.
	Recv(ctx context.Context, m Membership, after uint64, wait time.Duration) ([]Frame, error)

	// Members reports how many of the channel's two seats are occupied, so a
	// caller can tell "no one has joined" from "joined, handshake pending."
	Members(ctx context.Context, m Membership) (int, error)
}

A Relay is parley's untrusted broker: it admits two members to a channel, forwards frames between them, and learns nothing else. It holds no keys and sees no plaintext, and a single relay is the only network service the two agents share. A channel seats exactly two; the relay refuses a third.

type Secret

type Secret [32]byte

A Secret is the one-time value minted with an Invite. It is the joiner's proof that it holds the link, and it never reaches the relay in the clear: domain-separated derivation splits it into a Secret.PSK known only to the two endpoints and a Secret.JoinToken shown to the relay.

func NewSecret

func NewSecret() (Secret, error)

NewSecret mints a fresh invite secret.

func (Secret) JoinToken

func (s Secret) JoinToken() JoinToken

JoinToken derives the token the relay checks to admit the joiner.

func (Secret) PSK

func (s Secret) PSK() []byte

PSK is the pre-shared key mixed into the Noise handshake. Only the two endpoints, holding the Secret, can derive it.

type Session

type Session interface {
	// Open starts a channel and returns the invite a human carries to the peer.
	Open(ctx context.Context, topic string) (Invite, error)

	// Join accepts an invite. It returns once the joiner's first handshake
	// message is sent; the handshake finishes on the first Poll.
	Join(ctx context.Context, in Invite) error

	// Send posts one message to the peer and returns immediately. If the channel
	// is not yet live (the peer has not finished joining), the message is queued
	// and delivered as soon as it is.
	Send(ctx context.Context, text string) error

	// Poll returns the messages that arrived since the last call, completing the
	// handshake transparently. It waits up to wait for the first message and then
	// drains the rest; a wait of zero is fully non-blocking. Returned messages
	// are untrusted external input.
	Poll(ctx context.Context, wait time.Duration) ([]Message, error)

	// Close ends the channel with an outcome the peer also observes.
	Close(ctx context.Context, outcome string) error

	// Peer reports the other node's pinned identity and the channel's state as of
	// the last Poll.
	Peer() Peer
}

A Session is one agent's end of one channel and the surface an MCP server turns into tools.

It is non-blocking by design. Send posts a message and returns at once; Poll collects whatever has arrived, waiting at most the duration given. The two agents therefore move at independent tempos — neither is frozen in a tool call while the other's owner deliberates — which is what makes the protocol pleasant in any MCP host, not only one with its own event loop.

type State

type State uint8

State is where a channel is in its life.

const (
	Pending State = iota + 1 // opened or joined, handshake not yet complete
	Active                   // handshake complete, the two are talking
	Closed                   // ended
)

type Transport

type Transport interface {
	Seal(seq uint64, plaintext []byte) (ciphertext []byte, err error)
	Open(seq uint64, ciphertext []byte) (plaintext []byte, err error)
}

A Transport is the forward-secret channel a completed handshake produces. Each direction has its own key; Seal and Open are inverse. seq must strictly increase per direction and binds the nonce, so the same seq is never sealed twice under one key.

Directories

Path Synopsis
Package noise implements parley's handshake and transport with the Noise Protocol Framework, via github.com/flynn/noise.
Package noise implements parley's handshake and transport with the Noise Protocol Framework, via github.com/flynn/noise.
Package relayhttp is parley's reference relay: an HTTP Server that stores and forwards frames, and a Client that implements parley.Relay against it.
Package relayhttp is parley's reference relay: an HTTP Server that stores and forwards frames, and a Client that implements parley.Relay against it.
Package session is parley's reference client engine.
Package session is parley's reference client engine.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL