v1https://api.output.app/v1All responses follow a consistent structure:
{
"ok": true|false,
"data": { ... },
"errors": [ ... ]
}
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)Authorization: Bearer {auth_token}Workspace is specified via the X-Workspace-Id header
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 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)
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:
Both approaches use the same underlying journal system, ensuring consistency regardless of how your client receives updates.
A journal entry represents a single change to a workspace resource. Each entry contains:
create, update, or destroy// 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
}
}
}
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:
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)message, channel, emoji, etc.)// 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).
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.
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 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)
The WebSocket connection uses AnyCable for real-time updates. To establish a connection:
GET /v1/cable to obtain the WebSocket URL with embedded JWT tokenUse 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().
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
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();
}
}
Important: The JWT token embedded in the cable URL expires periodically. When using the tokenRefresher option (shown above), the client automatically:
tokenRefresher function to get a new URLIf 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();
}
});
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 identifiersigned_signal_journal_key - Signed key from manifest (obtained via GET /v1/journals/manifest)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.
Catch-up synchronization uses a simple time-range query pattern:
since your last timestamp// 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
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
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;
}
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();
}
});
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
Regenerate workspace invite key.
Access: Requires workspace admin
Leave the current workspace.
Access: Requires authenticated user
Remove a member from the workspace.
Example:
DELETE /v1/workspace/remove_member?profile_id=profile123
Access: Requires workspace admin
Deactivate the workspace.
Access: Requires workspace admin
List all profiles in the workspace.
Access: Requires authenticated user
Get a specific profile.
Access: Requires authenticated user
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
Update profile role (admin only).
Required fields: role
Request:
{
"profile": {
"role": "admin"|"member"
}
}
Access: Requires workspace admin
List accessible channels (public channels + channels user is member of).
Access: Requires authenticated user
Get read markers and unread skeletons for all channels.
Response:
{
"ok": true,
"read_markers": [ ... ],
"skeletons": [ ... ],
"conference_transcripts": [ ... ]
}
Access: Requires authenticated user
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 a specific channel.
Access: Requires channel membership or public channel
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
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 a channel.
Access: Requires workspace admin (cannot delete last public channel)
Mark channel as read.
Response:
{
"ok": true,
"last_read_sort_order": "01HN8XYZ..."
}
Access: Requires channel membership
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 a specific message.
Access: Requires channel access
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
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 a message.
Access: Requires message ownership
List agent chats for current profile.
Access: Requires authenticated user
Get a specific agent chat.
Access: Requires authenticated user
Create a new agent chat.
Access: Requires authenticated user
Delete an agent chat.
Access: Requires authenticated user
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
List messages in an agent chat.
Access: Requires authenticated user
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
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
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 a reaction.
Access: Requires reaction ownership
List all emojis for the workspace (including deactivated).
Access: Requires workspace access (shadows allowed)
Create a custom emoji.
Required fields: shortcode, custom
Request:
{
"emoji": {
"shortcode": "my_emoji",
"custom": "att_123"
}
}
Access: Requires authenticated user
Update an emoji (sort order).
Required fields: sort_order
Optional fields: rebalance
Request:
{
"sort_order": 1000,
"rebalance": true
}
Access: Requires authenticated user
Deactivate an emoji.
Access: Requires authenticated user
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
Update a media attachment (typically after Lambda processing).
Required fields: jwt
Request:
{
"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Access: Requires authenticated user or shadow
{
"ok": false,
"errors": ["error_code", "specific_error_code"],
"validations": { ... }
}
not_found - Resource not foundnot_authorized - Authentication/authorization failureinvalid_param - Invalid parameter valuerequired_param_missing - Required parameter missingvalidation_error - Validation failurenot_accepted - Request not acceptedforbidden - Operation forbidden200 - Success400 - Bad Request (invalid parameters)401 - Unauthorized (authentication required)403 - Forbidden (authorization failure)404 - Not Found422 - Unprocessable Entity (validation errors)500 - Internal Server Errorpublic - Public channel (all workspace members can view and join)private - Private channel (invite-only, members must be added)direct_message - Direct message between specific usersopen_breakout - Open breakout room (inherits parent channel membership)closed_breakout - Closed breakout room (archived sub-discussion)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.
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 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 attachments include metadata for efficient placeholder rendering:
"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.
The journal-based architecture enables building clients that work reliably even with unstable connections. A production-ready client should:
All timestamps and sort orders use ULID (Universally Unique Lexicographically Sortable Identifier):
This makes ULIDs ideal for both pagination (offset parameters) and synchronization (since/until parameters).
Most list endpoints support pagination via offset parameter (ULID sort order). Responses include has_more boolean when applicable. Default page sizes vary by endpoint.
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
Message content size limits vary by subscription tier. Attachment limits vary by subscription tier. Limits are returned in /v1/current configs.
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