API Documentation

Introduction

API Version

  • Current version: v1
  • Base URL: https://api.output.app/v1

Response Format

All responses follow a consistent structure:

{
  "ok": true|false,
  "data": { ... },
  "errors": [ ... ]
}

Request Headers

  • Authorization: Bearer {token} - Authentication token (optional if using session cookies)
  • X-Workspace-Id: {id} - Workspace identifier (required)
  • X-Shadow-Id: {id} - Shadow identifier (for anonymous/shared channel access)
  • X-Shared-Channel-Key: {key} - Shared channel key (for shared channel access)

Authentication & Authorization

Authentication Methods

  1. Bearer Token: Authorization: Bearer {auth_token}
  2. Session Cookie: Standard HTTP session cookie
  3. Shadow Access: Anonymous access via shadow ID (shared channels only)

Authorization Levels

  • User: Authenticated user with profile
  • Shadow: Anonymous user (limited access)
  • Workspace Admin: Profile with admin role
  • Channel Member: Profile with membership in channel

Workspace Context

Workspace is specified via the X-Workspace-Id header

POST /v1/session

Create a session (login).

Required fields: email, password

Request:

{
  "email": "[email protected]",
  "password": "password123"
}

Response:

{
  "ok": true,
  "user": {
    "id": "abc123",
    "auth_token": "token_here",
  }
}

Access: Public (no authentication required)

GET /v1/current

Get current user/workspace/profile context and configuration. Use without workspace context to list all workspaces, or with X-Workspace-Id header to get detailed workspace/profile data.

Response (without workspace context):

{
  "ok": true,
  "configs": { ... },
  "workspaces": [
    { "id": "ws_123", "title": "My Workspace", ... },
    { "id": "ws_456", "title": "Another Workspace", ... }
  ]
}

Response (with workspace context):

{
  "ok": true,
  [everything from the response without workspace context]
  "workspace": { "id": "ws_123", "title": "My Workspace", ... },
  "profile": { "id": "prof_123", "full_name": "John Doe", ... },
  "feature_flags": { ... },
  "last_viewed_channel": { ... }
  "last_viewed_agent_chat": { ... }
}

Access: Requires authentication (user or shadow)

Real-time Updates & Synchronization

Overview: The Journal System

The API uses a journal-based architecture to keep clients synchronized with workspace data. Every change to workspace resources (messages, channels, profiles, emojis, etc.) is recorded as a journal entry with a timestamp. This enables two complementary synchronization strategies:

  • Real-time subscriptions - Receive instant updates via WebSocket as changes happen (ideal for active users)
  • Catch-up synchronization - Fetch missed changes by time range (essential for offline periods or reconnection)

Both approaches use the same underlying journal system, ensuring consistency regardless of how your client receives updates.

What is a Journal Entry?

A journal entry represents a single change to a workspace resource. Each entry contains:

  • Action - The type of change: create, update, or destroy
  • Timestamp - When the change occurred (ULID-based for ordering)
  • Data - The full resource state after the change (or deletion metadata)
  • Reference - What type of resource changed (message, channel, profile, etc.)
// example journal entry
{
  "action": "create",
  "sort_order": "01HN8XYZ123...",  // timestamp-based ULID
  "data": {
    "message": {
      "id": "msg_123",
      "text": "<p>Hello world</p>",
      "channel_id": "ch_456",
      // ... full message data
    }
  }
}

Collections & The Manifest

Workspace data is organized into collections, which are logical groupings of related journal entries. The manifest lists all collections you have access to and provides signed keys for subscribing to their updates.

Each manifest entry contains:

  • collection_name - The scope of the collection:
    • root - Workspace-wide resources (workspace settings, emojis)
    • {profile_id} - Profile-specific resources (channel memberships, read markers, agent chats)
    • {channel_id} - Channel-specific resources (messages, reactions)
  • reference_kind - The type of resource in this collection (message, channel, emoji, etc.)
  • key - Cryptographically signed authorization token for accessing this collection's journal entries
// example manifest entry
{
  "collection_name": "ch_abc123",     // channel-specific collection
  "reference_kind": "message",        // contains message changes
  "key": "eyJhbGciOiJIUzI1NiJ9..."   // signed key for authorization
}

Why separate collections? This design allows fine-grained access control (you only receive updates for channels you're a member of) and efficient subscriptions (subscribe only to what you need).

Fetching the Manifest

GET /v1/journals/manifest

Get the manifest listing all collections you have access to.

Response:

{
  "ok": true,
  "collections": [
    {
      "collection_name": "root",
      "reference_kind": "workspace",
      "key": "eyJhbGciOiJIUzI1NiJ9..."
    },
    {
      "collection_name": "root",
      "reference_kind": "emoji",
      "key": "eyJhbGciOiJIUzI1NiJ9..."
    },
    {
      "collection_name": "prof_123",
      "reference_kind": "channel_membership",
      "key": "eyJhbGciOiJIUzI1NiJ9..."
    },
    {
      "collection_name": "ch_abc",
      "reference_kind": "message",
      "key": "eyJhbGciOiJIUzI1NiJ9..."
    }
  ]
}

Access: Requires authenticated user

Usage: Fetch the manifest once on app initialization, then use the signed keys for both real-time subscriptions and catch-up fetches. Refresh the manifest when joining new channels or when your access permissions change.

Real-time Updates via WebSocket

When to use: For active users who need instant updates. Real-time subscriptions deliver journal entries as they're created, providing immediate UI updates without polling.

GET /v1/cable

Get ActionCable WebSocket connection URL with JWT authentication.

Response:

{
  "ok": true,
  "cable": {
    "url": "wss://cable.output.app/cable?token=eyJhbG..."
  }
}

Access: Requires workspace access (shadows allowed)

Connecting to the WebSocket

The WebSocket connection uses AnyCable for real-time updates. To establish a connection:

  1. Call GET /v1/cable to obtain the WebSocket URL with embedded JWT token
  2. Connect to the returned URL using an ActionCable-compatible client
  3. Subscribe to channels to receive real-time updates for workspaces, channels, messages, etc.

Setting Up the Cable Client

Use an ActionCable-compatible client library. For JavaScript, we recommend @anycable/web:

import { createCable } from '@anycable/web';
import { get } from '@rails/request.js';

const cable = createCable({
  protocol: 'actioncable-v1-ext-json',
  protocolOptions: { pongs: true },
  tokenRefresher: async (transport) => {
    // automatically refresh token when it expires
    const response = await get('/v1/cable');
    const data = await response.json();

    if (data.ok) {
      transport.setURL(data.data.cable.url);
    } else {
      console.error('Failed to refresh cable token', data);
    }
  }
});

// listen for connection events
cable.on('connect', (event) => {
  console.log('Cable connected', { reconnect: event.reconnect });
});

cable.on('disconnect', (event) => {
  console.log('Cable disconnected', { reason: event.reason });
});

Note: The cable client automatically connects when you create the first subscription. You do not need to manually call cable.connect().

Subscribing to Channels

Subscribe to channels using cable.subscribeTo(channelName, params). The primary channel for real-time updates is SignalJournalChannel:

// subscribe to a journal collection
const subscription = cable.subscribeTo('SignalJournalChannel', {
  workspace_id: workspaceId,
  signed_signal_journal_key: signedKey
});

// handle incoming messages
subscription.on('message', async (message) => {
  const { action, data } = message;

  // process the journal event
  switch (action) {
    case 'create':
      console.log('Resource created:', data);
      break;
    case 'update':
      console.log('Resource updated:', data);
      break;
    case 'destroy':
      console.log('Resource deleted:', data);
      break;
  }
});

// store the unbind handle for cleanup
const unbind = subscription.on('message', handler);

// unsubscribe when done
subscription.unsubscribe();
unbind(); // remove message handler

Managing Multiple Subscriptions

For applications that track multiple collections, organize subscriptions by type:

// organize subscriptions by collection type
const subscriptions = {
  root: new Map(),      // workspace-wide resources
  profile: new Map(),   // profile-specific resources
  channels: new Map()   // channel-specific resources
};

// subscribe to manifest collections
async function subscribeToManifest(manifest) {
  for (const entry of manifest) {
    const { collection_name, reference_kind, key } = entry;

    // avoid duplicate subscriptions
    const existingMap = subscriptions[collection_name];
    if (existingMap?.has(reference_kind)) continue;

    // create subscription
    const subscription = cable.subscribeTo('SignalJournalChannel', {
      workspace_id: workspaceId,
      signed_signal_journal_key: key
    });

    // handle messages
    subscription.on('message', (msg) => {
      handleJournalEvent(msg, reference_kind, collection_name);
    });

    // store subscription
    if (!subscriptions[collection_name]) {
      subscriptions[collection_name] = new Map();
    }
    subscriptions[collection_name].set(reference_kind, subscription);
  }
}

// unsubscribe from all
function unsubscribeAll() {
  for (const map of Object.values(subscriptions)) {
    for (const subscription of map.values()) {
      subscription.unsubscribe();
    }
    map.clear();
  }
}

Token Expiration & Automatic Refresh

Important: The JWT token embedded in the cable URL expires periodically. When using the tokenRefresher option (shown above), the client automatically:

  • Detects when the token is about to expire
  • Calls your tokenRefresher function to get a new URL
  • Reconnects to the WebSocket with the fresh token
  • Automatically re-subscribes to all active channels

If you're not using automatic token refresh, you must handle reconnection manually:

cable.on('disconnect', async (event) => {
  if (event.reason === 'token_expired') {
    // fetch new cable URL
    const response = await fetch('/v1/cable');
    const data = await response.json();

    // reconnect with new URL
    cable.setURL(data.data.cable.url);

    // re-subscribe to all channels
    await resubscribeToChannels();
  }
});

Available Channels

The primary channel for real-time data synchronization is:

  • SignalJournalChannel - Real-time journal events for collections (messages, channels, profiles, emojis, reactions, etc.)

Parameters:

  • workspace_id - The workspace identifier
  • signed_signal_journal_key - Signed key from manifest (obtained via GET /v1/journals/manifest)

Catch-up Synchronization

When to use: Catch-up sync is essential for fetching missed changes during offline periods, app startup, or after WebSocket reconnections. It queries journal entries by timestamp range, ensuring you never miss updates even if the real-time connection was interrupted.

How It Works

Catch-up synchronization uses a simple time-range query pattern:

  1. Track the last sync timestamp for each collection (store locally)
  2. On reconnection or app launch, query for entries since your last timestamp
  3. Process returned journal entries to update your local state
  4. Update your last sync timestamp
  5. Resume real-time subscriptions

Typical Flow

// on app start or reconnection
const lastSyncTime = getLastSyncTimestamp(); // timestamp from local storage
const manifest = await fetchManifest();

// fetch missed changes for each collection
for (const collection of manifest.collections) {
  let hasMore = true;
  let since = lastSyncTime; // starts as timestamp, becomes ULID

  while (hasMore) {
    const result = await fetchJournalEntries({
      keys: [collection.key],
      since: since,
      until: Date.now()
    });

    // process entries
    for (const entry of result.journal_entries) {
      applyJournalEntry(entry);
      since = entry.sort_order; // now a ULID for pagination
    }

    hasMore = result.has_more;
  }
}

// update sync timestamp
setLastSyncTimestamp(Date.now());

// now safe to show UI and start real-time subscriptions

POST /v1/journals/fetch

Fetch journal entries by time range for one or more collections.

Required fields: since, until, keys

Request:

{
  "since": "01HN8XYZ..." | 1234567890,
  "until": "01HN9ABC..." | 1234567899,
  "keys": [
    "eyJhbGciOiJIUzI1NiJ9...",
    "eyJhbGciOiJIUzI1NiJ9..."
  ]
}

Note: The since and until parameters accept either ULIDs or Unix timestamps in milliseconds. Use timestamps for initial queries (e.g., Date.now()), then use the sort_order from the last entry as a ULID for pagination.

Response:

{
  "ok": true,
  "journal_entries": [
    {
      "action": "create",
      "sort_order": "01HN8XYZ123...",
      "data": {
        "message": { /* full message object */ }
      }
    },
    {
      "action": "update",
      "sort_order": "01HN8ZAB456...",
      "data": {
        "channel": { /* full channel object */ }
      }
    }
  ],
  "has_more": false,  // true if more entries exist beyond this page
  "warnings": []      // any non-fatal issues (e.g. expired keys)
}

Access: Requires authenticated user

Pagination

If has_more is true, use the last entry's sort_order ULID as the new since value for the next request:

// start with timestamp, switch to ULIDs for pagination
let cursor = Date.now();  // initial: timestamp in milliseconds
let hasMore = true;

while (hasMore) {
  const response = await fetch('/v1/journals/fetch', {
    method: 'POST',
    body: JSON.stringify({
      since: cursor,
      until: Date.now(),
      keys: collectionKeys
    })
  });
  const data = await response.json();

  // process entries
  for (const entry of data.journal_entries) {
    applyJournalEntry(entry);
  }

  // update cursor to last entry's ULID for pagination
  if (data.journal_entries.length > 0) {
    cursor = data.journal_entries[data.journal_entries.length - 1].sort_order;
  }

  hasMore = data.has_more;
}

Best Practices

  • Track timestamps per collection - Different collections may sync at different rates
  • Batch fetch multiple collections - Pass multiple keys in one request to reduce round trips
  • Handle offline periods gracefully - Store a "last online" timestamp and catch up on reconnection
  • Process entries idempotently - You may receive the same entry from both catch-up and real-time, ensure your processing logic handles duplicates
  • Validate entry timestamps - Entries use ULID sort orders which embed timestamps, useful for debugging sync issues

Combining Real-time & Catch-up

A robust client uses both strategies together:

// on app start
await performCatchupSync(); // get to current state
await subscribeRealtime();  // start receiving live updates

// on reconnection after disconnect
cable.on('connect', async (event) => {
  if (event.reconnect) {
    // fetch missed changes while we were disconnected
    await performCatchupSync();
    // subscriptions automatically re-establish
  }
});

// on visibility change (tab becomes active)
document.addEventListener('visibilitychange', async () => {
  if (document.visibilityState === 'visible') {
    // catch up on changes that happened while tab was hidden
    await performCatchupSync();
  }
});

Workspace Management

PATCH /v1/workspace

Update workspace settings. All fields optional.

Request:

{
  "workspace": {
    "title": "My Workspace",
    "slug": "my-workspace",
    "agent_rules": "...",
    "flexicon": {
      "kind": "glyph",
      "glyph": {
        "id": "glyph_abc123"
      }
    }
  }
}

Access: Requires workspace admin

POST /v1/workspace/regenerate_invite_key

Regenerate workspace invite key.

Access: Requires workspace admin

DELETE /v1/workspace/leave

Leave the current workspace.

Access: Requires authenticated user

DELETE /v1/workspace/remove_member

Remove a member from the workspace.

Example:

DELETE /v1/workspace/remove_member?profile_id=profile123

Access: Requires workspace admin

DELETE /v1/workspace/deactivate

Deactivate the workspace.

Access: Requires workspace admin

Profile Management

GET /v1/profiles

List all profiles in the workspace.

Access: Requires authenticated user

GET /v1/profiles/:id

Get a specific profile.

Access: Requires authenticated user

PATCH /v1/profiles/:id

Update own profile. All fields optional.

Request:

{
  "profile": {
    "full_name": "John Doe",
    "time_zone": "America/New_York",
    "theme": "dark",
    "flexicon": {
      "kind": "letter",
      "letter": {
        "color": "#3b82f6"
      }
    },
    "agent_rules": "...",
    "preferences": {
      "notifications_enabled": true,
      "show_avatars": true
    }
  }
}

Access: Requires own profile

PATCH /v1/profiles/:id/update_role

Update profile role (admin only).

Required fields: role

Request:

{
  "profile": {
    "role": "admin"|"member"
  }
}

Access: Requires workspace admin

Channel Management

GET /v1/channels

List accessible channels (public channels + channels user is member of).

Access: Requires authenticated user

GET /v1/channels/read_markers

Get read markers and unread skeletons for all channels.

Response:

{
  "ok": true,
  "read_markers": [ ... ],
  "skeletons": [ ... ],
  "conference_transcripts": [ ... ]
}

Access: Requires authenticated user

POST /v1/channels/direct_message

Create or retrieve a direct message channel for one or more profiles.

Required fields: profile_ids

Request:

{
  "profile_ids": ["profile_123", "profile_456"]
}

Access: Requires authenticated user

GET /v1/channels/:id

Get a specific channel.

Access: Requires channel membership or public channel

POST /v1/channels

Create a new channel.

Required fields: channel.name

Optional fields: kind, parent, channel.agent_rules, channel.flexicon, channel_section_id

Request:

{
  "kind": "public",
  "parent": "channel_123",
  "channel": {
    "name": "General",
    "agent_rules": "...",
    "flexicon": {
      "kind": "glyph",
      "glyph": {
        "id": "glyph_abc123"
      }
    }
  },
  "channel_section_id": "section_123"
}

Access: Requires authenticated user

PATCH /v1/channels/:id

Update a channel. All fields optional.

Request:

{
  "channel": {
    "name": "Updated Channel Name",
    "agent_rules": "...",
    "flexicon": {
      "kind": "image",
      "image": {
        "id": "att_xyz789"
      }
    }
  }
}

Access: Requires channel membership

DELETE /v1/channels/:id

Delete a channel.

Access: Requires workspace admin (cannot delete last public channel)

POST /v1/channels/:id/mark_as_read

Mark channel as read.

Response:

{
  "ok": true,
  "last_read_sort_order": "01HN8XYZ..."
}

Access: Requires channel membership

Message Management

GET /v1/channels/:channel_id/messages

List messages in a channel.

Example:

GET /v1/channels/ch_123/messages?order=desc&offset=01HN8XYZ...

Response:

{
  "ok": true,
  "messages": [ ... ],
  "has_more": true
}

Access: Requires channel access

GET /v1/channels/:channel_id/messages/:id

Get a specific message.

Access: Requires channel access

POST /v1/channels/:channel_id/messages

Create a new message. Must provide either text or attachments.

Optional fields: optimistic_sort_order, reply_to_message_id

Request:

{
  "message": {
    "text": "<p>Hello world!</p>",
    "optimistic_sort_order": "01HN8XYZ...",
    "attachments": "att_123,att_456",
    "reply_to_message_id": "msg_789"
  }
}

Access: Requires channel membership

PATCH /v1/channels/:channel_id/messages/:id

Update a message. All fields optional.

Request:

{
  "message": {
    "text": "<p>Updated message text</p>",
    "attachments": "att_123",
    "reply_to_message_id": ""
  }
}

Access: Requires message ownership

DELETE /v1/channels/:channel_id/messages/:id

Delete a message.

Access: Requires message ownership

Agent Chats

GET /v1/agent_chats

List agent chats for current profile.

Access: Requires authenticated user

GET /v1/agent_chats/:id

Get a specific agent chat.

Access: Requires authenticated user

POST /v1/agent_chats

Create a new agent chat.

Access: Requires authenticated user

DELETE /v1/agent_chats/:id

Delete an agent chat.

Access: Requires authenticated user

POST /v1/agent_chats/context

Add context to an agent chat. Must provide either channel_id or message_id.

Optional fields: agent_chat_id, metadata, priority

Request:

{
  "agent_chat_id": "chat_123",
  "channel_id": "channel_456",
  "message_id": "msg_789",
  "metadata": {
    "key": "value"
  },
  "priority": 1
}

Access: Requires authenticated user

GET /v1/agent_chats/:agent_chat_id/messages

List messages in an agent chat.

Access: Requires authenticated user

POST /v1/agent_chats/:agent_chat_id/messages

Create a new agent chat message (request to agent).

Required fields: user_text

Optional fields: attachments

Request:

{
  "agent_chat_message": {
    "user_text": "<p>Can you help me with this?</p>",
    "attachments": "att_123,att_456"
  }
}

Access: Requires authenticated user

Message Reactions

POST /v1/channels/:channel_id/messages/:message_id/reactions

Create a reaction on a message.

Required fields: emoji_id

Optional fields: pos_x, pos_y, scale, angle, optimistic_sort_order

Request:

{
  "message_reaction": {
    "emoji_id": "emoji_123",
    "pos_x": 50,
    "pos_y": 100,
    "scale": 1.2,
    "angle": 15,
    "optimistic_sort_order": "01HN8XYZ..."
  }
}

Access: Requires channel access

PATCH /v1/channels/:channel_id/messages/:message_id/reactions/:id

Update a reaction (position, scale, angle). All fields optional.

Request:

{
  "message_reaction": {
    "pos_x": 75,
    "pos_y": 120,
    "scale": 1.5,
    "angle": 30
  }
}

Access: Requires reaction ownership

DELETE /v1/channels/:channel_id/messages/:message_id/reactions/:id

Delete a reaction.

Access: Requires reaction ownership

Shared Channels

Shared channels allow public access to specific channels via a share key, without requiring authentication. They use the same journal-based synchronization system as authenticated access.

GET /v1/shared_channels/:share_key

Get shared channel information.

Headers: X-Shared-Channel-Key: {share_key}

Response:

{
  "ok": true,
  "channel": { ... },
  "profiles": [ ... ]
}

Access: Public (requires share key)

GET /v1/shared_channels/:share_key/manifest

Get journal manifest for shared channel. Works identically to /v1/journals/manifest but scoped to the shared channel's collections.

Response:

{
  "ok": true,
  "collections": [
    {
      "collection_name": "ch_abc123",
      "reference_kind": "message",
      "key": "eyJhbGciOiJIUzI1NiJ9..."
    },
    {
      "collection_name": "ch_abc123",
      "reference_kind": "message_reaction",
      "key": "eyJhbGciOiJIUzI1NiJ9..."
    }
  ]
}

Access: Public (requires share key)

POST /v1/shared_channels/:share_key/fetch

Fetch journal entries for shared channel. Works identically to /v1/journals/fetch but scoped to the shared channel.

Request:

{
  "since": "01HN8XYZ...",
  "until": "01HN9ABC...",
  "keys": ["eyJhbGciOiJIUzI1NiJ9..."]
}

Response:

{
  "ok": true,
  "journal_entries": [ ... ],
  "has_more": false,
  "warnings": []
}

Access: Public (requires share key)

Synchronization Pattern

Shared channels use the same synchronization pattern as authenticated access:

  1. Fetch manifest using the share key
  2. Use signed keys from manifest for catch-up sync and real-time subscriptions
  3. Subscribe to SignalJournalChannel for real-time updates

Note: All requests to shared channel endpoints must include the X-Shared-Channel-Key header with the share key.

Emojis

GET /v1/emojis

List all emojis for the workspace (including deactivated).

Access: Requires workspace access (shadows allowed)

POST /v1/emojis

Create a custom emoji.

Required fields: shortcode, custom

Request:

{
  "emoji": {
    "shortcode": "my_emoji",
    "custom": "att_123"
  }
}

Access: Requires authenticated user

PATCH /v1/emojis/:id

Update an emoji (sort order).

Required fields: sort_order

Optional fields: rebalance

Request:

{
  "sort_order": 1000,
  "rebalance": true
}

Access: Requires authenticated user

DELETE /v1/emojis/:id

Deactivate an emoji.

Access: Requires authenticated user

Media Attachments

POST /v1/media_attachments

Create a media attachment. For images, include thumbhash and thumbsize for optimal loading.

Required fields: purpose, file_name, content_type, content_length, content_md5

Optional fields: thumbhash, thumbsize

Request:

{
  "media_attachment": {
    "purpose": "message_attachments",
    "file_name": "photo.jpg",
    "content_type": "image/jpeg",
    "content_length": 524288,
    "content_md5": "abc123...",
    "thumbhash": "3OcRJYB4d3h/iIeHeFh3eIhw+j3A",
    "thumbsize": "640x480"
  }
}

Response:

{
  "ok": true,
  "media_attachment": { ... },
  "lambda_payload": { ... },
  "presigned_upload_url": "https://..."
}

Access: Requires authenticated user or shadow

PATCH /v1/media_attachments/:id

Update a media attachment (typically after Lambda processing).

Required fields: jwt

Request:

{
  "jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Access: Requires authenticated user or shadow

Error Handling

Error Response Format

{
  "ok": false,
  "errors": ["error_code", "specific_error_code"],
  "validations": { ... }
}

Common Error Codes

  • not_found - Resource not found
  • not_authorized - Authentication/authorization failure
  • invalid_param - Invalid parameter value
  • required_param_missing - Required parameter missing
  • validation_error - Validation failure
  • not_accepted - Request not accepted
  • forbidden - Operation forbidden

HTTP Status Codes

  • 200 - Success
  • 400 - Bad Request (invalid parameters)
  • 401 - Unauthorized (authentication required)
  • 403 - Forbidden (authorization failure)
  • 404 - Not Found
  • 422 - Unprocessable Entity (validation errors)
  • 500 - Internal Server Error

Data Models

Channel Kinds

  • public - Public channel (all workspace members can view and join)
  • private - Private channel (invite-only, members must be added)
  • direct_message - Direct message between specific users
  • open_breakout - Open breakout room (inherits parent channel membership)
  • closed_breakout - Closed breakout room (archived sub-discussion)

Channel Sharing

Channels of type public, private, open_breakout, or closed_breakout can be shared publicly. Shared channels have a share_key that allows public access without authentication.

Shadows (anonymous users) can be created for accessing shared channels without a full workspace account.

Message Sort Orders

Messages use ULID (Universally Unique Lexicographically Sortable Identifier) for sort orders. Sort orders are used for pagination and synchronization. Scrollback limits may apply based on subscription tier.

Flexicons

Flexicons are visual identifiers for channels, profiles, and workspaces. They are represented as a structured object with a kind field and the corresponding flexicon data:

// Letter flexicon (default, color-coded letter)
{
  "flexicon": {
    "kind": "letter",
    "letter": {
      "color": "#000000",
      "letter": "A",
      "public_url": "https://emoji.stdout.host/letters/white/256/A.png"
    }
  }
}

// Glyph flexicon (icon from a predefined set)
{
  "flexicon": {
    "kind": "glyph",
    "glyph": {
      "id": "glyph_abc123",
      "kind": "hashtags",
      "filename": "hashtag-01",
      "public_url": "https://emoji.stdout.host/glyphs/hashtags/256/hashtag-01.png",
      "thumbhash": "1QcSHQRnh493V4dIeEh5aHh4aPdw+H",
      "thumbsize": "256x256"
    }
  }
}

// Image flexicon (custom uploaded image)
{
  "flexicon": {
    "kind": "image",
    "image": {
      "id": "att_xyz789",
      "file_name": "avatar.png",
      "content_type": "image/png",
      "thumbhash": "3OcRJYB4d3h/iIeHeFh3eIhw+j3A",
      "thumbsize": "640x640",
      "public_url": "https://..."
    }
  }
}

Setting flexicons: When updating a resource's flexicon, provide the flexicon object in the request body. The system will extract and store the appropriate type and reference.

Image Thumbnails: ThumbHash & ThumbSize

Image attachments include metadata for efficient placeholder rendering:

  • thumbhash - A base64-encoded ThumbHash, a compact ~25-byte representation of an image placeholder. Can be decoded client-side to show a blurred preview while the full image loads.
  • thumbsize - Dimensions of the thumbnail in format "WIDTHxHEIGHT" (e.g., "640x480"). Represents the scaled-down version stored on the server, constrained to 640x640 while preserving aspect ratio.

Usage: Clients can decode the thumbhash to display an instant placeholder, then request the thumbnail URL for a medium-quality preview, and finally load the full-resolution image.

Additional Notes

Building a Robust Client

The journal-based architecture enables building clients that work reliably even with unstable connections. A production-ready client should:

  • Maintain local state - Use IndexedDB or similar to persist journal entries and track sync cursors
  • Handle offline periods - Queue user actions locally and sync when reconnected
  • Use both sync strategies - Real-time for active use, catch-up for gaps
  • Process idempotently - The same journal entry may arrive via both real-time and catch-up
  • Track sync state per collection - Different collections sync independently

ULID Sort Orders

All timestamps and sort orders use ULID (Universally Unique Lexicographically Sortable Identifier):

  • Lexicographically sortable - String comparison gives chronological order
  • Timestamp-embedded - First 10 characters encode millisecond timestamp
  • Unique - Contains random component to prevent collisions
  • URL-safe - Uses Crockford's base32 alphabet

This makes ULIDs ideal for both pagination (offset parameters) and synchronization (since/until parameters).

Pagination

Most list endpoints support pagination via offset parameter (ULID sort order). Responses include has_more boolean when applicable. Default page sizes vary by endpoint.

Idempotency

Message creation supports optimistic_sort_order for idempotency. Clients can generate a ULID before sending to prevent duplicate submissions:

// generate optimistic sort order before sending
const optimisticId = ulid(); // generates timestamped unique ID

await fetch('/v1/channels/ch_123/messages', {
  method: 'POST',
  body: JSON.stringify({
    message: {
      text: '<p>Hello!</p>',
      optimistic_sort_order: optimisticId
    }
  })
});

// if request is retried, server will reject duplicate optimistic_sort_order

Content Limits

Message content size limits vary by subscription tier. Attachment limits vary by subscription tier. Limits are returned in /v1/current configs.

Recommended Client Architecture

For a production client, we recommend this architecture:

1. Initialize
   ├─ Load manifest
   ├─ Perform catch-up sync
   └─ Subscribe to real-time updates

2. During active use
   ├─ Apply real-time updates immediately
   ├─ Track last seen timestamp per collection
   └─ Persist state to local storage

3. On disconnect/reconnect
   ├─ Perform catch-up sync for missed period
   ├─ Resume real-time subscriptions
   └─ Deduplicate any overlapping updates

4. On visibility change
   ├─ Catch up if tab was hidden or client was backgrounded
   └─ Resume normal operation

5. On error
   ├─ Log sync state for debugging
   ├─ Retry with exponential backoff
   └─ Fall back to full resync if needed