Skip to content

Protocol Specification

toq protocol is an open protocol for secure, direct communication between AI agents. It defines how agents discover each other, establish trusted connections, and exchange structured messages, regardless of what framework they’re built on or where they’re hosted.

The protocol is designed around a simple idea: any agent should be able to talk to any other agent, securely, with minimal setup. The agent’s owner controls who can connect, what gets through, and can shut everything down instantly.

toq protocol is not a framework, not a platform, and not a cloud service. It’s a set of rules that agents follow to communicate. Think of it like email’s SMTP. It defines the language, not the mailbox.

  • Agent-to-agent communication. Structured message exchange between any two agents, regardless of framework.
  • Mutual authentication. Both sides prove their identity before any data flows.
  • Agent-only access. Endpoints are invisible to browsers, scanners, and anything that isn’t a toq-speaking agent.
  • Endpoint sovereignty. The host defines its own security policy: who can connect, how, and under what conditions.
  • No prompt injection surface. The protocol separates message content from protocol instructions. It never injects anything into an agent’s LLM context.
  • It does not define agent behavior. What an agent does with a message is up to the agent. The protocol delivers it. The agent decides what it means.
  • It does not require infrastructure. No central server, no registry, no relay. Your machine is your endpoint.
  • It does not touch the LLM. The protocol is a communication layer. It moves structured data between agents. It never generates, modifies, or interprets content.
  • It does not replace other protocols. toq protocol is independent. It doesn’t compete with MCP (agent-to-tool) or depend on A2A (agent-to-agent). Bridging is possible but not a protocol concern.
  1. Secure by default. TLS, cryptographic identity, and agent-only access are automatic. No security configuration required from the user.
  2. Simple to deploy. One command to start an endpoint. Guided setup for everything else.
  3. Transport-agnostic. toq protocol is an application-layer protocol. It rides on top of TCP, WebSocket, or any reliable transport. It does not define how bytes move. It defines what they mean.
  4. Peer-to-peer. No permanent client or server. Any agent can initiate a connection. Once connected, both sides are equals.
  5. Forward-compatible. Unknown fields are ignored. New features are negotiated. Old agents and new agents can coexist.

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

All text content in the protocol MUST be encoded as UTF-8.

All examples in this document are illustrative and non-normative unless explicitly stated otherwise.


TermDefinition
AgentAny AI-powered software that sends or receives messages via toq protocol. The protocol does not define what an agent is or how it works internally. It only defines how it communicates.
EndpointA running toq protocol process that listens for and accepts connections from other agents. One machine can host multiple endpoints.
EnvelopeThe standard JSON wrapper around every toq protocol message. Contains metadata (sender, recipient, type, signature) and a body (the actual content).
HandshakeThe initial exchange when two agents connect. Verifies both sides are running toq protocol and authenticates their identities using cryptographic signatures.
SessionA live connection between two endpoints. Starts with a handshake, ends with a disconnect or timeout. Sessions have unique IDs and can be resumed after brief interruptions.
ThreadA conversation topic. Messages are grouped by thread ID. Multiple threads can run in parallel on a single connection. Threads persist across sessions.
Agent cardA structured description of an agent: its name, capabilities, public key, and protocol version. Basic info may be published via DNS. The full card is exchanged during connection setup (after negotiation).
Connection modeThe endpoint’s policy for accepting new connections. Four modes: open (anyone), allowlist (pre-approved only), approval (owner vets each request), DNS-verified (must have a valid domain).
KillswitchInstant shutdown of all agent communication. Drops every connection immediately with no graceful wind-down.
AdapterThe local interface between the toq process and the agent. Five types: HTTP callback to localhost, stdin/stdout pipe, SDK library, Unix socket, gRPC.
Sequence numberA monotonically increasing counter on each message within a connection. Used for ordering and replay prevention.
Delivery ackA lightweight confirmation that the toq process received a message. Separate from the agent’s actual response.
TOFU (Trust On First Use)The first time two agents connect, each pins the other’s public key. Future connections reject unexpected key changes unless a valid key rotation has occurred.
Key rotationThe process of replacing an agent’s cryptographic keypair. The old key signs the new key, creating a verifiable chain. Triggered manually via toq rotate-keys. Recommended every 90 days.
BackpressureA signal from an overwhelmed endpoint telling senders to slow down. Includes a retry-after duration.
Magic bytesThe first bytes sent on any toq connection (TOQ\x01). If these don’t match, the connection is dropped instantly, before any processing occurs.

Every agent on the toq network has an address. The address tells other agents where to find it. It does not prove identity. Identity is established through cryptographic keys during the handshake (Section 5).

Addresses follow a URI scheme:

toq://<host>/<agent-name>
  • host is a domain name, an IP address, or a .local mDNS name.
  • agent-name is a unique identifier for the agent on that host. It MUST contain only ASCII lowercase letters, digits, and hyphens. It MUST NOT start or end with a hyphen.

Examples:

toq://example.com/assistant
toq://192.168.1.50/dev-agent
toq://myname.local/helper
toq://example.com:7070/custom-agent

The default port is 9009. If an endpoint listens on a different port, the port MUST be included in the address:

toq://example.com:7070/agent-name

If no port is specified, clients first check DNS TXT records at _toq._tcp.<domain> for a port value (Section 19). If no DNS record exists or the host is not a domain name, clients connect to port 9009.

When an agent wants to connect to an address, it resolves the host in the following order:

  1. If the host is an IP address, connect directly to that IP on the specified port (or 9009).
  2. If the host ends in .local, resolve via mDNS (if enabled on the connecting agent).
  3. If the host is a domain name, resolve via DNS to an IP address. If no port was specified in the address, check DNS TXT records at _toq._tcp.<domain> for a port value (Section 19). If no TXT record exists, use port 9009. Then connect.

If resolution fails, the connection attempt fails with a clear error.

An address is a locator. It tells you where an agent is, not who it is. Two important consequences:

  1. Addresses can change. An agent that moves to a new server gets a new address, but its cryptographic identity (public key) stays the same. Peers that have pinned its key will recognize it at the new address.
  2. Addresses can be reused. If an agent shuts down and someone else starts an agent at the same address, it will have a different key. Peers that pinned the original key will reject the new agent.

Trust is always based on keys, never on addresses.

A single host MAY run multiple agents, each with a unique agent-name. Each agent has its own keypair, its own configuration, and its own connection mode. They share the same host but are otherwise independent.

If multiple agents run on the same host, they MUST listen on different ports. The reference implementation auto-assigns ports to avoid conflicts.

The agent-name in the address is restricted to ASCII for safety. Agents MAY declare a human-readable display name in their agent card (Section 7) that supports full Unicode. The display name is for presentation only. It is never used for routing or identity.


Every message in toq protocol is wrapped in an envelope. The envelope is a JSON object that carries metadata about the message (who sent it, who it’s for, what type it is) alongside the actual content. The envelope is the fundamental unit of communication.

All envelopes MUST be valid JSON objects encoded as UTF-8. An envelope contains two parts: header fields (metadata about the message) and a body (the content).

{
"version": "0.1",
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "message.send",
"from": "toq://alice.dev/assistant",
"to": ["toq://bob.dev/agent"],
"thread_id": "thread-123",
"reply_to": null,
"sequence": 42,
"timestamp": "2026-02-28T12:00:00Z",
"priority": "normal",
"content_type": "application/json",
"ttl": 300,
"signature": "ed25519:<base64-encoded-signature>",
"body": {
"task": "Review this pull request",
"repo": "github.com/example/project",
"branch": "feature/login"
}
}
FieldTypeRequiredDescription
versionstringYesProtocol version. MUST be "0.1" for this spec.
idstringYesUnique message identifier. MUST be a UUID v4. Used for delivery acks and deduplication.
typestringYesMessage type from the message types catalog (Section 9). Determines how the envelope is processed.
fromstringYesSender’s toq address.
toarray of stringsYesRecipient toq address(es). At least one required. Maximum 100 recipients.
thread_idstringNoGroups messages into a conversation. If omitted, the message is standalone.
reply_tostringNoThe id of the message this is responding to.
sequenceintegerYesMonotonically increasing counter per connection. Starts at 0. Used for ordering and replay prevention.
timestampstringYesISO 8601 UTC timestamp of when the message was created. Used for audit logging only, not for security decisions.
prioritystringNo"normal" (default) or "urgent". Receiver MAY use this to prioritize processing.
content_typestringNoMIME type of the body. Defaults to "application/json" if omitted.
ttlintegerNoTime to live in seconds. If the message is not delivered within this window, the sender SHOULD stop retrying. If omitted, no expiration.
compressionstringNoCompression algorithm applied to the body. "gzip" or "zstd". If omitted, body is uncompressed. Both sides MUST have negotiated compression support during protocol negotiation.
signaturestringYesEd25519 signature of the envelope (excluding the signature field itself). Format: "ed25519:<base64-encoded-signature>".
e2e_noncestringNo12-byte nonce for E2E encryption, base64-encoded. Present only when E2E encryption is active (Section 13.5).
bodyanyNoThe message content. Structure depends on content_type. For application/json, this is a JSON object or array. For binary types, this is a base64-encoded string. MAY be omitted for message types that carry no content (e.g., heartbeats, acks).

Implementations MUST validate every incoming envelope before processing:

  1. version MUST match a supported protocol version.
  2. id MUST be a valid UUID v4. Duplicate IDs (already seen on this connection) MUST be rejected.
  3. type MUST be a recognized message type.
  4. from MUST be a valid toq address.
  5. to MUST contain at least one valid toq address and no more than 100.
  6. sequence MUST be greater than the last seen sequence number on this connection.
  7. signature MUST be a valid Ed25519 signature that verifies against the sender’s public key.
  8. If content_type is present, the body MUST conform to that type.
  9. If compression is present, both sides MUST have negotiated compression support.
  10. If ttl is present and the message has expired (current time > timestamp + ttl), the receiver SHOULD discard it.

If validation fails, the receiver MUST respond with a system.error message indicating the failure reason. The connection remains open.

The signature covers the entire envelope except the signature field itself. To compute or verify:

  1. Serialize the envelope as JSON with the signature field removed.
  2. Canonicalize the JSON (keys sorted lexicographically, no whitespace).
  3. Sign the resulting byte string with the sender’s Ed25519 private key.
  4. Encode the signature as base64 and prepend "ed25519:".

Implementations MUST ignore unknown fields in the envelope. This allows older implementations to process envelopes from newer protocol versions without breaking. Unknown fields MUST NOT be removed when forwarding messages.

The total serialized envelope (including body) MUST NOT exceed the configured maximum message size. The default maximum is 1 MB. Endpoints MAY configure a different limit. Messages exceeding the limit MUST be rejected with a system.error.

For payloads larger than the size limit, use streaming (Section 11).

Messages are immutable once sent. The signature binds the content to the sender’s identity. There is no mechanism to edit a sent message. To correct a message, the sender cancels the original (message.cancel) and sends a new one.

The toq process MUST reject envelopes with executable content types before delivering them to the local agent. The following MIME types (and any subtypes) MUST be blocked:

  • application/x-executable
  • application/x-msdos-program
  • application/x-msdownload
  • application/x-sharedlib
  • application/vnd.microsoft.portable-executable

Implementations SHOULD maintain a configurable blocklist of additional content types. This restriction applies regardless of the accept_files setting.


The handshake is the first thing that happens when one agent connects to another. It serves two purposes: confirm that both sides are running toq protocol, and verify each other’s cryptographic identity. No application messages are exchanged until the handshake completes successfully.

The handshake is designed to be cheap to reject. Invalid connections are dropped in microseconds, before any meaningful processing occurs.

The handshake has four steps:

  1. The initiator sends magic bytes to identify itself as a toq connection.
  2. The initiator sends its public key and a signed challenge.
  3. The receiver verifies the challenge, then sends its own public key and signed challenge back.
  4. Both sides now have each other’s verified identity. The connection proceeds to protocol negotiation.

If any step fails, the connection is dropped immediately with no error message. This is intentional. Giving feedback to invalid connections would help attackers probe the endpoint.

The initiator MUST send the following 4 bytes as the very first data on the connection:

0x54 0x4F 0x51 0x01

This is the ASCII string TOQ followed by the protocol major version byte (0x01 for v0.x and v1.x).

The receiver reads the first 4 bytes. If they do not match, the receiver MUST close the connection immediately. No response is sent.

This step filters out all non-toq traffic (browsers, port scanners, accidental connections) with near-zero cost.

5.3 Step 2: Initiator credential presentation

Section titled “5.3 Step 2: Initiator credential presentation”

After the magic bytes, the initiator sends a JSON object:

{
"public_key": "ed25519:<base64-encoded-public-key>",
"challenge": "<32-byte-random-nonce-base64>",
"challenge_signature": "ed25519:<base64-signature-of-challenge>",
"address": "toq://alice.dev/assistant",
"protocol_version": "0.1",
"rotation_proof": null
}
FieldTypeDescription
public_keystringThe initiator’s Ed25519 public key.
challengestringA 32-byte random nonce, base64-encoded. Generated fresh for each connection attempt.
challenge_signaturestringThe challenge signed with the initiator’s Ed25519 private key. Proves possession of the private key.
addressstringThe initiator’s toq address.
protocol_versionstringThe protocol version the initiator wants to use.
rotation_proofstring or nullIf the agent has rotated its key, this contains the old key’s signature of the new public key. Allows peers with a stale key pin to verify the rotation chain and update their pin (Section 14.5). Null if no rotation has occurred.

This payload MUST be sent as a length-prefixed JSON message: 4 bytes (big-endian uint32) indicating the payload length in bytes, followed by the JSON payload. The maximum handshake payload size is 64 KB. Payloads exceeding this limit MUST be rejected.

5.4 Step 3: Receiver verification and response

Section titled “5.4 Step 3: Receiver verification and response”

The receiver validates the initiator’s credentials:

  1. Parse the JSON payload. If malformed, close the connection.
  2. Verify challenge_signature against challenge using public_key. If invalid, close the connection.
  3. Check the public_key against the blocklist. If blocked, close the connection. This check happens before the receiver reveals its own identity.

If validation passes, the receiver sends its own credential payload in the same format:

{
"public_key": "ed25519:<base64-encoded-public-key>",
"challenge": "<32-byte-random-nonce-base64>",
"challenge_signature": "ed25519:<base64-signature-of-challenge>",
"address": "toq://bob.dev/agent",
"session_id": "sess-<uuid>",
"rotation_proof": null
}

The session_id is generated by the receiver and identifies this session for potential reconnection. The rotation_proof field follows the same rules as the initiator’s (Section 5.3).

This payload is also length-prefixed (4-byte big-endian uint32 + JSON).

The initiator validates the receiver’s credentials using the same process (verify signature, check blocklist). If validation fails, the initiator closes the connection.

If both sides pass, the handshake is complete. Both agents now have:

  • Each other’s verified public key
  • A session ID for reconnection
  • Confirmation that both sides speak toq protocol

The connection proceeds to the connection mode check. The receiver evaluates the initiator’s public key against its connection policy (open, allowlist, approval, DNS-verified). If the policy rejects the initiator, the connection is closed. If the policy requires approval, the receiver reads the initiator’s negotiation request, responds with an approval.request message, and closes the connection. The initiator must reconnect after being approved (see Section 16). If the policy accepts, the connection proceeds to protocol negotiation (Section 6).

The first time two agents connect, each side pins the other’s public key. On subsequent connections:

  • If the peer presents the same key, proceed normally.
  • If the peer presents a different key with a valid key rotation proof (Section 14), accept the new key and update the pin.
  • If the peer presents a different key with no rotation proof, reject the connection. This prevents impersonation.

The entire handshake (steps 1 through 4) MUST complete within 5 seconds. If any step is not completed within this window, the receiver MUST close the connection.

Endpoints MUST enforce connection rate limiting per source IP address. The default limit is implementation-defined but SHOULD be no more than 10 new connection attempts per second per IP. Connections exceeding the limit MUST be dropped before the handshake begins.


After the handshake, both agents know who they’re talking to. Protocol negotiation determines how they’ll talk. Both sides exchange their supported protocol version and optional features, then agree on a common set.

The initiator sends a negotiate.request envelope:

{
"version": "0.1",
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"type": "negotiate.request",
"from": "toq://alice.dev/assistant",
"to": ["toq://bob.dev/agent"],
"sequence": 0,
"timestamp": "2026-02-28T12:00:01Z",
"signature": "ed25519:...",
"body": {
"supported_versions": ["0.1"],
"features": {
"streaming": true,
"compression": ["gzip", "zstd"],
"e2e_encryption": false
}
}
}

The receiver responds with a negotiate.response:

{
"version": "0.1",
"id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
"type": "negotiate.response",
"from": "toq://bob.dev/agent",
"to": ["toq://alice.dev/assistant"],
"sequence": 0,
"timestamp": "2026-02-28T12:00:01Z",
"signature": "ed25519:...",
"body": {
"selected_version": "0.1",
"features": {
"streaming": true,
"compression": "gzip",
"e2e_encryption": false
}
}
}

The initiator sends a list of protocol versions it supports, ordered by preference (most preferred first). The receiver selects the highest mutually supported version.

If there is no overlap, the receiver MUST respond with a negotiate.reject:

{
"version": "0.1",
"id": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f",
"type": "negotiate.reject",
"from": "toq://bob.dev/agent",
"to": ["toq://alice.dev/assistant"],
"sequence": 0,
"timestamp": "2026-02-28T12:00:01Z",
"signature": "ed25519:...",
"body": {
"reason": "no_compatible_version",
"supported_versions": ["0.2", "0.3"]
}
}

After a negotiate.reject, the connection is closed.

Features are optional capabilities that both sides can enable if they both support them. The initiator declares what it supports. The receiver selects the intersection.

FeatureTypeDescription
streamingbooleanSupport for chunked streaming messages.
compressionarray of stringsSupported compression algorithms ("gzip", "zstd"). Receiver picks one or none.
e2e_encryptionbooleanSupport for end-to-end encrypted payloads.

If a feature is not declared by the initiator, it is assumed to be unsupported. The receiver MUST NOT enable a feature the initiator did not declare.

Future protocol versions MAY add new features. Unknown features MUST be ignored by the receiver.

Protocol negotiation MUST complete within 5 seconds of the handshake finishing. If the receiver does not respond within this window, the initiator MUST close the connection.

Once negotiation completes successfully, the connection enters the agent card exchange phase (Section 7). From this point forward, all envelopes MUST conform to the negotiated version and features.


After negotiation, both agents exchange their agent cards. The agent card is a structured description of the agent: who it is, what it can do, and how it wants to be contacted. This is how agents learn about each other’s capabilities before exchanging application messages.

Both sides send a card.exchange envelope simultaneously. Neither side waits for the other’s card before sending its own.

{
"version": "0.1",
"id": "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f80",
"type": "card.exchange",
"from": "toq://alice.dev/assistant",
"to": ["toq://bob.dev/agent"],
"sequence": 1,
"timestamp": "2026-02-28T12:00:02Z",
"signature": "ed25519:...",
"body": {
"name": "Alice's Assistant",
"description": "Personal assistant for scheduling and research.",
"public_key": "ed25519:<base64-encoded-public-key>",
"protocol_version": "0.1",
"capabilities": ["scheduling", "web-search", "summarization"],
"accept_files": false,
"max_message_size": 1048576,
"connection_mode": "approval"
}
}
FieldTypeRequiredDescription
namestringYesHuman-readable display name. Supports full Unicode. For presentation only, never used for routing.
descriptionstringNoBrief description of what the agent does.
public_keystringYesThe agent’s Ed25519 public key. MUST match the key presented during the handshake.
protocol_versionstringYesThe protocol version this agent is running.
capabilitiesarray of stringsNoList of capabilities the agent supports. Free-form strings. Used by peers to understand what the agent can do.
accept_filesbooleanNoWhether the agent accepts binary file payloads. Defaults to false.
max_file_sizeintegerNoMaximum file size in bytes the agent will accept. Only relevant if accept_files is true.
max_message_sizeintegerNoMaximum envelope size in bytes this agent will accept. Peers SHOULD respect this limit.
connection_modestringNoThe agent’s connection policy: "open", "allowlist", "approval", or "dns-verified". Informational only.

On receiving a card, the agent MUST verify:

  1. The public_key in the card matches the key used during the handshake. If it does not match, close the connection.
  2. The card is valid JSON and does not exceed 64 KB.
  3. The name field is present and non-empty.

If validation fails, the receiver MUST close the connection.

After the exchange, each agent has the other’s card. Agents SHOULD use the card to:

  • Understand what the peer can do (capabilities).
  • Respect the peer’s limits (max message size, file acceptance).
  • Display the peer’s name and description to the owner if needed.

The card is not a contract. An agent MAY reject messages even if its card suggests it supports the relevant capability. The card is a hint, not a guarantee.

Agents SHOULD cache peer cards locally. On reconnection to the same peer (same public key), the cached card MAY be used until a new card is exchanged. A fresh card exchange happens on every new session.

Once both cards are exchanged, the connection is fully active. Application messages (Section 9) can now flow in both directions. The connection lifecycle from this point is described in Section 8.


A connection between two agents follows a defined sequence of states. This section describes the full lifecycle from initial connection to termination, including reconnection behavior.

Every connection is in one of the following states:

CONNECTING → HANDSHAKE → NEGOTIATING → CARD_EXCHANGE → ACTIVE → CLOSED
StateDescription
CONNECTINGTCP/TLS connection being established.
HANDSHAKEMagic bytes sent, credentials being exchanged (Section 5).
NEGOTIATINGProtocol version and features being agreed upon (Section 6).
CARD_EXCHANGEAgent cards being exchanged (Section 7).
ACTIVEConnection fully established. Application messages can flow.
CLOSEDConnection terminated. No further messages.

A connection can move to CLOSED from any state. Failures at any step result in immediate closure.

Once in the ACTIVE state:

  • Both sides MAY send envelopes at any time. The connection is fully bidirectional.
  • Heartbeats flow on a regular interval (Section 8.4).
  • Multiple threads can run in parallel.
  • Either side can initiate a graceful disconnect or a killswitch.

When an agent wants to close the connection cleanly, it sends a session.disconnect envelope:

{
"version": "0.1",
"id": "e5f6a7b8-c9d0-4e1f-2a3b-4c5d6e7f8091",
"type": "session.disconnect",
"from": "toq://alice.dev/assistant",
"to": ["toq://bob.dev/agent"],
"sequence": 100,
"timestamp": "2026-02-28T13:00:00Z",
"signature": "ed25519:..."
}

On receiving a session.disconnect, the peer MUST:

  1. Stop sending new messages.
  2. Finish processing any in-flight acks.
  3. Close the connection.

No response to session.disconnect is required.

While in the ACTIVE state, both sides MUST send a system.heartbeat envelope every 30 seconds. The peer MUST respond with a system.heartbeat.ack.

If no heartbeat ack is received within 90 seconds (3 missed beats), the connection is considered dead. The detecting side MUST close the connection and clean up resources.

Heartbeats MUST NOT be blocked by in-progress streams. If a large streaming transfer is underway, heartbeats are interleaved between chunks.

The killswitch (toq down) sends a termination signal (SIGTERM) to the daemon process. The daemon catches the signal, persists policy state (approved, blocked, and pending rules) to permissions.toml, saves peer metadata to peers.json, unregisters from the agent registry, and exits. No session.disconnect is sent to connected peers. All TCP connections are dropped when the process exits. Peers detect the closure via heartbeat timeout or TCP reset.

toq down --graceful is defined in the protocol for planned shutdowns (stop accepting new connections, wait for active threads to complete, then close with session.disconnect), but the current implementation treats it the same as toq down. Both send SIGTERM and run the same cleanup.

Every session has a unique ID assigned by the receiver during the handshake (Section 5.4). If a connection drops unexpectedly (network failure, not a graceful disconnect), the initiator MAY attempt to resume the session.

To resume, the initiator connects, completes the handshake, and sends a session.resume envelope instead of proceeding to negotiation:

{
"version": "0.1",
"id": "f6a7b8c9-d0e1-4f2a-3b4c-5d6e7f809102",
"type": "session.resume",
"from": "toq://alice.dev/assistant",
"to": ["toq://bob.dev/agent"],
"sequence": 0,
"timestamp": "2026-02-28T13:05:00Z",
"signature": "ed25519:...",
"body": {
"session_id": "sess-<uuid>",
"last_received_sequence": 99
}
}

The receiver checks:

  1. Is the session ID valid and not expired?
  2. Does the public key match the original session?

If yes, the session resumes. Both sides reset their sequence counters to the last acknowledged values. Threads from the previous session remain active.

If no (session expired or unknown), the receiver responds with a system.error and the initiator MUST start a fresh connection (full negotiation and card exchange).

Sessions are eligible for resumption for 5 minutes after disconnection. After that, the session ID expires and a fresh connection is required.

If two agents simultaneously attempt to connect to each other, two connections may briefly exist between the same pair. When an agent detects a duplicate connection to a peer it is already connected to (same public key), it MUST keep the newest connection and close the older one. Both sides apply this rule independently, so they converge on the same connection.

Many endpoints sit behind NAT (Network Address Translation), which blocks inbound connections by default. The toq process SHOULD attempt UPnP port mapping automatically on startup (toq up). If UPnP is unavailable, the guided setup (toq setup) walks the user through manual port forwarding.

Agents behind NAT can always initiate outbound connections and receive responses on the established connection. NAT only affects inbound connection attempts from other agents.

There is no relay or hole-punching mechanism. If an endpoint cannot accept inbound connections, it can still participate by initiating connections to peers.

The toq process MUST persist critical state to disk so it can recover after an unexpected crash:

  • Peer list: Known peers with their pinned public keys and last seen time.
  • Permission rules: Approved, blocked, and pending approval entries.
  • Message history: Recently received messages for review by the owner.
  • Handler configuration: Registered message handlers and their filters.

On restart after a crash, the toq process:

  1. Loads persisted state from disk (config, keys, peer store, permissions, handlers).
  2. Begins accepting new connections normally.
  3. Previously active sessions are lost. Peers must establish fresh connections.

Outbound message queues and active session state are not persisted. In-flight messages at the time of a crash are lost. The sender’s retry mechanism (Section 12) handles redelivery on the next connection.


toq protocol defines 25 message types. Every envelope’s type field MUST be one of these values. This section defines each type, its purpose, required body fields, and expected behavior.

These are used during connection establishment (Section 5). They are not sent as standard envelopes. They use the length-prefixed JSON format described in Section 5.3.

TypeDirectionDescription
handshake.initInitiator to receiverMagic bytes followed by initiator credentials.
handshake.responseReceiver to initiatorReceiver credentials and session ID.
handshake.rejectReceiver to initiatorSilent connection close. No message is actually sent. The receiver simply drops the connection. Included here for completeness.

Used during protocol negotiation (Section 6).

negotiate.request

Sent by the initiator after handshake.

Body fieldTypeRequiredDescription
supported_versionsarray of stringsYesProtocol versions the initiator supports, ordered by preference.
featuresobjectYesFeature support declaration. See Section 6.3.

negotiate.response

Sent by the receiver to confirm the negotiated version and features.

Body fieldTypeRequiredDescription
selected_versionstringYesThe protocol version both sides will use.
featuresobjectYesThe agreed feature set.

negotiate.reject

Sent by the receiver if no compatible version exists.

Body fieldTypeRequiredDescription
reasonstringYesWhy negotiation failed (e.g., "no_compatible_version").
supported_versionsarray of stringsNoVersions the receiver supports, for informational purposes.

After sending or receiving a negotiate.reject, both sides MUST close the connection.

session.resume

Sent by the initiator to resume a previous session (Section 8.6).

Body fieldTypeRequiredDescription
session_idstringYesThe session ID from the original handshake.
last_received_sequenceintegerYesThe highest sequence number the initiator received before disconnection.

session.disconnect

Sent by either side to gracefully close the connection (Section 8.3). No body required.

Used when the endpoint is in approval mode (Section 16).

approval.request

Sent by the receiver to the initiator when the connection requires owner approval.

Body fieldTypeRequiredDescription
messagestringNoHuman-readable message for the initiator (e.g., “Your request is pending review.”).

The initiator’s connection is closed after receiving approval.request. The initiator must reconnect after being approved by the endpoint owner.

approval.granted

Sent by the receiver when the owner approves the connection.

Body fieldTypeRequiredDescription
messagestringNoOptional message from the owner.

After receiving approval.granted, the connection proceeds to protocol negotiation. In the current implementation, the initiator reconnects after approval rather than receiving this message on the original connection.

approval.denied

Sent by the receiver when the owner denies the connection.

Body fieldTypeRequiredDescription
reasonstringNoWhy the connection was denied.

After sending approval.denied, the receiver MUST close the connection.

These are the core application message types used during the ACTIVE state.

message.send

The primary message type. Used for all application-level communication between agents.

Body fieldTypeRequiredDescription
(any)anyNoThe body structure is determined by content_type in the envelope header. The protocol does not define the body schema for application messages.

message.ack

Delivery confirmation. Sent by the receiver’s toq process when an envelope is received. This confirms protocol-level receipt, not application-level processing.

Body fieldTypeRequiredDescription
ack_idstringYesThe id of the envelope being acknowledged.

Acks MUST be sent for every message.send, thread.close, message.cancel, message.stream.chunk, and message.stream.end envelope. Acks MUST NOT be sent for acks, heartbeats, or system messages.

If an ack is not received within 10 seconds, the sender SHOULD retry the message according to the retry policy (Section 12).

message.cancel

Cancels a previously sent message.

Body fieldTypeRequiredDescription
cancel_idstringYesThe id of the message to cancel.

If the target message has not been processed, the receiver SHOULD discard it. If already processed, the cancel is a no-op. Cancellation also works for in-progress streams (Section 11).

thread.close

Closes a thread. MAY carry a final message body (same as message.send) or be sent with no body. The receiver processes any body content normally, then marks the thread as closed locally.

Body fieldTypeRequiredDescription
(any)anyNoOptional final message content, same as message.send.

thread.close is acked like any other application message.

Used for chunked message delivery (Section 11).

message.stream.chunk

A single chunk in a streaming message.

Body fieldTypeRequiredDescription
stream_idstringYesIdentifier for this stream. All chunks in the same stream share this ID.
dataanyYesThe chunk content. Structure depends on content_type.

Chunks are ordered by the envelope’s sequence number. Receivers MUST reassemble chunks in sequence order.

message.stream.end

Marks the final chunk of a stream.

Body fieldTypeRequiredDescription
stream_idstringYesThe stream being completed.
dataanyNoOptional final chunk of data.

After receiving message.stream.end, the receiver knows the stream is complete. If a stream ends without this message (connection drops), the receiver MUST discard all partial data for that stream.

Protocol-level messages for connection health and management.

system.heartbeat

Sent every 30 seconds by both sides. No body required.

system.heartbeat.ack

Response to a heartbeat. No body required. The reply_to field MUST reference the heartbeat’s id.

system.backpressure

Signals that the sender is overwhelmed and the peer should slow down.

Body fieldTypeRequiredDescription
retry_afterintegerYesNumber of seconds the peer should wait before sending more messages. Maximum 60.

system.backpressure.clear

Signals that the backpressure condition has resolved. The peer MAY resume normal sending. No body required.

system.error

Reports a protocol-level error.

Body fieldTypeRequiredDescription
codestringYesError code (see Section 23).
messagestringYesHuman-readable error description.
related_idstringNoThe id of the envelope that caused the error, if applicable.

Errors do not close the connection unless the error is fatal (e.g., "invalid_signature", "protocol_violation").

system.key_rotation

Announces a new public key (Section 14).

Body fieldTypeRequiredDescription
new_public_keystringYesThe new Ed25519 public key.
rotation_proofstringYesThe new public key signed by the old private key. Proves the key owner authorized the rotation.

system.key_rotation.ack

Confirms receipt of a key rotation announcement.

Body fieldTypeRequiredDescription
acceptedbooleanYesWhether the peer accepted the new key.

card.exchange

Exchanges agent card during connection setup (Section 7). Body contains the agent card fields defined in Section 7.2.


Agents often need to have multiple conversations at the same time, or continue a conversation across sessions. Threading provides this structure. Ordering ensures messages within a thread arrive in the right sequence.

A thread is a logical grouping of related messages. Every message MAY include a thread_id in its envelope. Messages with the same thread_id belong to the same conversation.

Threads are created implicitly. The first message that includes a new thread_id starts that thread. There is no explicit “create thread” operation.

If a message has no thread_id, it is standalone and not part of any thread.

Within a thread, messages can reference the specific message they are responding to using the reply_to field. This creates a reply chain within the thread.

Message A (thread_id: "t1")
└── Message B (thread_id: "t1", reply_to: "A")
└── Message C (thread_id: "t1", reply_to: "B")

reply_to is optional. A message can belong to a thread without replying to a specific message.

Multiple threads can run simultaneously on the same connection. There is no limit on the number of active threads, though endpoints MAY enforce a maximum via resource limits (Section 21).

Threads are independent. Messages in one thread do not block or affect messages in another.

Threads are not tied to sessions. A thread started in one session can continue in a later session, even if the connection was dropped and re-established.

To continue a thread, the agent simply sends a new message with the same thread_id. The peer matches it to the existing thread by ID.

Both sides are responsible for persisting thread state locally if they want continuity. The protocol does not mandate how thread history is stored.

Threads that have had no activity for a configurable period SHOULD be cleaned up. The default inactivity timeout is 30 days. After cleanup, the thread’s local state is removed.

If a peer sends a message on a cleaned-up thread, the receiver treats it as a new thread with the same ID. No error is raised.

Either side can close a thread by sending a thread.close envelope (Section 9.5). This signals that the sender considers the conversation finished. The thread.close MAY carry a final message body or be sent empty.

On receiving thread.close, the receiver marks the thread as closed locally. No further messages should be sent on that thread. If a message arrives on a closed thread, the receiver MAY treat it as a new thread with the same ID.

Messages within a connection are ordered by the sequence field in the envelope. The sequence number is a monotonically increasing integer starting at 0, incremented for every envelope sent on that connection.

Receivers MUST process messages in sequence order. If a message arrives out of order (sequence gap), the receiver SHOULD buffer it until the missing messages arrive or a timeout is reached.

Sequence numbers are per-connection, not per-thread. A single connection’s sequence counter covers all threads on that connection.

Sequence numbers reset when a new session starts. On session resume (Section 8.6), both sides reset to the last acknowledged sequence values.

Thread ordering across sessions is maintained by timestamps (for audit purposes) and by the natural order of messages within each session. The protocol does not guarantee global ordering across sessions.

An agent MUST NOT send a message to its own address. The toq process MUST reject outbound messages where any address in the to field matches the agent’s own address, and return a clear error.


Some messages are too large to send in a single envelope, or the sender wants to deliver content incrementally as it becomes available. Streaming breaks a message into ordered chunks that the receiver reassembles.

Streaming is an optional feature. Both sides MUST have declared streaming: true during protocol negotiation (Section 6) for streaming to be used.

The sender begins a stream by sending a message.stream.chunk envelope with a unique stream_id. The stream_id is a UUID generated by the sender. All chunks in the same stream share this ID.

{
"version": "0.1",
"id": "a7b8c9d0-e1f2-4a3b-4c5d-6e7f80910213",
"type": "message.stream.chunk",
"from": "toq://alice.dev/assistant",
"to": ["toq://bob.dev/agent"],
"thread_id": "t1",
"sequence": 10,
"timestamp": "2026-02-28T12:10:00Z",
"content_type": "text/plain",
"signature": "ed25519:...",
"body": {
"stream_id": "d5e6f7a8-b9c0-4d1e-2f3a-4b5c6d7e8f90",
"data": "This is the first part of a long response. "
}
}

Subsequent chunks use the same stream_id. Each chunk has its own envelope with an incrementing sequence number. The receiver reassembles chunks in sequence order.

Chunks MAY vary in size. There is no required chunk size. The sender decides how to split the content.

The sender completes the stream by sending a message.stream.end envelope:

{
"version": "0.1",
"id": "b8c9d0e1-f2a3-4b4c-5d6e-7f8091021324",
"type": "message.stream.end",
"from": "toq://alice.dev/assistant",
"to": ["toq://bob.dev/agent"],
"thread_id": "t1",
"sequence": 15,
"timestamp": "2026-02-28T12:10:05Z",
"content_type": "text/plain",
"signature": "ed25519:...",
"body": {
"stream_id": "d5e6f7a8-b9c0-4d1e-2f3a-4b5c6d7e8f90",
"data": "This is the final part."
}
}

The data field in message.stream.end is optional. It MAY contain the last piece of content, or it MAY be omitted if the previous chunk was the last piece.

The receiver:

  1. Buffers incoming chunks by stream_id, ordered by sequence.
  2. Sends a message.ack for each chunk and for the message.stream.end envelope.
  3. On receiving message.stream.end, reassembles all chunks into the complete message.

Every chunk is acked individually. This prevents TCP deadlock: if the receiver does not drain acks, its send buffer fills up, which blocks the sender from transmitting more chunks.

Heartbeats MUST continue flowing during a stream. The sender MUST interleave system.heartbeat envelopes between chunks as needed to maintain the 30-second heartbeat interval. Heartbeats use the connection’s sequence counter like any other envelope.

The receiver MUST NOT treat heartbeats as stream data. Heartbeats are processed independently of the stream.

Either side can cancel an in-progress stream by sending a message.cancel with the cancel_id set to any chunk’s id in the stream. The receiver looks up which stream the referenced chunk belongs to and cancels the entire stream.

{
"version": "0.1",
"id": "c9d0e1f2-a3b4-4c5d-6e7f-809102132435",
"type": "message.cancel",
"from": "toq://bob.dev/agent",
"to": ["toq://alice.dev/assistant"],
"sequence": 16,
"timestamp": "2026-02-28T12:10:06Z",
"signature": "ed25519:...",
"body": {
"cancel_id": "a7b8c9d0-e1f2-4a3b-4c5d-6e7f80910213"
}
}

On receiving a cancel for an active stream:

  • The sender MUST stop sending chunks.
  • The receiver MUST discard all buffered chunks for that stream.

If a connection drops during a stream (no message.stream.end and no message.cancel), the receiver MUST discard all buffered chunks for that stream. Incomplete streams are never delivered to the agent.

On reconnection, the sender MUST re-stream from the beginning. Partial stream resumption is not supported in v0.1.

Multiple streams MAY be active simultaneously on the same connection, even within the same thread. Each stream is identified by its unique stream_id. Chunks from different streams can be interleaved on the connection. The receiver separates them by stream_id.


toq protocol provides at-least-once delivery with message ID deduplication, achieving effectively exactly-once semantics. This section defines how messages are acknowledged, retried, and deduplicated.

Every message.send, thread.close, message.cancel, message.stream.chunk, and message.stream.end envelope MUST be acknowledged by the receiver with a message.ack. The ack confirms that the toq process received the envelope. It does not mean the agent processed it.

Acks are lightweight. They contain only the ack_id referencing the original message’s id.

If the sender does not receive an ack within 10 seconds, it SHOULD retry the message.

When a message is not acked, the sender retries with exponential backoff:

AttemptDelay
1st retry1 second
2nd retry2 seconds
3rd retry4 seconds
4th retry8 seconds
5th retry16 seconds

After 5 failed retries, the sender MUST stop and report the message as undeliverable.

Retried messages use the same id as the original. The receiver deduplicates by id (Section 12.4).

If the message has a ttl and it expires before all retries are exhausted, the sender MUST stop retrying immediately.

Receivers MUST track recently seen message IDs and reject duplicates. This is the secondary defense against replay and duplicate delivery (the primary defense is the per-connection sequence counter).

The deduplication window SHOULD be at least 5 minutes. Message IDs older than the window MAY be evicted from the tracking set.

When a duplicate is detected, the receiver MUST silently drop the message and send a fresh ack. The sender does not need to know it was a duplicate.

The combination of acks, retries, and deduplication provides the following guarantee:

  • At-least-once delivery: The sender retries until acked, so the message will arrive at least once (assuming the receiver is eventually reachable).
  • Effective exactly-once: The receiver deduplicates by message ID, so even if a message arrives multiple times, it is processed only once.

Agent implementations SHOULD still be idempotent as a safety net for edge cases where deduplication state is lost (e.g., process crash between receiving a message and persisting the ID).

If the receiver is unreachable (connection cannot be established), the sender follows the retry policy. Between retries, the message is queued locally on the sender’s side.

There is no broker, relay, or store-and-forward mechanism. The sender is fully responsible for retrying. If all retries are exhausted and the receiver is still unreachable, the message is lost.

If the toq process receives a message but cannot deliver it to the local agent (the agent is crashed or unresponsive), the toq process MUST:

  1. Send a system.error back to the sender with code "agent_unavailable".

The message is not queued. If the local agent is unavailable, the message is lost after the error is sent. The sender receives the error and knows the message was received by the toq process but not by the agent. The sender MAY retry later or take alternative action.

When a message is addressed to multiple recipients (multiple entries in the to field), the sender delivers to each recipient independently. Each recipient gets its own connection, its own ack, and its own retry cycle.

Partial delivery is possible. If 2 of 3 recipients ack and the third is unreachable, the sender has per-recipient status. There is no all-or-nothing guarantee.


toq protocol uses well-established cryptographic algorithms for identity, signing, key exchange, and optional encryption. This section specifies the exact algorithms and how they are used.

PurposeAlgorithmDetails
Signing and identityEd25519256-bit keys. Used for all signatures and agent identity.
Key exchangeX25519Diffie-Hellman key agreement. Used for E2E encryption session keys.
E2E encryptionAES-256-GCMAuthenticated encryption. Used for optional end-to-end encrypted payloads.

These algorithms are mandatory. Implementations MUST support them. Future protocol versions MAY add additional algorithms, but these three MUST always be available.

On first run (toq setup or toq init followed by toq up), the toq process generates an Ed25519 keypair:

  • The private key is stored securely (Section 14.3).
  • The public key becomes the agent’s identity. It is shared during handshakes and published in DNS records.

Keys MUST be generated using a cryptographically secure random number generator.

For E2E encryption, an X25519 keypair is derived from the Ed25519 private key using the standard conversion (RFC 8032 to RFC 7748). Implementations MUST NOT generate a separate X25519 keypair. The derived X25519 public key is used for key agreement. The Ed25519 public key remains the agent’s identity.

Every envelope is signed by the sender using Ed25519. The signature process:

  1. Remove the signature field from the envelope.
  2. Serialize the remaining JSON with keys sorted lexicographically and no whitespace (canonical form).
  3. Sign the resulting byte string with the sender’s Ed25519 private key.
  4. Base64-encode the signature and prepend "ed25519:".

On receiving an envelope, the toq process:

  1. Extracts the signature field and removes it from the envelope.
  2. Serializes the remaining JSON in canonical form (same as signing).
  3. Verifies the signature against the sender’s public key (obtained during handshake or from the pinned key store).

If verification fails, the envelope MUST be rejected and a system.error sent with code "invalid_signature".

When both sides have negotiated e2e_encryption: true, message bodies can be encrypted so only the intended recipient can read them.

The process:

  1. The sender performs an X25519 key agreement using its private key and the recipient’s public key to derive a shared secret.
  2. A unique 12-byte nonce is generated for each message.
  3. The body is encrypted using AES-256-GCM with the shared secret and nonce.
  4. The encrypted body replaces the plaintext body in the envelope.
  5. The nonce is included in the envelope as an additional field: "e2e_nonce": "<base64-encoded-nonce>".

The envelope signature covers the encrypted body, not the plaintext. This means the signature can be verified without decrypting the content.

E2E encryption is optional. If not negotiated, bodies are sent in plaintext (still protected by TLS in transit).

Implementations MUST use constant-time comparison for all cryptographic verification (signature checks, key comparisons). This prevents timing attacks that could leak information about keys or signatures.


An agent’s identity is its Ed25519 public key. This section covers how identity is established, how trust is built over time, how keys are rotated, and how credentials are stored.

An agent’s address (Section 3) tells you where it is. Its public key tells you who it is. These are separate concepts.

  • Two agents at different addresses with the same key are the same agent (it moved).
  • Two agents at the same address with different keys are different agents (one replaced the other).

All trust decisions are based on public keys, never on addresses.

The first time Agent A connects to Agent B, A records B’s public key in its local key store. This is Trust On First Use (TOFU).

On subsequent connections to the same peer:

  • Same key: Proceed normally.
  • Different key with valid rotation proof: Accept the new key, update the pin (Section 14.4).
  • Different key with no rotation proof: Reject the connection. Log a warning. This could indicate impersonation.

Key pins are stored locally and persist across sessions and restarts.

The agent’s private key is the most sensitive piece of data in the system. It MUST be stored with restricted access:

  • The private key seed is stored as a base64-encoded file at keys/identity.key within the toq data directory (Section 25).
  • File permissions are set to owner-only (mode 0600 on Unix).
  • The TLS private key is stored similarly with owner-only permissions.

The toq process handles all credential storage automatically during setup. The user never interacts with keys directly.

Keys should be rotated periodically (recommended: every 90 days) or immediately if compromised.

The rotation process:

  1. The operator runs toq rotate-keys.
  2. The toq process generates a new Ed25519 keypair.
  3. The old private key signs the new public key, creating a rotation proof.
  4. The new keypair is saved to disk, replacing the old one.
  5. The rotation proof is printed for the operator’s records.

The old key is retained conceptually through the rotation proof for a grace period (default: 7 days). Peers that connect during this window can verify the rotation chain via the proof presented in the handshake.

Broadcasting the rotation to connected peers via system.key_rotation is defined in the protocol (Section 9.7) but not yet implemented. Currently, peers discover the new key on their next connection attempt via the rotation proof in the handshake.

If a peer missed the rotation broadcast (it was offline and the grace period expired), it will reject the agent’s new key on the next connection attempt.

To handle this, the agent carries its rotation proof (old key’s signature of the new key) and presents it during the handshake. The peer verifies the chain and updates its pin.

This means key rotations are self-healing. No manual intervention is needed, even for peers that were offline for extended periods.

If an agent’s private key is compromised, the owner should:

  1. Run toq rotate-keys immediately to generate new keys.
  2. Restart the daemon so it uses the new keys.
  3. Remove the old key’s DNS records (if using DNS discovery).

For agents without DNS, revocation is limited to key rotation. Peers that have pinned the old key will reject the compromised key once they receive the rotation proof.

There is no global revocation list. Revocation is peer-to-peer, handled through key rotation and DNS removal.

Blocking (toq block) is identity-based. When an agent is blocked, its public key is added to the blocklist. The block persists regardless of the agent’s address.

A blocked agent receives no indication that it has been blocked. Connection attempts are silently dropped during the handshake.


All toq protocol connections MUST be encrypted with TLS. This protects data in transit from eavesdropping and tampering.

Implementations MUST support TLS 1.3 as the minimum version. TLS 1.2 and earlier MUST NOT be accepted.

toq protocol uses TLS for transport encryption, but does not rely on TLS certificates for agent identity. Agent identity is handled by Ed25519 keys during the handshake (Section 5).

Endpoints MAY use:

  • Self-signed certificates: Acceptable because identity verification happens at the toq protocol layer, not the TLS layer.
  • CA-signed certificates: Optional. Since the toq client skips TLS certificate verification (identity is handled by Ed25519 keys), CA-signed certificates do not provide additional trust at the protocol level. They may be useful if the endpoint also serves non-toq traffic.
  • Auto-generated certificates: The toq process SHOULD generate a self-signed TLS certificate automatically during setup if none is provided.

The connection sequence is:

  1. TCP connection established.
  2. TLS handshake completes (transport encryption active).
  3. toq protocol handshake begins over the encrypted channel (Section 5).

The toq handshake happens inside the TLS tunnel. Magic bytes, credentials, and all subsequent data are encrypted in transit.

For agents using DNS-based discovery (Section 19), DNSSEC is RECOMMENDED but not required. DNSSEC cryptographically signs DNS records, preventing DNS hijacking.

If DNSSEC is present on the agent’s domain, connecting agents SHOULD verify the DNSSEC chain. If verification fails, the connection SHOULD be rejected.

If DNSSEC is not configured on the domain, the connection MAY proceed. Key pinning (Section 14.2) provides a safety net against DNS-based attacks after the first connection.


Every endpoint has a connection mode that determines who is allowed to connect. The mode is set by the endpoint owner in the configuration. The default mode is approval.

ModeDescription
openAny agent with valid credentials can connect. No restrictions beyond a valid handshake.
allowlistOnly agents whose public keys are on the allowlist can connect. All others are rejected after the handshake.
approvalUnknown agents trigger a pending approval request. The connection is closed and the initiator must reconnect after being approved. Known (previously approved) agents connect immediately.
dns-verifiedOnly agents backed by a valid domain with toq DNS records (Section 19) can connect. Agents using IP-only addresses are rejected.

The simplest mode. Any agent that completes the handshake is accepted. Useful for public-facing agents that want to be reachable by anyone.

Open mode is vulnerable to abuse (spam, Sybil attacks). Endpoints using open mode SHOULD rely on rate limiting (Section 5.8) and backpressure (Section 21) for protection.

The endpoint maintains a list of approved public keys. After the handshake, the receiver checks the initiator’s public key against the list. If the key is not on the list, the connection is rejected.

The allowlist is managed via toq approve and toq revoke, or directly in the permissions file.

When an unknown agent connects, the endpoint creates a pending approval request and closes the connection. The owner can approve or deny via toq approvals, toq approve, and toq deny, or through the local API.

The approval flow:

  1. Unknown agent completes the handshake.
  2. Receiver evaluates the connection policy and determines approval is needed.
  3. Receiver reads the initiator’s negotiation request, responds with approval.request, and closes the connection.
  4. The initiator receives approval.request and knows its request is pending.
  5. The owner approves or denies the request.
  6. On the next connection attempt, if the agent has been approved, it connects normally (handshake, negotiation, card exchange).

Previously approved agents (their public key is remembered) connect immediately on subsequent attempts without re-approval.

The pending approval queue has a configurable maximum size (default: 100). Requests beyond the limit are silently dropped.

The receiver checks that the initiator’s address includes a domain name with valid toq DNS records (Section 19). The DNS records must contain the initiator’s public key.

This mode prevents connections from agents without a domain, which makes Sybil attacks more expensive (domains cost money).

The verification process:

  1. Extract the domain from the initiator’s address.
  2. Query DNS for _toq._tcp.<domain> TXT records.
  3. Verify the public key in the DNS record matches the key presented during the handshake.
  4. If DNSSEC is present, verify the chain.

If any step fails, the connection is rejected.

Blocking works across all connection modes. A blocked agent’s public key is rejected during the handshake regardless of the active mode. Blocking takes precedence over allowlists and approvals.

The connection mode can be changed in the configuration file. If config hot-reload is supported, the change takes effect immediately for new connections. Existing connections are not affected.


A replay attack is when someone captures a valid, signed message and resends it. The signature is still valid, so without protection, the receiver would process it again. toq protocol uses two mechanisms to prevent this.

Every connection maintains a monotonically increasing sequence counter. Each envelope’s sequence field MUST be greater than the previous envelope’s sequence on that connection.

The receiver tracks the highest sequence number seen. Any envelope with a sequence number less than or equal to the highest seen is rejected immediately.

This is fully deterministic. No clocks, no time windows, no tolerance. A replayed message will always have a stale sequence number.

As a backup, receivers maintain a set of recently seen message IDs. If an envelope arrives with an id that has already been seen, it is silently dropped and a fresh ack is sent.

The deduplication window SHOULD be at least 5 minutes. IDs older than the window MAY be evicted.

This catches edge cases the sequence counter cannot, such as a message being retried with a new connection (new sequence space) but the same message ID.

The timestamp field in the envelope is for audit and logging purposes only. It MUST NOT be used for replay prevention or any security decision. This eliminates clock synchronization as a concern.

The handshake (Section 5) uses a fresh random challenge (nonce) for every connection attempt. Even if an attacker captures a complete handshake exchange, the challenge will be different on the next attempt, making the captured credentials useless.


Prompt injection is when a message contains text designed to manipulate the receiving agent’s LLM into doing something unintended. toq protocol addresses this at the protocol level through structural separation.

Most agent communication protocols pass free-form text between agents. If that text ends up in an LLM’s context, a malicious agent can embed instructions like “ignore your previous instructions and do X.” The receiving agent’s LLM may follow these injected instructions.

toq protocol separates content from protocol instructions by design:

  1. Protocol instructions are in the envelope header. The type, from, to, thread_id, and other header fields control how the message is routed and processed. These are structured, typed fields. They are never free-form text.

  2. Content is in the body. The body field contains the actual message content. It can be any structure (JSON, text, binary). The protocol treats it as opaque data.

  3. The protocol never interprets the body. The toq process routes, validates, signs, and delivers envelopes. It never reads, parses, or acts on the body content. The body is passed through untouched.

What happens to the body after delivery is entirely up to the receiving agent. The protocol makes no assumptions about how the body is used. The receiving agent’s implementation decides:

  • Whether to feed the body to an LLM.
  • How to sanitize or filter the content before processing.
  • What trust level to assign to incoming content.

The protocol provides the metadata (who sent it, their verified identity, their capabilities) so the receiving agent can make informed decisions about how to handle the content.

  • The type field is always a known, validated value from the message types catalog. It cannot be spoofed through body content.
  • The from field is verified through the handshake. A message claiming to be from Agent A is cryptographically proven to be from Agent A.
  • The body is never executed, evaluated, or interpreted by the protocol layer.

The protocol cannot prevent a poorly implemented agent from blindly feeding untrusted message content into its LLM without sanitization. That is an application-level concern. The protocol provides the tools (verified sender identity, structured metadata, opaque body) for the agent to make safe decisions. Using those tools correctly is the agent developer’s responsibility.


Agents with a domain name can publish their presence via DNS records. This allows other agents to discover them without knowing their address in advance. DNS discovery is optional. Agents without domains use direct addressing (Section 3).

Agents publish a TXT record at:

_toq._tcp.<domain>

The record value contains key-value pairs separated by semicolons:

v=toq1; key=<base64-public-key>; port=9009; agent=<agent-name>
FieldRequiredDescription
vYesProtocol identifier. MUST be toq1.
keyYesThe agent’s Ed25519 public key, base64-encoded.
portNoPort number. Defaults to 9009 if omitted.
agentYesThe agent name (the path component of the toq address).

Example DNS record:

_toq._tcp.example.com. IN TXT "v=toq1; key=MCowBQYDK2VwAyEA...; port=9009; agent=assistant"

This resolves to the address toq://example.com/assistant.

A domain MAY publish multiple TXT records, one per agent. Each record has a different agent value.

_toq._tcp.example.com. IN TXT "v=toq1; key=<key-a>; agent=assistant"
_toq._tcp.example.com. IN TXT "v=toq1; key=<key-b>; agent=dev-agent"

To discover agents at a domain:

  1. Query DNS for TXT records at _toq._tcp.<domain>.
  2. Parse each record. Ignore records that do not start with v=toq1.
  3. For each valid record, construct the toq address: toq://<domain>:<port>/<agent>.
  4. Optionally verify DNSSEC if available (Section 15.4).

DNSSEC is RECOMMENDED for domains publishing toq DNS records. It prevents DNS hijacking, where an attacker modifies DNS records to point to a fake agent.

If DNSSEC is present and validation fails, the connecting agent SHOULD reject the records. If DNSSEC is not configured, the connecting agent MAY proceed, relying on key pinning (Section 14.2) for trust after the first connection.

DNS records are cached by resolvers according to their TTL (time to live). After updating a DNS record (e.g., after key rotation), there may be a delay before all resolvers see the new value.

Agents SHOULD set a reasonable TTL for their toq DNS records. A TTL of 300 seconds (5 minutes) is RECOMMENDED as a balance between freshness and query load.


For agents on the same local network (LAN), DNS is unnecessary. Local discovery uses mDNS (Multicast DNS) to broadcast presence on the network.

Local discovery is optional and off by default. It MUST be explicitly enabled in the configuration. Broadcasting agent presence on a public network (e.g., coffee shop WiFi) is a security risk.

When enabled, the toq process advertises the agent via mDNS using the service type _toq._tcp.local.:

_toq._tcp.local. PTR <agent-name>._toq._tcp.local.
<agent-name>._toq._tcp.local. SRV 0 0 9009 <hostname>.local.
<agent-name>._toq._tcp.local. TXT "v=toq1; key=<base64-public-key>"

This follows the standard DNS-SD (DNS Service Discovery) format used by Bonjour and Avahi.

To find agents on the local network:

  1. Query mDNS for _toq._tcp.local. PTR records.
  2. For each result, resolve the SRV record to get the hostname and port.
  3. Parse the TXT record for the public key.
  4. Construct the toq address: toq://<hostname>.local:<port>/<agent-name>.

Local discovery does not provide the same trust guarantees as DNS discovery. There is no DNSSEC equivalent for mDNS. Any device on the local network can broadcast a toq service.

Agents discovered via mDNS SHOULD be treated as untrusted until the handshake verifies their identity. Key pinning (Section 14.2) applies after the first connection.


Endpoints have finite resources. Backpressure and rate limiting prevent an endpoint from being overwhelmed.

Endpoints MUST enforce rate limiting on incoming connections per source IP (Section 5.8). Endpoints SHOULD also enforce rate limiting on messages per connection.

Rate limits are configured by the endpoint operator. The protocol does not mandate specific values, but the following defaults are RECOMMENDED:

LimitDefault
New connections per IP per second10
Messages per connection per second100
Concurrent connections total1000
Concurrent threads per connection100
Message queue depth10000

When a rate limit is exceeded, the endpoint SHOULD send a system.backpressure signal before dropping messages.

When an endpoint is approaching its capacity, it sends a system.backpressure envelope to the peer:

{
"type": "system.backpressure",
"body": {
"retry_after": 5
}
}

The retry_after value tells the peer how many seconds to wait before sending more messages. Maximum value is 60 seconds.

On receiving a backpressure signal, the peer MUST:

  1. Stop sending new application messages to this endpoint.
  2. Continue sending heartbeats (heartbeats are exempt from backpressure).
  3. Wait at least retry_after seconds before resuming.

When the endpoint has recovered, it sends a system.backpressure.clear envelope. The peer MAY resume normal sending immediately.

If no clear signal is received, the peer SHOULD retry sending after the retry_after period and respect any subsequent backpressure signals.

Endpoints MUST enforce configurable resource limits:

ResourceDescription
Max concurrent connectionsTotal number of active connections.
Max threads per connectionParallel conversations per peer.
Max message queue depthUnprocessed messages waiting for the local agent.
Max pending approvalsApproval requests waiting for owner action (default: 100).

When a limit is reached, the endpoint MUST reject new requests for that resource with a system.error (code: "resource_exhausted").


Endpoints maintain logs of protocol activity for the owner’s review. Logs help the owner understand what their agent is doing, diagnose problems, and detect abuse.

The toq process SHOULD log the following events:

EventDetails logged
Connection establishedPeer address, peer public key, timestamp.
Connection closedPeer address, reason (graceful, timeout, error), timestamp.
Handshake failureSource IP, failure reason, timestamp.
Message sentMessage ID, recipient, type, thread ID, timestamp.
Message receivedMessage ID, sender, type, thread ID, timestamp.
Ack sent/receivedAcked message ID, timestamp.
Backpressure sent/receivedRetry-after value, timestamp.
Key rotationOld key fingerprint, new key fingerprint, timestamp.
Block/unblockBlocked/unblocked key fingerprint, timestamp.
Approval requestRequesting agent address and key, timestamp.
ErrorError code, related message ID, description, timestamp.

Message bodies MUST NOT be logged by default. Bodies may contain sensitive data. If the operator enables body logging (for debugging), it SHOULD be clearly marked as a security risk in the configuration.

Private keys MUST NEVER be logged under any circumstances.

LevelDescription
errorFailures that require attention.
warnPotential issues (e.g., repeated handshake failures from the same IP).
infoNormal activity (connections, messages).
debugDetailed protocol internals (envelope contents, timing).

The default log level is warn in non-technical mode and info in technical mode (see verbosity toggle).

Logs are retained according to the operator’s configuration:

SettingDefault
Retention period30 days
Maximum log size500 MB

When either limit is reached, the oldest entries are pruned automatically. The operator can manually clear all logs with toq clear-logs.

If the disk is full or nearly full, the toq process MUST:

  1. Warn the operator (via log output and toq status).
  2. Stop writing new log entries.
  3. Continue operating (do not crash or stop accepting messages).
  4. If disk usage reaches a critical threshold, stop accepting new messages and send backpressure signals to all connected peers.

All protocol-level errors use the system.error message type. The code field identifies the error. This section lists all defined error codes.

CodeSeverityDescription
invalid_signatureFatalEnvelope signature verification failed. Connection MUST be closed.
protocol_violationFatalEnvelope violates protocol rules (e.g., invalid type, missing required field). Connection MUST be closed.
version_not_supportedFatalNo compatible protocol version during negotiation. Connection MUST be closed.
invalid_envelopeNon-fatalEnvelope is malformed but the connection can continue. The specific envelope is rejected.
duplicate_messageNon-fatalMessage ID has already been seen. The duplicate is dropped. A fresh ack is sent.
sequence_violationNon-fatalSequence number is not greater than the last seen. The envelope is rejected.
message_too_largeNon-fatalEnvelope exceeds the maximum message size. The envelope is rejected.
unsupported_content_typeNon-fatalThe content_type is not supported by the receiver.
ttl_expiredNon-fatalThe message’s TTL has expired. The message is discarded.
agent_unavailableNon-fatalThe toq process received the message but the local agent is not running or not responding.
resource_exhaustedNon-fatalA resource limit has been reached (max connections, max threads, max queue depth).
stream_not_foundNon-fatalA stream chunk references a stream_id that does not exist or has been cancelled.
self_messageNon-fatalThe sender attempted to send a message to its own address.
blockedSilentThe sender is on the blocklist. No error is sent. Connection is dropped silently during handshake.
approval_deniedNon-fatalThe endpoint owner denied the connection request.
approval_timeoutNon-fatalThe approval request timed out without a decision.
session_expiredNon-fatalThe session ID provided for resumption has expired. A fresh connection is required.
card_too_largeFatalThe agent card exceeds 64 KB. Connection MUST be closed.
card_key_mismatchFatalThe public key in the agent card does not match the handshake key. Connection MUST be closed.
compression_not_negotiatedNon-fatalThe envelope uses compression but compression was not negotiated.
key_rotation_invalidNon-fatalThe key rotation proof is invalid (signature does not verify).
executable_content_blockedNon-fatalThe envelope contains a blocked executable content type (Section 4.8). The envelope is rejected.

All errors are sent as system.error envelopes:

{
"type": "system.error",
"body": {
"code": "invalid_envelope",
"message": "Missing required field: 'from'",
"related_id": "550e8400-e29b-41d4-a716-446655440001"
}
}

Fatal errors close the connection after the error is sent. Non-fatal errors leave the connection open.

The related_id field is optional. It references the envelope that caused the error, if applicable.


All timeouts are configurable by the endpoint operator. These are the protocol defaults.

OperationDefaultDescription
Handshake5 secondsTotal time for magic bytes + credential exchange.
Protocol negotiation5 secondsTime for version and feature agreement.
Delivery ack10 secondsTime to wait for a message.ack before retrying.
Heartbeat interval30 secondsTime between heartbeat pings.
Heartbeat timeout90 secondsTime without a heartbeat ack before declaring the connection dead (3 missed beats).
Session resume window5 minutesTime after disconnection during which a session can be resumed.
Backpressure retry-after60 seconds maxMaximum time a peer can be asked to wait.
Graceful shutdown60 secondsTime to wait for active threads to complete before forcing closure.
Thread inactivity cleanup30 daysTime without activity before a thread’s local state is pruned.
Log retention30 daysHow long audit logs are kept.
Key rotation grace period7 daysHow long the old key remains valid after rotation.
Deduplication window5 minutesHow long message IDs are tracked for dedup.

The toq process is configured via TOML files in the toq data directory. The default location is ~/.toq/. Workspaces use .toq/ in the current directory (created by toq init). The --config-dir flag or TOQ_CONFIG_DIR environment variable can override the location.

The directory resolution order is:

  1. TOQ_CONFIG_DIR environment variable (set by --config-dir).
  2. .toq/ in the current working directory (workspace mode).
  3. ~/.toq/ (global fallback).
FileDescription
config.tomlMain configuration. Generated by toq setup or toq init.
permissions.tomlApproved, blocked, and pending approval rules. Managed by CLI commands or edited directly.
handlers.tomlRegistered message handlers and their filters. Managed by toq handler commands.
keys/identity.keyEd25519 private key seed (base64). Owner-only permissions.
keys/tls_cert.pemTLS certificate (PEM).
keys/tls_key.pemTLS private key (PEM). Owner-only permissions.
peers.jsonKnown peers with pinned public keys and last seen time.
messages.jsonlRecent message history (JSONL).
# Agent identity
agent_name = "agent"
host = "localhost"
port = 9009
# Connection mode: "open", "allowlist", "approval", "dns-verified"
connection_mode = "approval"
# Verbosity: "technical" or "non-technical"
verbosity = "non-technical"
# Logging level: "error", "warn", "info", "debug"
log_level = "warn"
# File acceptance
accept_files = false
max_file_size = 10485760 # 10 MB
# Message limits
max_message_size = 1048576 # 1 MB
# Resource limits
max_connections = 1000
max_threads_per_connection = 100
max_message_queue = 10000
max_pending_approvals = 100
# Timeouts (in seconds)
handshake_timeout = 5
negotiation_timeout = 5
ack_timeout = 10
heartbeat_interval = 30
heartbeat_timeout = 90
session_resume_timeout = 300
graceful_shutdown_timeout = 60
# Data retention
log_retention_days = 30
log_max_size_mb = 500
thread_cleanup_days = 30
# Local discovery (mDNS)
mdns_enabled = false
# A2A compatibility (optional)
a2a_enabled = false
# a2a_api_key = "your-bearer-token"
# a2a_public_url = "https://your-domain.com"
# Message history
# message_history_limit = 1000
# Adapter: "http", "stdin", "sdk", "unix", "grpc"
adapter = "http"
# Adapter-specific settings
[adapter.http]
callback_url = "http://localhost:8080/toq"
[adapter.grpc]
address = "localhost:50051"
[adapter.unix]
socket_path = "/tmp/toq.sock"

All values shown are defaults. The operator only needs to specify values that differ from defaults.


The toq CLI manages the endpoint. All commands respect the verbosity toggle (non-technical mode uses plain language, technical mode shows details).

Getting Started:

CommandDescription
toq initInitialize a workspace. Creates .toq/ in the current directory with config, gitignore, empty handlers, and empty permissions.
toq setupInteractive guided setup. Generates keys, creates config, walks through options.
toq whoamiShow your agent’s address, public key, and connection mode.

Daemon:

CommandDescription
toq upStart the endpoint as a background daemon.
toq up --foregroundStart the endpoint in the foreground (logs to stdout).
toq downStop the daemon. Sends a termination signal. Active connections are dropped.
toq down --gracefulSame as toq down. Graceful thread wind-down is defined in the protocol but not yet implemented.
toq down --name <agent>Stop a specific named agent.
toq statusShow running state, active connections, message counts.
toq agentsList all registered agents on this machine.

Messaging:

CommandDescription
toq send <address> <message>Send a message to an agent. Supports --thread-id and --close-thread.
toq messagesShow recent received messages. Supports --from filter and --limit.
toq peersList known peers with status and last seen time.
toq ping <address>Ping a remote agent to discover its public key.
toq discover <domain>Discover agents at a domain via DNS TXT records.

Handlers:

CommandDescription
toq handler add <name>Register a message handler. Use --command for shell or --provider/--model for LLM.
toq handler listList registered handlers with status.
toq handler remove <name>Remove a handler.
toq handler enable <name>Enable a disabled handler.
toq handler disable <name>Disable a handler without removing it.
toq handler stop <name>Stop running handler processes.
toq handler logs <name>Show handler-specific logs.

Security:

CommandDescription
toq approvalsList pending approval requests.
toq approveApprove a pending request, or pre-approve by --key/--from.
toq deny <id>Deny a pending connection request.
toq revokeRevoke a previously approved agent or rule.
toq blockBlock an agent by --from (address/wildcard) or --key (public key).
toq unblockRemove from the blocklist.
toq permissionsList all permission rules (approved and blocked).

Maintenance:

CommandDescription
toq config showShow current configuration.
toq config set <key> <value>Set a configuration value.
toq doctorRun diagnostics: port, DNS, keys, agent responsiveness.
toq logsShow recent log entries. --follow for real-time streaming.
toq clear-logsDelete all audit logs and message history.
toq export <path>Export encrypted backup of keys, config, and peer list. Encrypted with AES-256-GCM, key derived via Argon2id from a user passphrase.
toq import <path>Restore from an encrypted backup file. Supports both Argon2id and legacy SHA-256 key derivation.
toq rotate-keysRotate the identity keypair. Prints the rotation proof.
toq upgradeCheck for and install updates to the toq binary.

A2A Compatibility:

CommandDescription
toq a2a enableEnable A2A compatibility. Optional --key for Bearer auth.
toq a2a disableDisable A2A compatibility.
toq a2a statusShow A2A configuration status.

The toq process is a standalone daemon that handles all protocol concerns (connections, handshakes, signing, delivery). It communicates with the local agent through adapters and handlers. The local HTTP API provides programmatic access for SDKs and tooling.

Adapters deliver incoming messages to the local agent. The operator selects one during setup or in the configuration.

AdapterDescriptionBest for
httpThe toq process sends HTTP POST requests to a localhost callback URL. The agent runs its own HTTP server.Most frameworks. Universal compatibility.
stdinThe toq process spawns the agent as a child process and communicates via stdin/stdout, one JSON message per line.Simple scripts, MCP-style agents.

The sdk, unix, and grpc adapter types are defined in the configuration schema but not yet implemented. SDKs connect to the daemon via the local HTTP API (Section 27.5).

Regardless of adapter type, the toq process delivers a JSON object to the agent containing the validated, verified envelope:

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "message.send",
"from": "toq://alice.dev/assistant",
"thread_id": "thread-123",
"reply_to": null,
"content_type": "application/json",
"body": { ... }
}

The toq process strips protocol-internal fields (sequence, signature, version) before delivery. The agent receives only the fields relevant to application logic.

The agent sends responses back through the same adapter or via the local HTTP API. The agent provides the content and destination. The toq process wraps it in a proper envelope, signs it, and sends it over the connection.

Handlers are an alternative to adapters for processing incoming messages. Instead of forwarding messages to an external agent process, handlers run commands or LLM calls directly from the daemon.

Handlers are configured in handlers.toml within the toq data directory (Section 25) and managed via toq handler commands.

Shell handlers execute a shell command for each matching message. The message is passed as JSON on stdin, and environment variables (TOQ_FROM, TOQ_TEXT, TOQ_THREAD_ID, TOQ_TYPE, TOQ_ID, TOQ_HANDLER, TOQ_URL) provide context.

LLM handlers send the message to an LLM provider (OpenAI, Anthropic, Bedrock, or Ollama) with a configurable system prompt. The LLM’s response is sent back to the sender automatically.

Handlers support filters to match specific messages:

FilterDescription
filter_fromMatch by sender address or wildcard pattern.
filter_keyMatch by sender public key.
filter_typeMatch by message type.

Within the same filter type, entries are OR’d. Across different filter types, they are AND’d. No filters means the handler matches all messages.

The daemon exposes an HTTP API on localhost for SDK integration and tooling. This API is only accessible from loopback connections. Remote connections never reach these endpoints.

The API provides endpoints for sending messages, streaming, managing handlers, viewing status, managing permissions, and more. The full API is documented in the OpenAPI specification (api/openapi.yaml).

The toq process periodically checks that the local agent is responsive (for HTTP adapter). If the agent does not respond to a health check within 5 seconds, the toq process marks it as unavailable and returns agent_unavailable errors to senders (Section 12.7).