Skip to main content
chat-relayer doesn’t use API keys. End users authenticate by signing each request with a delegate Ed25519 key (in production, derived from their wallet via the MemWal client SDK). This is the auth scheme most likely to trip up a new client integration, so this page documents it field-by-field against a known-working run.

The scheme

  1. Generate (or load) an Ed25519 keypair. The raw 32-byte public key, hex-encoded, is delegate_pubkey_hex.
  2. Build the canonical byte string exactly:
    <role1>:<content1>\n<role2>:<content2>\n...\nmodel:<model>\nowner:<owner_address>\nns:<namespace>
    
    — one role:content\n line per message in messages, in order, then a final line model:...\nowner:...\nns:... with no trailing newline.
  3. Sign those exact bytes with Ed25519 — no prehashing, no re-encoding the string first. Sign the raw UTF-8 bytes directly. The raw 64-byte signature, hex-encoded, is signature_hex.
  4. Send delegate_pubkey_hex and signature_hex as plain JSON fields alongside messages / model / owner_address / namespace.
Server-side verification lives in chat-relayer/src/relayer/src/http.rs::canonical_chat_req (builds the identical string) and src/relayer/src/auth.rs::verify_signature (decodes both hex fields, requires exactly 32 and 64 bytes respectively, rejects anything that doesn’t decode or verify with 401 Unauthorized — not a 400, so check auth first if a client gets rejected before reaching the model).
Common client bugs:
  • Hashing the canonical string before signing — Ed25519 here signs the raw message directly, which most libraries simply call “sign”, not “sign the hash of.”
  • An off-by-one on the newline — there must be no trailing newline after the final ns:<namespace> line.
  • Sending the DER/SPKI-wrapped public key instead of the raw 32-byte key (Node’s crypto.generateKeyPairSync exports DER by default — see the script below for how to strip the wrapper).
  • Sending the signature as base64 instead of hex.

Reference implementation

import crypto from "node:crypto";

const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const rawPub = publicKey.export({ type: "spki", format: "der" }).slice(-32);
const delegatePubkeyHex = Buffer.from(rawPub).toString("hex");

function canonicalChatReq({ messages, model, owner_address, namespace }) {
  let s = "";
  for (const m of messages) s += `${m.role}:${m.content}\n`;
  s += `model:${model}\nowner:${owner_address}\nns:${namespace}`;
  return Buffer.from(s, "utf8");
}

function sign(bytes) {
  const sig = crypto.sign(null, bytes, privateKey);
  return Buffer.from(sig).toString("hex");
}

async function call(base, body) {
  const canonical = canonicalChatReq(body);
  const signature_hex = sign(canonical);
  const payload = { ...body, delegate_pubkey_hex: delegatePubkeyHex, signature_hex };
  const resp = await fetch(`${base}/v1/chat`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(payload),
  });
  return resp.json();
}

Worked example

Canonical bytes signed for a first message:
user:Remember that my favorite color is teal and I'm building Pinaivu in Rust.
model:gemma4-e4b-128k:latest
owner:0xpersisttest1
ns:default
(rendered with line breaks above for readability — the actual signed bytes use \n, not real newlines plus a trailing one) Resulting request:
{
  "messages": [{ "role": "user", "content": "Remember that my favorite color is teal and I'm building Pinaivu in Rust." }],
  "model": "gemma4-e4b-128k:latest",
  "owner_address": "0xpersisttest1",
  "namespace": "default",
  "delegate_pubkey_hex": "79b5370a255aa5a8364510bf602be3b3970b4f2a2242617dc88e10804b53fe71",
  "signature_hex": "2c8cef80690bc2d865185f07c2de51602c1fff7825145cf102cbd5940ae9b8e376123de5f4db3cffdb2945b222d8ba4cb6575629034813ed241d57b3b9870d0b"
}
Resulting response:
{
  "content": "I remember that your favorite color is teal and that you are building Pinaivu using Rust.",
  "session_id": "b7c0bee8-c01f-4392-acfb-0e1a333d386b",
  "session_key": "gavubc6GtzOCw2OEmp+U+YH680f+wXidfy5DJc2EUNc=",
  "request_id": "430bb3aa-2f1a-4e6f-80e0-cd8b8734ba23",
  "recalled_facts": [],
  "latency_ms": 3250
}

owner_address is permanent

owner_address is a key into long-term, cross-session memory — it is not reset between requests, sessions, or days. Re-running a test with the same owner_address surfaces accumulated recalled_facts from every prior call, which is correct behavior (that’s the point of the cross-session memory layer), but it makes isolated test runs hard to read. Use a fresh, unique owner_address (e.g. a UUID) per test run if you want a clean before/after comparison.

Full chat-relayer API reference

Endpoint, request/response shapes, and the two automatic memory layers