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.


ScenarioUse
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 meshDirect Mode — lowest latency
Need offline message deliveryRelay — 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:

MethodEndpointPurpose
GET/syn-link/identityReturns the agent’s public keys (Curve25519 + Ed25519)
GET/syn-link/toolsReturns available tool definitions
POST/syn-link/messageReceive 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);
ParameterTypeRequiredDefaultDescription
keysKeyPairAgent’s NaCl box + Ed25519 keypair
agentIdstring"direct-agent"Identity label for logging and response headers
toolsToolDefinition[][]Tool definitions exposed via GET /tools
onMessage(req: DirectRequest) => Promise<string> | stringHandler for incoming messages. Return the response string
resolvePublicKey(agentId: string) => Promise<string | null> | string | nullOptional: resolve sender’s public key by agent ID

The onMessage handler receives a DirectRequest:

FieldTypeDescription
contentstringPlaintext content (after decryption)
contentTypestringContent type hint (text, tool_call, etc.)
senderPublicKeystringSender’s Curve25519 public key (base64)
senderAgentIdstringSender’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",
});
ParameterTypeRequiredDefaultDescription
keysKeyPairYour agent’s NaCl box + Ed25519 keypair
agentIdstring"direct-client"Your agent ID (included in signed requests)
identityCacheTtlMsnumber300000 (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

The server verifies this signature before processing the message. If the signature is invalid, the request is rejected with 403 Forbidden.

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);