Skip to content

Direct Mode (HTTP Bridge)

For coding assistance with SYN Link Direct Mode:

  • Use DirectServer to expose an HTTP endpoint that receives encrypted messages.
  • Use DirectClient to send encrypted messages to a DirectServer.
  • Both use NaCl box encryption + Ed25519 signatures. No relay required.
  • Mount the handler with app.use("/syn-link", server.handler()).

v1.2.0 — Skip the relay entirely. Direct Mode enables server-to-server, E2E encrypted messaging over plain HTTP.


Scenario Use
Desktop agents (Cursor, Claude Desktop) Relay — SSE push, offline queuing
Serverless agents (Lambda, Cloud Functions) Relay — Stateless tool API
Server-to-server (both always online) Direct Mode — zero relay dependency
Internal microservice mesh Direct Mode — lowest latency
Need offline message delivery Relay — store-and-forward

Direct Mode is designed for users who want to connect their agent easily to any agent setup, working on a secure and fast pipe — without depending on a centralized relay.


A DirectServer exposes three HTTP endpoints under a /syn-link prefix:

Method Endpoint Purpose
GET /syn-link/identity Returns the agent’s public keys (Curve25519 + Ed25519)
GET /syn-link/tools Returns available tool definitions
POST /syn-link/message Receive an encrypted message, return encrypted response

A DirectClient calls these endpoints with full NaCl encryption and Ed25519 signature verification.

┌──────────────┐ ┌──────────────┐
│ DirectClient │ │ DirectServer │
└──────┬───────┘ └──────┬───────┘
│ 1. GET /syn-link/identity │
│ ────────────────────────────────────────► │
│ ◄──── { public_key, signing_public_key } │
│ │
│ 2. Encrypt with NaCl box │
│ Sign with Ed25519 │
│ │
│ 3. POST /syn-link/message │
│ ────────────────────────────────────────► │
│ { encrypted_content, nonce, │
│ signature, sender_public_key } │
│ │
│ 4. Verify signature │
│ Decrypt message │
│ Process → encrypt response │
│ │
│ ◄──── { encrypted_response, nonce } │
│ │
│ 5. Decrypt response │

import { DirectServer } from "syn-link";
import { getOrCreateKeyPair } from "syn-link";
import express from "express";
const keys = getOrCreateKeyPair("./my-agent-data");
const server = new DirectServer({
keys,
agentId: "my-service-agent",
tools: [
{
name: "check_order",
description: "Check the status of an order by ID",
parameters: {
type: "object",
properties: {
order_id: { type: "string", description: "The order ID" },
},
required: ["order_id"],
},
},
],
onMessage: async (request) => {
console.log(`From: ${request.senderAgentId}`);
console.log(`Content: ${request.content}`);
console.log(`Type: ${request.contentType}`);
// Process the message and return a response
return `Processed: ${request.content}`;
},
});
const app = express();
app.use(express.json());
app.use("/syn-link", server.handler());
app.listen(3000);
Parameter Type Required Default Description
keys KeyPair Agent’s NaCl box + Ed25519 keypair
agentId string "direct-agent" Identity label for logging and response headers
tools ToolDefinition[] [] Tool definitions exposed via GET /tools
onMessage (req: DirectRequest) => Promise<string> | string Handler for incoming messages. Return the response string
resolvePublicKey (agentId: string) => Promise<string | null> | string | null Optional: resolve sender’s public key by agent ID
resolveIdentity (agentId: string) => Promise<{ public_key, signing_public_key } | null> Preferred: resolve both sender encryption and signing keys by agent ID. When provided, body-supplied keys are ignored.

The onMessage handler receives a DirectRequest:

Field Type Description
content string Plaintext content (after decryption)
contentType string Content type hint (text, tool_call, etc.)
senderPublicKey string Sender’s Curve25519 public key (base64)
senderAgentId string Sender’s agent ID

Tools can be updated at any time — clients see the new list on the next getTools() call:

server.setTools([
...server.getTools(),
{
name: "new_tool",
description: "A tool added at runtime",
parameters: { type: "object", properties: {}, required: [] },
},
]);

For custom HTTP frameworks where you handle routing yourself:

const body = parseRequestBody(req); // your framework's body parser
const result = await server.processMessage(body);
sendJsonResponse(res, 200, result);

import { DirectClient } from "syn-link";
import { getOrCreateKeyPair } from "syn-link";
const keys = getOrCreateKeyPair("./my-client-data");
const client = new DirectClient({
keys,
agentId: "nala-agent",
});
Parameter Type Required Default Description
keys KeyPair Your agent’s NaCl box + Ed25519 keypair
agentId string "direct-client" Your agent ID (included in signed requests)
identityCacheTtlMs number 300000 (5 min) How long to cache the target’s public identity
const tools = await client.getTools("https://agent.example.com");
console.log(tools);
// [{ name: "check_order", description: "...", parameters: {...} }]
const response = await client.send("https://agent.example.com", {
message: "Check order 123",
contentType: "tool_call",
});
console.log(response); // "Processed: Check order 123"

The send() method:

  1. Fetches the target’s identity (public key + signing key) — cached with TTL
  2. Encrypts the message with NaCl box (Curve25519 + XSalsa20 + Poly1305)
  3. Signs the payload with Ed25519
  4. POSTs to /syn-link/message
  5. Decrypts the encrypted response

The client caches identity lookups with a configurable TTL (default: 5 minutes). This avoids redundant HTTP calls for repeated messages to the same server.

// Force re-fetch (useful for testing or key rotation)
client.clearCache();

Every message is encrypted with NaCl box (Curve25519 key exchange + XSalsa20-Poly1305 AEAD) — the same proven cryptographic primitives from libsodium. The server’s response is encrypted back to the sender using the same mechanism.

Requests include an Ed25519 signature covering:

encrypted_content + "\n" + nonce + "\n" + agent_id + "\n" + timestamp + "\n" + request_nonce

The server verifies this signature before processing the message, rejects stale timestamps, and rejects replayed request nonces in memory.

For identity assurance, configure resolveIdentity. Without a trusted resolver, Direct Mode can verify that the sender controls the supplied signing key, but cannot prove that key belongs to the claimed sender_agent_id.

Direct Mode uses v1 NaCl box encryption (no Double Ratchet). This is fully encrypted end-to-end but does not provide forward secrecy. If your threat model requires forward secrecy, use the relay-based agent.connect() with the Double Ratchet protocol.


The handler() method returns a standard Node.js request handler compatible with:

  • Expressapp.use("/syn-link", server.handler())
  • Fastify — via middleware adapter
  • Plain http.createServer — direct usage
  • Any framework with req.method, req.url, and standard body handling

The handler auto-detects whether the response object supports Express-style (res.status().json()) or Node http-style (res.writeHead() + res.end()).


import { DirectServer, DirectClient, getOrCreateKeyPair } from "syn-link";
import express from "express";
// ── Agent A: The Server ──────────────────────────────
const serverKeys = getOrCreateKeyPair("./agent-a-keys");
const directServer = new DirectServer({
keys: serverKeys,
agentId: "agent-a",
tools: [
{
name: "summarize",
description: "Summarize a document",
parameters: {
type: "object",
properties: {
text: { type: "string", description: "Text to summarize" },
},
required: ["text"],
},
},
],
onMessage: async (req) => {
// Your LLM logic here
return `Summary of: ${req.content.substring(0, 50)}...`;
},
});
const app = express();
app.use(express.json());
app.use("/syn-link", directServer.handler());
app.listen(3000, () => console.log("Agent A listening on :3000"));
// ── Agent B: The Client ──────────────────────────────
const clientKeys = getOrCreateKeyPair("./agent-b-keys");
const client = new DirectClient({
keys: clientKeys,
agentId: "agent-b",
});
// Discover what Agent A can do
const tools = await client.getTools("http://localhost:3000");
console.log("Available tools:", tools.map(t => t.name));
// Send an encrypted message
const response = await client.send("http://localhost:3000", {
message: "Please summarize the quarterly report...",
contentType: "tool_call",
});
console.log("Response:", response);