aboutsummaryrefslogtreecommitdiffstats

Axon Protocol — High Level Design

A Nostr-inspired event relay protocol for AI agent infrastructure. Retains the core architectural insight — signed events, relay as message bus, filtered subscriptions — while cleaning up the crypto, encoding, and type system.


Core Insight

The relay is an append-only log with cryptographic identity. It routes signed events between clients and stores them for replay. It is structurally incapable of understanding content it was not designed to index.


Architecture

[client A] ──publish──▶ [relay] ──fanout──▶ [client B]
                           
                        [index]   ← id, pubkey, kind, created_at, tags
                        [store]   ← raw msgpack bytes (opaque)

Consumers (agents, indexers, report jobs) subscribe to the relay and maintain their own materialized views. The relay never aggregates, summarizes, or transforms content. Derived data is always downstream.


Event Structure

Event {
  id         bytes   // 32 bytes, SHA256 of canonical signing payload
  pubkey     bytes   // 32 bytes, Ed25519 public key
  created_at int64   // unix timestamp
  kind       uint16  // see Event Kinds registry
  content    bytes   // opaque to the relay; msgpack bin type, no UTF-8 assumption
  sig        bytes   // 64 bytes, Ed25519 signature over id
  tags       []Tag
}

Tag {
  name   string
  values []string
}

Signing

The event ID is the SHA256 of a canonical byte payload. All integers are big-endian. All strings are UTF-8. || denotes concatenation.

id  = SHA256(canonical_payload)
sig = ed25519.Sign(privkey, id)

canonical_payload:

Field Encoding
pubkey uint16(32) || 32 bytes
created_at uint64
kind uint16
content uint32(len) || UTF-8 bytes
tags see below

canonical_tags:

Tags are sorted by name lexicographically (byte order). For ties on name, sort by first value lexicographically. Two tags sharing the same name and same first value is a protocol error — the relay must reject the event with 400. Tags are effectively keyed on (name, first value); duplicates are a bug or an attack.

uint16(num_tags)
for each tag (in sorted order):
  uint16(len(name)) || utf8(name)
  uint16(num_values)
  for each value:
    uint32(len(value)) || utf8(value)

The tags field in canonical_payload is SHA256(canonical_tags) — a fixed 32-byte commitment regardless of tag count. Implementations may cache this hash to avoid re-sorting on repeated signature verification.

Full canonical_payload byte layout:

[0:2]     uint16 = 32    pubkey length  always 32 for Ed25519; validate and reject if not 32;
                         reserved for future key types
[2:34]    bytes          pubkey
[34:42]   uint64         created_at
[42:44]   uint16         kind
[44:48]   uint32         content length  wire format supports up to ~4GB but relay enforces
                         a maximum of 65536 bytes (64KB); larger events are rejected with 413
[48:48+n] bytes          content (n bytes, n  65536)
[48+n:80+n] bytes        SHA256(canonical_tags), 32 bytes

Two implementations that agree on this layout will always produce the same id for the same event.


Crypto

Purpose Algorithm Go package
Signing Ed25519 crypto/ed25519 (stdlib)
Key exchange X25519 golang.org/x/crypto/curve25519
Key derivation HKDF-SHA256 golang.org/x/crypto/hkdf
Encryption ChaCha20-Poly1305 golang.org/x/crypto/chacha20poly1305
Hashing / event ID SHA-256 crypto/sha256 (stdlib)

All dependencies are from the Go standard library or golang.org/x/crypto. No third-party crypto. Ed25519 keys are converted to X25519 for ECDH — one keypair serves both signing and encryption. The raw X25519 shared secret is passed through HKDF-SHA256 (info: "axon-dm-v1") to derive the symmetric encryption key. ChaCha20-Poly1305 provides authenticated encryption (AEAD) with the sender and recipient public keys bound as associated data; the ciphertext cannot be tampered with or re-targeted without detection.


Wire Format

Transport: WebSocket (binary frames) Serialization: MessagePack

MessagePack is binary JSON — identical data model, no schema, no codegen. Binary fields (id, pubkey, sig) are raw bytes on the wire, eliminating base64 encoding and simplifying the signing story.

Connection Authentication

Authentication happens immediately on connect before any other messages are accepted.

relay   Challenge { nonce: bytes }               // 32 random bytes
client  Auth      { pubkey: bytes, sig: bytes }
relay   Ok        { message: string }            // or Error then close

The client signs over nonce || relay_url to prevent replay to a different relay:

sig = ed25519.Sign(privkey, SHA256(nonce || utf8(relay_url)))

The relay verifies the signature then checks the pubkey against its allowlist. Failures return Error { code: 401 } and close the connection.

Allowlist: the relay maintains a set of authorized pubkeys in config or the local database. Publish and subscribe are both gated on allowlist membership. Adding a user means adding their pubkey — no passwords, no tokens, no certificate infrastructure.

Client → Relay

Auth        { pubkey: bytes, sig: bytes }
Subscribe   { sub_id: string, filter: Filter }
Unsubscribe { sub_id: string }
Publish     { event: Event }

Relay → Client

Challenge     { nonce: bytes }
EventEnvelope { sub_id: string, event: Event }
Eose          { sub_id: string }
Ok            { message: string }
Error         { code: uint16, message: string }

Each message is a msgpack array: [message_type, payload] where message_type is a uint16.

Error Codes

HTTP status codes, reused for familiarity.

Code Meaning
400 Bad request (malformed message, invalid signature)
401 Not authenticated
403 Not authorized (pubkey not in allowlist)
409 Duplicate event
413 Message too large

The relay sends Error and keeps the connection open for recoverable conditions (e.g. a bad publish). For unrecoverable conditions (e.g. auth failure) it sends Error then closes.

Keepalive

The relay sends a WebSocket ping every 30 seconds. Clients must respond with a pong. Connections that miss two consecutive pings (60 seconds) are closed. Clients may also send pings; the relay will pong.


Filters

Filter {
  ids       []bytes   // match by event id
  authors   []bytes   // match by pubkey
  kinds     []uint16  // match by event kind
  since     int64
  until     int64
  limit     int32
  tags      []TagFilter
}

TagFilter {
  name   string
  values []string   // match any
}

Relay Internals

The relay unmarshals only what it needs for indexing and routing. content is never parsed — it is opaque bytes as far as the relay is concerned.

On ingest: 1. Unmarshal the event envelope to extract index fields (id, pubkey, kind, created_at, tags) 2. Verify signature: recompute id, check ed25519.Verify(pubkey, id, sig) 3. Reject if id already exists — id PRIMARY KEY makes duplicate events impossible to store, and the fanout path checks an in-memory seen set before forwarding 4. Write index fields to the index tables 5. Write the verbatim msgpack envelope bytes to envelope_bytes — the entire event exactly as received, not re-serialized 6. Fanout to matching subscribers

On query/fanout: - Read envelope_bytes from store - Forward directly to subscribers — no unmarshal, no remarshal

Index schema (SQLite or Postgres):

CREATE TABLE events (
  id             BLOB PRIMARY KEY,
  pubkey         BLOB NOT NULL,
  created_at     INTEGER NOT NULL,
  kind           INTEGER NOT NULL,
  envelope_bytes BLOB NOT NULL  -- verbatim msgpack bytes of the full event, including content
);

CREATE TABLE tags (
  event_id BLOB REFERENCES events(id),
  name     TEXT NOT NULL,
  value    TEXT NOT NULL
);

CREATE INDEX ON events(pubkey);
CREATE INDEX ON events(kind);
CREATE INDEX ON events(created_at);
CREATE INDEX ON tags(name, value);

Event Kinds

Integer kinds with named constants. The integer is the wire format; the name is what appears in code and logs. Ranges enable efficient category queries without enumerating individual kinds.

Range Allocation

Range Category
0000 – 0999 Identity & meta
1000 – 1999 Messaging
2000 – 2999 Encrypted messaging
3000 – 3999 Presence & ephemeral
4000 – 4999 Reserved
5000 – 5999 Job requests
6000 – 6999 Job results
7000 – 7999 Job feedback
8000 – 8999 System / relay
9000 – 9999 Reserved

Defined Kinds

Constant Kind Description
KindProfile 0 Identity metadata
KindMessage 1000 Plain text note
KindDM 2000 Encrypted direct message
KindProgress 3000 Ephemeral progress/status indicator (thinking, agent steps, job status)
KindJobRequest 5000 Request for agent work
KindJobFeedback 7000 In-progress status / error
KindJobResult 6000 Completed job output

Range Queries

-- all job-related events
WHERE kind >= 5000 AND kind < 8000

-- ephemeral events (relay does not persist)
WHERE kind >= 3000 AND kind < 4000

Ephemeral events (kind 3000–3999) are fanned out to subscribers but never written to the store.


Threading

Conversations use explicit e tags with mandatory role markers:

Tag{ name: "e", values: ["<event-id>", "root"] }
Tag{ name: "e", values: ["<event-id>", "reply"] }

Root marker is required on all replies. No fallback heuristics.


Direct Messages

KindDM (2000) events carry ChaCha20-Poly1305 encrypted content. The recipient is identified by a p tag carrying their pubkey:

Tag{ name: "p", values: ["<recipient-pubkey>"] }

Encryption details:

  1. Compute the X25519 shared secret from the sender's private key and recipient's public key
  2. Derive a 32-byte symmetric key via HKDF-SHA256 (salt: nil, info: "axon-dm-v1")
  3. Generate a 12-byte random nonce
  4. Encrypt with ChaCha20-Poly1305 using associated data = sender_pubkey || recipient_pubkey
  5. Wire format of content field: nonce (12 bytes) || ciphertext

The associated data binds the ciphertext to both parties, preventing key-confusion attacks where an attacker re-targets a ciphertext to a different recipient.

The relay indexes the p tag to route DMs to the recipient's subscription. Content is opaque; the relay cannot decrypt it.


Job Protocol

Any client can publish a KindJobRequest; any agent subscribed to the relay can fulfill it. The flow:

KindJobRequest  (5000)  { kind: 5000, content: "<prompt>", tags: [["t", "<job-type>"]] }
KindJobFeedback (7000)  { kind: 7000, content: "<status>", tags: [["e", "<request-id>"]] }
KindJobResult   (6000)  { kind: 6000, content: "<output>",  tags: [["e", "<request-id>"]] }

Multiple agents can compete to fulfill the same request. The requester can target a specific agent with a p tag.

Expiry: job requests may include an expires_at tag carrying a unix timestamp. Agents must check this before starting work and skip expired requests. The relay does not enforce expiry — it is agent-side policy.

Tag{ name: "expires_at", values: ["<unix timestamp>"] }

Consumers

The relay is the log. Anything requiring derived data subscribes and maintains its own view:

  • Search indexer — subscribes to all events, feeds full-text index
  • Daily report — subscribes to past 24h, generates summary via agent
  • Metrics collector — counts event types, feeds dashboard
  • Conversation summarizer — subscribes to completed threads

Each consumer is independent and can rebuild from relay replay on restart.

Resumption: consumers track their own position by storing the created_at of the last processed event and resuming with a since filter on restart. Use event id to deduplicate any overlap at the boundary.


Threat Model

DM metadata: KindDM content is encrypted and opaque to the relay, but sender pubkey and recipient p tag are stored in plaintext. The relay operator can see who is talking to whom and when. Content is private; the social graph is not.


What This Is Not

  • Not a database. Don't query it like one.
  • Not a general message queue. It has no consumer groups or offset tracking — consumers manage their own position.
  • Not decentralized. Single relay, single operator. Multi-relay federation is out of scope.