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()};
}
}
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();
}
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
- Validate early: Check input validity at the start of handlers
- Use @parsed for message validation: Keep parsing and validation separate from business logic
- Mark procedures as aborts: If a procedure can abort, declare it explicitly
- Prefer abort over silent return:
abortprovides 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);
}
}