WebSocket API

This is the protocol that makes Adama's real-time stuff work — bidirectional communication between clients and documents over a single WebSocket connection. Understanding this layer matters even if you end up using the JavaScript client, because when things go wrong (and they will), you need to know what's happening underneath.

Connection Establishment

Endpoint

Connect to Adama using the regional WebSocket endpoint:

wss://{region}.adama-platform.com/~s

Common regions include:

  • us-east-1 - US East (Primary)
  • us-west-2 - US West

Basic Connection

const ws = new WebSocket('wss://us-east-1.adama-platform.com/~s');

ws.onopen = () => {
  console.log('Connected to Adama');
};

ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  console.log('Received:', message);
};

ws.onerror = (error) => {
  console.error('WebSocket error:', error);
};

ws.onclose = (event) => {
  console.log('Connection closed:', event.code, event.reason);
};

Message Format

All communication uses JSON messages. Every request includes:

Field Type Description
method string The API method name (e.g., "connection/create")
id integer A unique request ID for correlating responses
Additional fields varies Method-specific parameters

Every response includes:

Field Type Description
id integer The request ID this response correlates to
status string "success" or "error"
Additional fields varies Response data or error details

Request-Response Pattern

let requestId = 1;

function sendRequest(method, params) {
  const message = {
    method: method,
    id: requestId++,
    ...params
  };
  ws.send(JSON.stringify(message));
}

// Example: Create a document connection
sendRequest('connection/create', {
  identity: 'your-identity-token',
  space: 'myspace',
  key: 'doc-123',
  'viewer-state': {}
});

Response Handling

const pendingRequests = new Map();

function sendRequest(method, params, callback) {
  const id = requestId++;
  pendingRequests.set(id, callback);

  ws.send(JSON.stringify({
    method: method,
    id: id,
    ...params
  }));
}

ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  const callback = pendingRequests.get(message.id);

  if (callback) {
    if (message.status === 'success') {
      callback(null, message);
    } else {
      callback(new Error(message.reason), null);
    }

    // Only delete for non-streaming responses
    if (!message.stream) {
      pendingRequests.delete(message.id);
    }
  }
};

Authentication

Before doing much of anything, you need an identity token. A few ways to get one:

Developer Account Login

// Step 1: Request email verification
sendRequest('init/setup-account', {
  email: 'developer@example.com'
});

// Step 2: Complete with code from email
sendRequest('init/complete-account', {
  email: 'developer@example.com',
  code: '123456'
}, (err, response) => {
  if (!err) {
    const identity = response.identity;
    // Store this identity for future use
  }
});

Password-Based Login

sendRequest('account/login', {
  email: 'developer@example.com',
  password: 'your-password'
}, (err, response) => {
  if (!err) {
    const identity = response.identity;
  }
});

Document Authorization

Documents can issue their own identities via the @authorize handler:

sendRequest('document/authorize', {
  space: 'myspace',
  key: 'doc-123',
  username: 'user@example.com',
  password: 'user-password'
}, (err, response) => {
  if (!err) {
    const identity = response.identity;
    // This identity is scoped to this document
  }
});

Custom Authorization

For flexible authentication flows, use document/authorization:

sendRequest('document/authorization', {
  space: 'myspace',
  key: 'doc-123',
  message: {
    type: 'oauth',
    provider: 'google',
    token: 'oauth-token-here'
  }
}, (err, response) => {
  if (!err) {
    const identity = response.identity;
  }
});

Document Connections

This is the core of Adama's real-time behavior. A document connection is a persistent link between a client and a document that enables:

  • Real-time state synchronization via deltas
  • Message sending through channels
  • Per-viewer state (viewer-state)

Creating a Connection

sendRequest('connection/create', {
  identity: identity,
  space: 'myspace',
  key: 'doc-123',
  'viewer-state': {}
}, handleConnectionResponse);

The response is a streaming response — you'll receive multiple messages with the same id:

function handleConnectionResponse(err, message) {
  if (err) {
    console.error('Connection error:', err);
    return;
  }

  if (message.delta) {
    // This is a state update
    applyDelta(message.delta);
  }

  if (message.complete) {
    // Connection was closed by server
    console.log('Connection ended');
  }
}

The Connection ID

After the initial response, you get a connection ID for subsequent operations:

let connectionId = null;

function handleConnectionResponse(err, message) {
  if (!err && message.connection) {
    connectionId = message.connection;
  }
  // ... handle deltas
}

Sending Messages

Once connected, send messages to document channels:

sendRequest('connection/send', {
  connection: connectionId,
  channel: 'say',
  message: {
    text: 'Hello, world!'
  }
}, (err, response) => {
  if (!err) {
    console.log('Message sent, seq:', response.seq);
  }
});

Updating Viewer State

Viewer state lets each client customize what the document sends:

sendRequest('connection/update', {
  connection: connectionId,
  'viewer-state': {
    page: 2,
    filter: 'active'
  }
});

Ending a Connection

Always close connections cleanly when you're done:

sendRequest('connection/end', {
  connection: connectionId
});

The Delta Protocol

Adama uses JSON deltas based on RFC 7396 (JSON Merge Patch) with extensions for efficient array handling. This is where the bandwidth savings come from.

Delta Structure

A delta describes changes to the document state:

{
  "delta": {
    "data": {
      "counter": 5,
      "users": {
        "42": {"name": "Alice", "score": 100},
        "@o": [42]
      }
    }
  }
}

Field Updates

Simple field changes are straightforward:

// State before: {"counter": 3}
// Delta:
{"data": {"counter": 5}}
// State after: {"counter": 5}

Nested Object Updates

Deltas merge recursively:

// State before: {"user": {"name": "Alice", "score": 50}}
// Delta:
{"data": {"user": {"score": 100}}}
// State after: {"user": {"name": "Alice", "score": 100}}

Deletions

Set a field to null to delete it:

// State before: {"temp": "value", "permanent": "data"}
// Delta:
{"data": {"temp": null}}
// State after: {"permanent": "data"}

Array/Table Handling

Adama tables show up as objects with numeric keys plus an @o field for ordering:

{
  "data": {
    "items": {
      "1": {"id": 1, "name": "First"},
      "2": {"id": 2, "name": "Second"},
      "@o": [1, 2]
    }
  }
}

When items change:

// Adding item 3 between 1 and 2:
{
  "data": {
    "items": {
      "3": {"id": 3, "name": "Middle"},
      "@o": [1, 3, 2]
    }
  }
}

Efficient Ordering Updates

For large tables, @o uses range notation to avoid sending huge arrays:

// @o can be: [1, 2, 3, 4, 5]
// Or efficiently: [[1, 5]]  (range from 1 to 5 inclusive)
// Mixed: [1, [3, 7], 10]  (1, then 3-7, then 10)

Building a Delta Processor

class AdamaState {
  constructor() {
    this.data = {};
  }

  applyDelta(delta) {
    if (delta.data) {
      this.merge(this.data, delta.data);
    }
  }

  merge(target, patch) {
    for (const key in patch) {
      const value = patch[key];

      if (value === null) {
        delete target[key];
      } else if (typeof value === 'object' && !Array.isArray(value)) {
        if (typeof target[key] !== 'object') {
          target[key] = {};
        }
        this.merge(target[key], value);
      } else {
        target[key] = value;
      }
    }
  }

  // Convert table object to ordered array
  tableToArray(tableObj) {
    if (!tableObj || !tableObj['@o']) return [];

    const order = this.expandOrder(tableObj['@o']);
    return order.map(id => tableObj[id]).filter(Boolean);
  }

  expandOrder(orderSpec) {
    const result = [];
    for (const item of orderSpec) {
      if (Array.isArray(item)) {
        // Range: [start, end]
        for (let i = item[0]; i <= item[1]; i++) {
          result.push(i);
        }
      } else {
        result.push(item);
      }
    }
    return result;
  }
}

Connection Lifecycle

Understanding the lifecycle matters for building anything that doesn't fall apart when the network hiccups.

States

[Disconnected] --> [Connecting] --> [Connected] --> [Disconnected]
                        |               |
                        v               v
                   [Reconnecting] <----+

The @connected Event

When a connection is established, the document's @connected handler fires:

@connected {
  // Client just connected
  // Return true to allow, false to reject
  return true;
}

On the client side, you receive the initial state delta immediately after connection.

The @disconnected Event

When a connection ends — client closes or network failure — @disconnected fires:

@disconnected {
  // Client disconnected
  // Perform cleanup
}

Implementing Reconnection

Network failures are a fact of life. Build reconnection in from the start:

class RobustConnection {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectDelay = 1000;
    this.maxReconnectDelay = 30000;
    this.state = new AdamaState();
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log('Connected');
      this.reconnectDelay = 1000; // Reset delay on success
      this.reestablishConnections();
    };

    this.ws.onclose = () => {
      console.log('Disconnected, reconnecting...');
      this.scheduleReconnect();
    };

    this.ws.onerror = (err) => {
      console.error('WebSocket error:', err);
    };

    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.handleMessage(message);
    };
  }

  scheduleReconnect() {
    setTimeout(() => {
      this.connect();
    }, this.reconnectDelay);

    // Exponential backoff
    this.reconnectDelay = Math.min(
      this.reconnectDelay * 2,
      this.maxReconnectDelay
    );
  }

  reestablishConnections() {
    // Re-create document connections after reconnect
    // The document will send fresh state
  }

  handleMessage(message) {
    if (message.delta) {
      this.state.applyDelta(message.delta);
      this.notifyListeners();
    }
  }
}

Error Handling

Error Response Format

{
  "id": 123,
  "status": "error",
  "reason": 405532
}

The reason is a numeric error code. Common ranges:

Code Meaning
403xxx Authorization/permission errors
404xxx Not found errors
405xxx Method/operation errors
500xxx Server errors

Handling Errors Gracefully

function handleError(errorCode) {
  const category = Math.floor(errorCode / 1000);

  switch (category) {
    case 403:
      console.error('Authorization failed');
      // Redirect to login
      break;
    case 404:
      console.error('Document not found');
      // Show not found UI
      break;
    case 405:
      console.error('Operation not allowed');
      // Show error message
      break;
    default:
      console.error('Server error:', errorCode);
      // Generic error handling
  }
}

Direct Message Sending

For fire-and-forget operations where you don't need to maintain a connection:

sendRequest('message/direct-send', {
  identity: identity,
  space: 'myspace',
  key: 'doc-123',
  channel: 'process',
  message: { action: 'trigger' }
}, (err, response) => {
  if (!err) {
    console.log('Message processed at seq:', response.seq);
  }
});

Idempotent Sending

Use message/direct-send-once with a dedupe key for at-most-once delivery:

sendRequest('message/direct-send-once', {
  identity: identity,
  space: 'myspace',
  key: 'doc-123',
  dedupe: 'unique-operation-id-12345',
  channel: 'process',
  message: { action: 'trigger' }
});

Document Operations

Creating Documents

sendRequest('document/create', {
  identity: identity,
  space: 'myspace',
  key: 'new-doc-456',
  arg: {
    title: 'My New Document',
    owner: 'user@example.com'
  }
}, (err, response) => {
  if (err) {
    console.error('Failed to create document');
  } else {
    console.log('Document created');
  }
});

Listing Documents

sendRequest('document/list', {
  identity: identity,
  space: 'myspace',
  limit: 100
}, (err, response) => {
  // This is a streaming response
  if (response.key) {
    console.log('Document:', response.key, 'created:', response.created);
  }
  if (response.complete) {
    console.log('Listing complete');
  }
});

Practical Advice

Handle reconnection from the start. Network failures happen. If you wait until production to think about it, you'll regret it.

Keep request IDs unique per connection. Reset them when reconnecting.

Apply deltas immediately. Don't buffer them. Apply as they arrive to keep state consistent.

Clean up connections. Always send connection/end before closing the WebSocket so the server can run cleanup logic in @disconnected.

Respect backpressure. If you're sending many messages, check the WebSocket buffer:

function safeSend(message) {
  if (ws.bufferedAmount < 65536) {
    ws.send(JSON.stringify(message));
    return true;
  }
  return false;
}

For most applications, you'll want the JavaScript Client which wraps all of this up nicely. But understanding the raw protocol helps when you're debugging or building a client in a language that doesn't have a library yet.

Previous Api