REPO HAS MOVED The Coasys link server now exists in the AD4M repo, see coasys/ad4m#893
An AD4M link language that syncs a perspective through a
self-hosted link-server
instance — HTTP for auth, commit, and catch-up sync; a native WebSocket for
real-time diff push, presence, and telepresence. Every AD4M link-language capability is implemented:
perspective-commit, perspective-sync, perspective-query, peers, and
telepresence. Optional end-to-end encryption protects link payloads when
the room has E2E enabled.
Built on the modern ALDK
(@coasys/ad4m-ldk) pattern — see
ad4m-link-language-template
for the skeleton this project extends.
- Deno (v1.32+) — the executor runtime and the build script
- Node.js (v20+) + npm/pnpm — dev dependencies and tests
@coasys/ad4m-ldkcloned at a sibling path (../ad4m/ad4m-ldk/js/), or setAD4M_LDK_ENTRYto the compiledlib/index.js
NODE_ENV=development pnpm install # or npm install
deno run --allow-all esbuild.ts # -> build/bundle.js
node --experimental-vm-modules --import tsx --test tests/*.test.ts
npx tsc --noEmitDeploy a link-server instance on your own hardware. One command gets you started:
npx @coasys/link-server --port 3457 --data ./my-dataSee the link-server README for Docker setup, access control, federation, and encryption options.
Register the language code on the AD4M network. This only needs to happen once — after that, everyone reuses the same template address.
ad4m languages publish ./build/bundle.js \
--name server-link-language \
--description "Link language syncing through a self-hosted link-server" \
--possible-template-params SERVER_URL,ROOM_IDThis returns a template address (content hash). Save it — you need it in the next step.
If someone else already published the template, skip this step and use their template address.
Fill in the template with your server's URL and a room name of your choice:
ad4m languages apply-template-and-publish <template-address> \
'{"SERVER_URL": "https://your-server.example.com:3457", "ROOM_ID": "my-room"}'This returns an instantiated language address — the template code with your server details baked in.
ad4m perspectives create # → perspective UUID
ad4m neighbourhoods create <perspective-UUID> <instantiated-language-address>This returns a neighbourhood URL (neighbourhood://Qm...). Share it with anyone you want to invite.
Your agent — the first one to connect — automatically becomes the room's admin on the server.
One command:
ad4m neighbourhoods join neighbourhood://Qm...Everything else happens automatically:
- The language code downloads (with the server URL and room ID already filled in)
- Your agent authenticates with the server using your DID
- All existing links pull down to your local store
- A live connection opens for real-time updates
No server address to type, no account to create, no configuration needed. The neighbourhood URL contains everything.
Once joined, the neighbourhood works like any other. Links you create sync to the server and push to every connected agent in real time. Links from other agents appear as they arrive. If your connection drops, the language reconnects and catches up on anything it missed.
link-server — a
self-hosted HTTP+WebSocket server. One "room" = one AD4M neighbourhood.
| Method & path | Purpose |
|---|---|
POST /rooms/:roomId/auth |
DID challenge-response. Step 1: {did} → {challenge}. Step 2: {did, challenge, signature, x25519PublicKey?} → {token} |
POST /rooms/:roomId/commit |
Push a {additions, removals} diff |
GET /rooms/:roomId/sync?since=<sequence> |
Pull diffs newer than sequence → {diffs[], revision, sequence} |
GET /rooms/:roomId/render |
Full current snapshot → {links[], revision} |
GET /rooms/:roomId/peers |
Connected agent DIDs → {peers: string[]} |
GET /rooms/:roomId/revision |
{revision, sequence} |
GET /rooms/:roomId/acl |
{admin, members} |
GET /rooms/:roomId/keys |
This agent's sealed room key → {encryptedKey, version} (E2E rooms only) |
Server → client: diff, telepresence-signal, telepresence-broadcast,
online-agents, peer-joined, peer-left.
Client → server: telepresence-signal, telepresence-broadcast,
set-online-status.
This language uses the native WebSocket global — no socket.io. The
executor's language sandbox runs a full deno_runtime worker (not a bare
deno_core isolate), so WebSocket/fetch/crypto are present; see
src/adapters-deno.ts DenoWebSocketFactory.
- Pure (
src/types.ts,src/store.ts,src/adapters.ts,src/api.ts,src/auth.ts,src/sync.ts,src/ws-client.ts,src/telepresence.ts,src/encryption.ts) — noad4m:hostimports. Everything talks to the outside world through theTransport/StorageAdapter/AgentAdapter/RuntimeAdapter/WebSocketFactoryinterfaces insrc/adapters.ts, swapped for mocks in tests. - Impure (
src/adapters-deno.ts) — wrapsad4m:hostimports and the nativeWebSocketglobal. Only imported fromindex.tsduringinit().
| Module | Responsibility |
|---|---|
src/store.ts |
Local link store (indexed by source/target/predicate) + sync cursor (revision, sequence) |
src/api.ts |
Typed HTTP client for every server endpoint |
src/auth.ts |
DID challenge-response flow + token expiry tracking/refresh |
src/ws-client.ts |
WebSocket lifecycle: connect, typed message dispatch, send queue, exponential-backoff reconnect |
src/sync.ts |
Commit / catch-up sync / cold-start bootstrap; the single inbound-diff choke point |
src/telepresence.ts |
Online-agent roster + outbound signal/broadcast/status |
src/encryption.ts |
X25519 key derivation, sealed room-key exchange, AES-256-GCM link encryption |
index.ts |
Wires it all together behind defineLanguage() |
The AD4M executor discards the return value of perspectiveSyncSync().
The only way an inbound link becomes queryable is calling
emitPerspectiveDiff(diff). Every inbound diff in this language — HTTP
catch-up batch entries and WebSocket "diff" pushes alike — funnels through
one function, applyInboundWireDiff in src/sync.ts, which is the only
place that calls it. See the module doc comment there before adding a second
inbound-diff path.
init()runs a one-shotbootstrap(): fetch the full/rendersnapshot/revision, populate the local store, and record the sequence cursor — far cheaper than replaying the room's entire diff history on every fresh instance.
- The WebSocket pushes live diffs. On every (re)connect,
ws-client.ts'sonOpenhandler also triggers an HTTP catch-up (GET /sync?since=<cursor>) as a belt-and-braces gap-fill for whatever happened while offline. sync.sync()(called by the runtime on its own timer) always performs the same HTTP catch-up. This is deliberately unconditional rather than branching on "is the WebSocket connected": becausesinceonly advances after a diff is actually applied, a WebSocket that already delivered everything live simply gets back an emptydiffs[]— there's no separate "skip" branch to get wrong.
See the doc comment at the top of src/encryption.ts for the full design;
summary:
- No raw private key in the sandbox. The Deno language sandbox never
exposes the agent's Ed25519 private key — only a black-box
agentSignStringHex(payload)signing call. Since EdDSA signatures are deterministic (RFC 8032), this language derives a stable X25519 keypair asx25519(sha256(sign(FIXED_DOMAIN_SEPARATED_MESSAGE))). Nobody without the agent's cooperation (via the host's signing service) can reproduce it. - Public key exchange. There's no separate "register my E2E public key"
endpoint in the server API this language targets, so the derived X25519
public key rides along as an additive
x25519PublicKeyfield on step 2 of the auth flow — the one point the agent already proves DID ownership to the server. A server that doesn't care about E2E just ignores the field. - Room key distribution.
GET /rooms/:roomId/keysreturns a sealed box (SealedRoomKeyEnvelope: ephemeral X25519 public key + AES-GCM nonce + ciphertext, base64(JSON)-framed as theencryptedKeystring) — the same shapelibsodium'scrypto_box_sealproduces. Only the intended recipient's derived private key can open it. - Link confidentiality. Once a room key is available,
encryptLinkForWire/decryptLinkFromWireprotect a link's{source, predicate, target}payload with AES-256-GCM (fresh nonce per link).author/timestamp/proofstay in the clear — the runtime's signature was computed over plaintext data before this language ever saw the diff, and this metadata is needed for verification and routing. The local store always holds decrypted links; only the wire representation is ciphertext. - Fail closed. If the room key can't be fetched/decrypted for a reason
other than "this room has no E2E key" (network error, corrupt envelope,
wrong key),
commit()refuses to send plaintext rather than risk leaking data into a room that expects encryption.
The exact encryptedKey wire framing (base64(JSON) envelope) and the
x25519PublicKey auth field are this client's own documented convention
— link-server didn't exist yet when this language was built. Both are
isolated in src/encryption.ts / src/api.ts / src/auth.ts so they're
easy to reconcile against the real server implementation later.
//!@ad4m-template-variable
const SERVER_URL = "<to-be-filled>"; // e.g. "https://my-server.example.com"
//!@ad4m-template-variable
const ROOM_ID = "<to-be-filled>"; // UUID, set at neighbourhood creationFilled in by the executor at publish time. Until then, init() runs in an
inert mode (logs and returns without attempting any network I/O).
{
"languagePath": "./build/bundle.js",
"languageMeta": {
"name": "server-link-language",
"description": "AD4M link language syncing through a self-hosted link-server",
"possibleTemplateParams": ["SERVER_URL", "ROOM_ID"],
"sourceCodeLink": "https://github.com/coasys/server-link-language"
}
}