Channels

If formulas and bubbles are the "read" path — how clients see document state — then channels are the "write" path. They're how clients talk to your document. A channel is a named endpoint that accepts a specific message type, runs some code, and (if everything goes well) mutates document state.

I think of channels as typed procedures exposed to the outside world. They're the API surface of your document.

record ChatLine {
  public principal who;
  public string text;
  public datetime when;
}
table<ChatLine> _chat;

message Say {
  string text;
}

// The channel "say" accepts Say messages from connected clients
channel say(Say msg) {
  // Handle the message - msg.text contains the client's input
  _chat <- {who: @who, text: msg.text, when: Time.datetime()};
}

When a client sends a message to the say channel, the handler runs with access to:

  • msg - the message data sent by the client
  • @who - the principal (identity) of the sender

Complete Channels (With Handlers)

Complete channels have a handler body that processes messages immediately when they arrive. This is the most common pattern.

Basic Syntax

int counter = 0;

message DoSomething {
  int value;
}

channel do_something(DoSomething msg) {
  // Handle the message immediately
  counter += msg.value;
}

With Principal Parameter

Channels can explicitly receive the sender's principal as a parameter:

message ChangeOutput {
  string new_output;
}

string output = "";
principal lastEditor;

channel change_output(ChangeOutput change) {
  output = change.new_output;
  lastEditor = @who;
}

The sender's identity is always available via the @who constant.

Multiple Channels Same Message Type

You can have multiple channels that accept the same message type, providing different behaviors:

message ChangeOutput {
  string new_output;
}

string output = "";
datetime lastModified;

channel change_output(ChangeOutput change) {
  output = change.new_output;
}

channel set_output(ChangeOutput change) {
  output = change.new_output;
  lastModified = Time.datetime();
}

Clients choose which channel to send to based on the desired behavior. Same input shape, different semantics.

Incomplete Channels (For Async)

Incomplete channels have no handler body. They exist to enable async workflows where the document asks a client for input rather than the client initiating the message. This is a flip of the usual client-server relationship — the document drives the conversation.

message SomeDecision {
  string text;
}

channel<SomeDecision> decision;  // No handler body - used for async fetch

Incomplete channels are used with state machines and the fetch method to request input from specific principals. See the State Machines chapter for the full picture.

Basic Async Pattern

message SomeDecision {
  string text;
}

channel<SomeDecision> decision;

private principal player1;
private principal player2;

#getsome {
  // Ask both players for their decisions
  future<SomeDecision> sd1 = decision.fetch(player1);
  future<SomeDecision> sd2 = decision.fetch(player2);

  // Wait for responses
  string text1 = sd1.await().text;
  string text2 = sd2.await().text;

  // Process both responses
}

The beauty of this is the decoupling of asking (fetch) and receiving (await). Both players can respond independently — you issue both fetches, then await both results. Concurrency without threads.

Channel Methods

Incomplete channels provide methods for requesting input from clients.

fetch(principal)

Block until the specified principal sends a message to this channel.

message SomeDecision {
  string text;
}

channel<SomeDecision> decision;
private principal player;

#waiting {
  future<SomeDecision> response = decision.fetch(player);
  SomeDecision result = response.await();
  // Process result
}
Method Return Type Behavior
fetch(principal who) future<T> Block until the principal returns a message

decide(principal, options)

Present a set of options to the principal and let them choose one. Returns maybe<T> since the selection may fail.

enum SquareState { Open, X, O }

record Square {
  public int id;
  public SquareState state;
}

table<Square> _squares;
private principal current;

message Play {
  int id;
}

channel<Play> play;

procedure processMove(int id) {}

#turn {
  // Get available moves
  list<Square> open = iterate _squares where state == SquareState::Open;

  // Convert to message format and let player decide
  if (play.decide(current, @convert<Play>(open)).await() as pick) {
    // Player made a valid selection
    processMove(pick.id);
  }
}
Method Return Type Behavior
decide(principal who, T[] options) future<maybe<T>> Block until the principal returns a message from the given array of options

choose(principal, options, limit)

Like decide, but allows selecting multiple items up to a limit.

message Card {
  int id;
  string suit;
  int value;
}

record HandCard {
  public int id;
  public string suit;
  public int value;
}

table<HandCard> _hand;
private principal currentPlayer;

channel<Card[]> discard;

procedure removeFromHand(int id) {}

#discard_phase {
  list<HandCard> hand = iterate _hand;
  // Let player choose up to 3 cards to discard
  if (discard.choose(currentPlayer, @convert<Card>(hand), 3).await() as selected) {
    // Process the selected cards
    foreach (card in selected) {
      removeFromHand(card.id);
    }
  }
}
Method Return Type Behavior
choose(principal who, T[] options, int limit) future<maybe<T[]>> Block until the principal returns a subset of the given array of options

Array Channels

Channels can accept arrays of messages for batch operations.

message Item {
  int productId;
  int quantity;
}

record InventoryItem {
  public int productId;
  public int quantity;
}
table<InventoryItem> _inventory;

// Incomplete array channel for async multi-select
channel<Item[]> items;

// Complete array channel for direct submission
channel add_items(Item[] items) {
  foreach (item in items) {
    _inventory <- {productId: item.productId, quantity: item.quantity};
  }
}

Array channels are useful for batch submissions, multi-select scenarios, and bulk operations.

Open Channels

The open modifier allows messages to be sent to a channel without requiring a persistent connection. This enables stateless HTTP-style interactions — fire and forget.

int deployments = 0;

message Deployment {
}

channel signal_deployment(Deployment d) open {
  deployments++;
}

Without open, clients must first establish a WebSocket connection (passing @connected checks) before sending messages. With open, clients can send a one-off message without maintaining a connection.

Use Cases for Open Channels

  • Webhooks: External services triggering document updates
  • Fire-and-forget notifications: Events that don't need a persistent session
  • Server-to-server communication: Backend services interacting with documents
record EventRecord {
  public string event;
  public string data;
  public datetime when;
}
table<EventRecord> _events;

message WebhookPayload {
  string event;
  string data;
}

channel webhook(WebhookPayload payload) open {
  // Process webhook - no connection required
  if (@who.isAdamaHost()) {
    _events <- {event: payload.event, data: payload.data, when: Time.datetime()};
  }
}
Note

Even with open channels, validate @who to ensure the sender is authorized. The open modifier only removes the connection requirement — it doesn't bypass authentication. Don't skip the check.

The @connected Event

The @connected event controls whether a client can establish a persistent connection to the document. It runs when a client attempts to connect and must return true to allow the connection.

@connected {
  return true;  // Allow all connections
}

Access to @who

Within @connected, you have access to @who — the principal attempting to connect:

public principal owner;
bool open_to_public = false;

@connected {
  if (@who == owner) {
    return true;  // Owner can always connect
  }
  return open_to_public;  // Others depend on setting
}

Connection Authorization Patterns

Allow only the owner:

principal owner;

@connected {
  return @who == owner;
}

Allow from a whitelist:

record AllowedUser {
  public int id;
  public principal who;
}

table<AllowedUser> _allowed;

@connected {
  return (iterate _allowed where who == @who).size() > 0;
}

Track active connections:

record Presence {
  public int id;
  public principal who;
  public bool active;
}

table<Presence> _presence;

@connected {
  list<Presence> existing = iterate _presence where who == @who;
  if (existing.size() == 0) {
    _presence <- {who: @who, active: true};
  } else {
    existing.active = true;
  }
  return true;
}

Mutating State on Connect

Since @connected runs within the document context, it can modify document state:

public int active_connections = 0;

@connected {
  active_connections++;
  return true;
}

The @disconnected Event

The @disconnected event fires when a client's connection ends — whether intentionally or because the network died. Use it for cleanup.

record Presence {
  public int id;
  public principal who;
  public bool active;
}
table<Presence> _presence;

@disconnected {
  // Cleanup for the disconnecting user
  (iterate _presence where who == @who).active = false;
}

Common Cleanup Patterns

Decrement connection counter:

public int active_connections = 0;

@disconnected {
  active_connections--;
}

Mark user as away:

record Player {
  public int id;
  public principal who;
  public string status;
}
table<Player> _players;

@disconnected {
  (iterate _players where who == @who).status = "away";
}

Clean up user-owned resources:

record TempItem {
  public int id;
  public principal owner;
}
table<TempItem> _temp_items;

@disconnected {
  // Remove temporary data owned by disconnecting user
  (iterate _temp_items where owner == @who).delete();
}
Note

The @disconnected event is informational — it cannot prevent disconnection or return a value. The user is already gone; you're just doing cleanup.

Error Handling

When a message is invalid or processing fails, you can abort the handler to reject the message and roll back any state changes. This is one of the things I'm proudest of in Adama's design — every channel handler is transactional. If anything goes wrong, you abort and the document state is as if the message never arrived.

The abort Statement

The abort statement immediately stops execution and rejects the message. All state changes made during the handler are rolled back, and the client receives an error response.

int playerBalance = 100;
int currentPot = 0;

message Bet {
  int amount;
}

channel place_bet(Bet bet) {
  // Validate input - abort if invalid
  if (bet.amount <= 0) {
    abort;  // Rejects message, rolls back state
  }

  if (bet.amount > playerBalance) {
    abort;  // Cannot bet more than you have
  }

  // Only reaches here if validation passes
  playerBalance -= bet.amount;
  currentPot += bet.amount;
}

When abort is called:

  • All state changes in the current handler are rolled back
  • The message is not processed
  • The client receives an error response

No partial mutations, no inconsistent state. This is the right way to handle validation failures.

Calling Abortable Procedures

Procedures that may abort must be marked with the aborts modifier. This propagates the abort behavior to the channel handler.

int maxBet = 1000;

message Bet {
  int amount;
}

procedure processBet(int amount) {
}

// Mark the procedure as potentially aborting
procedure validate_bet(int amount) aborts {
  if (amount <= 0) {
    abort;
  }
  if (amount > maxBet) {
    abort;
  }
}

channel place_bet(Bet bet) {
  // Calling an aborts procedure - if it aborts, this handler aborts too
  validate_bet(bet.amount);

  // Only reaches here if validation passes
  processBet(bet.amount);
}

Methods on records can also use the aborts modifier:

message Purchase {
  int playerId;
  int amount;
}

record Player {
  private int balance;

  method deduct(int amount) aborts {
    if (amount > balance) {
      abort;  // Insufficient funds
    }
    balance -= amount;
  }
}

table<Player> _players;

channel purchase(Purchase p) {
  if ((iterate _players where id == p.playerId)[0] as player) {
    player.deduct(p.amount);  // May abort if insufficient funds
  }
}

Message Validation with @parsed

Messages can include a @parsed event handler for input validation before the channel handler runs. I like this pattern because it keeps validation intrinsic to the message — the message itself knows what valid data looks like.

message UserInput {
  int value;
  string name;

  @parsed {
    // Validate immediately after parsing
    if (value < 0) {
      abort;  // Reject negative values
    }
    if (name.length() == 0) {
      abort;  // Reject empty names
    }
    if (name.length() > 100) {
      abort;  // Reject overly long names
    }
  }
}

procedure processInput(UserInput input) {
}

channel submit(UserInput input) {
  // By the time we get here, validation has passed
  // input.value is guaranteed non-negative
  // input.name is guaranteed non-empty and <= 100 chars
  processInput(input);
}

The @parsed event runs immediately after the message is deserialized, before the channel handler executes. Good for:

  • Range checking numeric values
  • Validating string lengths
  • Normalizing data (like taking absolute values)
  • Rejecting obviously invalid input early
message Coordinates {
  int x;
  int y;

  @parsed {
    // Normalize to absolute values
    x = x.abs();
    y = y.abs();

    // Validate range
    if (x > 1000 || y > 1000) {
      abort;
    }
  }
}

Authentication Abort Patterns

The @authorize handler uses abort to reject invalid authentication attempts:

@authorize (username, password) {
  if (validateCredentials(username, password)) {
    return "authenticated_agent";  // Return agent identifier
  }
  abort;  // Invalid credentials - reject
}

Error Responses to Clients

When a channel handler aborts:

  • The client receives an error response (HTTP 500-level or WebSocket error frame)
  • No state changes are persisted
  • The document remains in its pre-message state

This transactional behavior is the whole point. Your document never ends up in an inconsistent state due to validation failures or unexpected conditions.

Best Practices for Error Handling

  1. Validate early: Check input validity at the start of handlers
  2. Use @parsed for message validation: Keep parsing and validation separate from business logic
  3. Mark procedures as aborts: If a procedure can abort, declare it explicitly
  4. Prefer abort over silent return: abort provides clear feedback to clients; silent returns make debugging hard
message Input {
  int value;
}

// Bad: Silent failure gives no feedback
channel bad_example(Input i) {
  if (i.value < 0) {
    return;  // Client thinks it succeeded
  }
  // ...
}

// Good: Explicit abort signals failure
channel good_example(Input i) {
  if (i.value < 0) {
    abort;  // Client receives error response
  }
  // ...
}

Channel Guards

Channel handlers can include guard logic to conditionally accept or reject messages based on document state or the sender's identity.

Declarative Guards with requires

The requires<policy_name> keyword provides a declarative guard that runs before the channel body. If the policy returns false, the message is rejected and the handler never executes:

record Person {
  public int id;
  public principal account;
  public bool is_admin;
}
table<Person> _people;

message JustId { int id; }
message DraftCoords { int x; int y; }
message UpdateData { int id; int value; }

record Bulletin { public int id; public int x; public int y; }
table<Bulletin> _bulletins;

record DataItem { public int id; public int value; }
table<DataItem> _data;

policy is_admin {
  if ((iterate _people where account == @who)[0] as person) {
    return person.is_admin;
  }
  return false;
}

policy is_staff {
  return true;
}

policy is_admin_or_staff {
  return true;
}

// Only admins can delete people
channel person_delete(JustId jid) requires<is_admin> {
  (iterate _people where id == jid.id).delete();
}

// Only staff can create bulletins
channel bulletin_create(DraftCoords dc) requires<is_staff> {
  _bulletins <- dc as new_id;
}

// Combined with other modifiers
channel update_sensitive(UpdateData d) requires<is_admin_or_staff> {
  if ((iterate _data where id == d.id)[0] as item) {
    item <- d;
  }
}

This is cleaner than manually checking permissions inside the handler body, and it's what I recommend for production code. Put the authorization at the door, not scattered throughout the room.

Imperative Guards

Imperative guards use conditional logic within the handler body to reject messages. They can either silently reject (using return) or explicitly fail (using abort).

message Move { int x; int y; }
private principal currentPlayer;
private bool gameOver = false;

procedure processMove(Move m) {}

channel make_move(Move m) {
  // Guard: only current player can move
  if (@who != currentPlayer) {
    abort;  // Not your turn - reject with error
  }

  // Guard: game must be in progress
  if (gameOver) {
    abort;  // Game is over - reject with error
  }

  // Process the valid move
  processMove(m);
}

Authorization Checks

message AdminCommand { string action; }
private principal owner;

procedure executeAdminCommand(AdminCommand cmd) {}

channel admin_action(AdminCommand cmd) {
  // Only owner can perform admin actions
  if (@who != owner) {
    abort;  // Unauthorized - reject with error
  }

  // Process admin command
  executeAdminCommand(cmd);
}

State-Based Guards

message Bet {
  int amount;
}

record BetRecord {
  public int id;
  public principal player;
  public int amount;
}

table<BetRecord> _bets;

private label phase = #betting;

#betting {}

channel place_bet(Bet bet) {
  // Can only bet during betting phase
  if (phase != #betting) {
    abort;  // Wrong phase - reject
  }

  // Can only bet once per round
  if ((iterate _bets where player == @who).size() > 0) {
    abort;  // Already bet - reject
  }

  // Process bet
  _bets <- {player: @who, amount: bet.amount};
}

Combining Guards with Validation

message Answer { string text; }
private principal currentPlayer;
private bool questionActive = false;

procedure checkAnswer(string text) {}

channel submit_answer(Answer a) {
  // Authorization guard
  if (@who != currentPlayer) {
    abort;  // Not the current player
  }

  // State guard
  if (!questionActive) {
    abort;  // No active question
  }

  // Validation guard
  if (a.text.length() == 0) {
    abort;  // Empty answer not allowed
  }

  // All guards passed - process answer
  checkAnswer(a.text);
}

Putting It Together

Here's a complete example showing various channel patterns in a simple game:

// Message types
message JoinGame {}
message LeaveGame {}
message MakeMove { int position; }
message ServerEvent { string type; }

// Record types
record Player {
  public int id;
  public principal who;
  public bool active;
  public int score;
}

// Document state
public principal owner;
table<Player> _players;
public int currentTurn = 0;
public bool gameStarted = false;

procedure processMove(int position) {
}

procedure handleServerEvent(ServerEvent e) {
}

// Connection handling
@connected {
  return true;  // Anyone can connect
}

@disconnected {
  // Mark player as inactive when they disconnect
  (iterate _players where who == @who).active = false;
}

// Complete channel: join the game
channel join(JoinGame msg) {
  if (gameStarted) {
    abort;  // Cannot join after game starts
  }
  if ((iterate _players where who == @who).size() > 0) {
    abort;  // Already joined
  }
  _players <- {who: @who, active: true, score: 0};
}

// Complete channel: leave the game
channel leave(LeaveGame msg) {
  (iterate _players where who == @who).delete();
}

// Complete channel with guards: make a move
channel move(MakeMove m) {
  // Guard: game must be started
  if (!gameStarted) {
    abort;  // Game not started yet
  }

  // Guard: must be player's turn
  if ((iterate _players offset currentTurn limit 1)[0] as currentP) {
    if (currentP.who != @who) {
      abort;  // Not your turn
    }
  } else {
    abort;
  }

  // Process move
  processMove(m.position);
  maybe<int> nextTurn = (currentTurn + 1) % (iterate _players).size();
  if (nextTurn as nt) {
    currentTurn = nt;
  }
}

// Open channel: for server notifications
channel server_event(ServerEvent e) open {
  if (@who.isAdamaHost()) {
    handleServerEvent(e);
  }
}
Previous Privacy