Event Reference

This document provides detailed documentation for all events emitted by the Thrum daemon over WebSocket connections.

Event Format

Events are sent as JSON-RPC 2.0 notifications (without an id field):

{
  "jsonrpc": "2.0",
  "method": "event.type",
  "params": {
    // event-specific payload
  }
}

Key characteristics:

Event Delivery

Subscription-Based

Events are only delivered to clients that have active subscriptions matching the event.

Subscription types:

  1. Scope-based: Receive events for messages with specific scopes
  2. Mention-based: Receive events for messages mentioning a specific role
  3. All: Receive all events (use sparingly)

Delivery Guarantees

Common Fields

All persisted events share a common base structure:

{
  "type": "event.type",
  "timestamp": "2024-01-01T12:00:00Z",
  "event_id": "01HQXYZ...",
  "v": 1
}
Field Type Description
type string Event type identifier (e.g., message.create)
timestamp string ISO 8601 timestamp
event_id string Globally unique ULID, used for deduplication
v int Event schema version (currently 1)

JSONL Storage

Events are persisted as sharded JSONL files in the sync worktree at .git/thrum-sync/a-sync/:

.git/thrum-sync/a-sync/
├── events.jsonl              Agent lifecycle events
└── messages/
    └── {agent_name}.jsonl    Per-agent message events

Storage assignment:

File Event Types
events.jsonl agent.register, agent.session.start, agent.session.end, agent.cleanup
messages/{agent}.jsonl message.create, message.edit, message.delete, thread.create, agent.update

Events are append-only and immutable. The JSONL files are the source of truth; the SQLite database (.thrum/var/messages.db) is a derived read projection that can be rebuilt from the JSONL at any time.

Event Types

Message Events

message.create

Emitted when a new message is created in the system.

When emitted:

Stored in: messages/{agent_name}.jsonl

Payload (MessageCreateEvent):

{
  "type": "message.create",
  "timestamp": "2024-01-01T12:00:00Z",
  "event_id": "01HQXYZ...",
  "v": 1,
  "message_id": "msg_xyz789",
  "thread_id": "thread_abc123",
  "agent_id": "furiosa",
  "session_id": "s_abc123",
  "body": {
    "format": "markdown",
    "content": "Hello, world!",
    "structured": "{\"key\":\"value\"}"
  },
  "scopes": [
    {
      "type": "task",
      "value": "PROJ-123"
    }
  ],
  "refs": [
    {
      "type": "mention",
      "value": "reviewer"
    }
  ],
  "authored_by": "",
  "disclosed": false
}

Fields:

Related methods: message.send, message.list

Example usage:

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.method === "message.create") {
    const message = msg.params;
    console.log(
      `New message from ${message.agent_id}: ${message.body.content}`,
    );
  }
};

message.edit

Emitted when a message is edited.

When emitted:

Stored in: messages/{agent_name}.jsonl

Payload (MessageEditEvent):

{
  "type": "message.edit",
  "timestamp": "2024-01-01T13:00:00Z",
  "event_id": "01HQXYZ...",
  "v": 1,
  "message_id": "msg_xyz789",
  "body": {
    "format": "markdown",
    "content": "Updated content",
    "structured": "{\"updated\":true}"
  }
}

Fields:

Edit history: Full edit history is stored in the database (message_edits table) and includes:

Related methods: message.edit, message.get

Example usage:

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.method === "message.edit") {
    const edit = msg.params;
    updateMessageInUI(edit.message_id, edit.body.content);
  }
};

message.delete

Emitted when a message is soft-deleted.

When emitted:

Stored in: messages/{agent_name}.jsonl

Payload (MessageDeleteEvent):

{
  "type": "message.delete",
  "timestamp": "2024-01-01T14:00:00Z",
  "event_id": "01HQXYZ...",
  "v": 1,
  "message_id": "msg_xyz789",
  "reason": "spam"
}

Fields:

Soft deletion: Messages are marked as deleted but not removed from the database. The message content is preserved for audit purposes.

Related methods: message.delete, message.list (with include_deleted param)

Example usage:

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.method === "message.delete") {
    const deletion = msg.params;
    removeMessageFromUI(deletion.message_id);
  }
};

Thread Events

thread.create

Emitted when a new thread is created.

When emitted:

Stored in: messages/{agent_name}.jsonl

Payload (ThreadCreateEvent):

{
  "type": "thread.create",
  "timestamp": "2024-01-01T12:00:00Z",
  "event_id": "01HQXYZ...",
  "v": 1,
  "thread_id": "thread_abc123",
  "title": "Discussion about feature X",
  "created_by": "furiosa"
}

Fields:

Related methods: thread.create, thread.list, thread.get

thread.updated

Real-time notification emitted when a thread is updated with new messages. This event is a WebSocket notification only and is not persisted to JSONL.

When emitted:

Payload (ThreadUpdatedEvent):

{
  "type": "thread.updated",
  "timestamp": "2024-01-01T13:00:00Z",
  "event_id": "01HQXYZ...",
  "v": 1,
  "thread_id": "thread_abc123",
  "message_count": 5,
  "unread_count": 2,
  "last_activity": "2024-01-01T13:00:00Z",
  "last_sender": "furiosa",
  "preview": "Latest message text..."
}

Fields:

Agent Events

agent.register

Emitted when an agent registers with the daemon.

When emitted:

Stored in: events.jsonl

Payload (AgentRegisterEvent):

{
  "type": "agent.register",
  "timestamp": "2024-01-01T12:00:00Z",
  "event_id": "01HQXYZ...",
  "v": 1,
  "agent_id": "furiosa",
  "kind": "agent",
  "name": "furiosa",
  "role": "implementer",
  "module": "auth",
  "worktree": "main",
  "display": "Auth Implementer"
}

Fields:

Agent naming: Agents support human-readable names set via --name flag, THRUM_NAME env var, or identity files at .thrum/identities/{name}.json. Names must match [a-z0-9_]+ and cannot use reserved words (daemon, system, thrum, all, broadcast).

Related methods: agent.register, agent.list

agent.cleanup

Emitted when an agent is deleted or cleaned up.

When emitted:

Stored in: events.jsonl

Payload (AgentCleanupEvent):

{
  "type": "agent.cleanup",
  "timestamp": "2024-01-01T15:00:00Z",
  "event_id": "01HQXYZ...",
  "v": 1,
  "agent_id": "furiosa",
  "reason": "manual deletion",
  "method": "manual"
}

Fields:

Related methods: agent.delete, agent.cleanup

agent.update

Emitted when an agent's work context changes (git state, intent, task).

When emitted:

Stored in: messages/{agent_name}.jsonl

Payload (AgentUpdateEvent):

{
  "type": "agent.update",
  "timestamp": "2024-01-01T12:30:00Z",
  "event_id": "01HQXYZ...",
  "v": 1,
  "agent_id": "furiosa",
  "work_contexts": [
    {
      "session_id": "s_abc123",
      "branch": "feature/auth",
      "worktree_path": "/path/to/repo",
      "unmerged_commits": [{ "sha": "abc1234", "message": "Add login form" }],
      "uncommitted_files": ["auth.go"],
      "changed_files": ["auth.go", "auth_test.go"],
      "git_updated_at": "2024-01-01T12:30:00Z",
      "current_task": "Implement login flow",
      "task_updated_at": "2024-01-01T12:00:00Z",
      "intent": "Building auth module",
      "intent_updated_at": "2024-01-01T12:00:00Z"
    }
  ]
}

Fields:

Projection: Work contexts are merged by session_id -- for contexts with the same session, the one with the newer git_updated_at wins.

Session Events

agent.session.start

Emitted when a session starts.

When emitted:

Stored in: events.jsonl

Payload (AgentSessionStartEvent):

{
  "type": "agent.session.start",
  "timestamp": "2024-01-01T12:00:00Z",
  "event_id": "01HQXYZ...",
  "v": 1,
  "session_id": "s_abc123",
  "agent_id": "furiosa"
}

Fields:

Session lifecycle:

  1. Agent registers
  2. Session starts
  3. Agent sends/receives messages
  4. Session ends (gracefully or crash)
  5. Orphan recovery on next session start

Related methods: session.start, session.end

agent.session.end

Emitted when a session ends.

When emitted:

Stored in: events.jsonl

Payload (AgentSessionEndEvent):

{
  "type": "agent.session.end",
  "timestamp": "2024-01-01T13:00:00Z",
  "event_id": "01HQXYZ...",
  "v": 1,
  "session_id": "s_abc123",
  "reason": "normal"
}

Fields:

End reasons:

Related methods: session.end, session.start

Subscription Filtering

Events are filtered based on active subscriptions:

Scope-Based Subscriptions

Only receive events for messages that match the subscribed scope.

Example: Subscribe to all messages in task "PROJ-123"

{
  "jsonrpc": "2.0",
  "method": "subscribe.create",
  "params": {
    "filter_type": "scope",
    "scope": {
      "type": "task",
      "value": "PROJ-123"
    }
  },
  "id": 1
}

Matching logic:

Mention-Based Subscriptions

Only receive events for messages that mention a specific role.

Example: Subscribe to messages mentioning "@reviewer"

{
  "jsonrpc": "2.0",
  "method": "subscribe.create",
  "params": {
    "filter_type": "mention",
    "mention": "reviewer"
  },
  "id": 1
}

Matching logic:

All-Events Subscriptions

Receive all events (use with caution, high volume).

Example: Subscribe to all events

{
  "jsonrpc": "2.0",
  "method": "subscribe.create",
  "params": {
    "filter_type": "all"
  },
  "id": 1
}

Use cases:

Warning: High traffic, may overwhelm slow clients

Event Ordering

Guarantees

Timestamps

All events include ISO 8601 timestamps. Use these for:

Sequence Numbers

Future enhancement: Add sequence numbers for detecting gaps.

Client Buffer Management

Buffer Limits

Buffer Full Behavior

  1. Client's buffer fills up (slow consumer)
  2. New events are dropped (not queued)
  3. Client connection may be closed if buffer remains full

Best Practices

  1. Process events quickly: Don't block the event handler
  2. Use background workers: Offload heavy processing
  3. Monitor buffer: Watch for dropped events
  4. Adjust subscriptions: Subscribe only to necessary events

Error Scenarios

Missed Events

Causes:

Detection:

Recovery:

Duplicate Events

Causes:

Handling:

Note: Do not use message_id for deduplication, as multiple events can share the same message_id (e.g., a message.create and subsequent message.edit for the same message).

Best Practices

Subscription Management

  1. Specific filters: Use scope/mention filters instead of "all"
  2. Cleanup: Unsubscribe when no longer needed
  3. Session-bound: Subscriptions auto-expire when session ends

Event Processing

  1. Idempotent handlers: Handle duplicate events gracefully
  2. Error handling: Don't crash on malformed events
  3. Async processing: Don't block WebSocket thread

Performance

  1. Batch updates: Buffer UI updates, render in batches
  2. Debounce: Delay rapid successive events
  3. Throttle: Limit event processing rate

Debugging

  1. Log all events: For development/debugging
  2. Event counters: Track received vs processed
  3. Timestamp monitoring: Detect delivery delays

Example Event Handlers

JavaScript/TypeScript

const eventHandlers = {
  "message.create": (params: MessageCreateEvent) => {
    console.log(`New message: ${params.message_id}`);
    addMessageToUI(params);
  },

  "message.edit": (params: MessageEditEvent) => {
    console.log(`Message edited: ${params.message_id}`);
    updateMessageInUI(params);
  },

  "message.delete": (params: MessageDeleteEvent) => {
    console.log(`Message deleted: ${params.message_id}`);
    removeMessageFromUI(params);
  },
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  // Handle events (no id field)
  if (!msg.id && msg.method) {
    const handler = eventHandlers[msg.method];
    if (handler) {
      handler(msg.params);
    } else {
      console.warn(`Unknown event type: ${msg.method}`);
    }
  }

  // Handle RPC responses (has id field)
  else if (msg.id) {
    handleRPCResponse(msg);
  }
};

Go

type EventHandler func(json.RawMessage) error

handlers := map[string]EventHandler{
    "message.create": func(params json.RawMessage) error {
        var event types.MessageCreateEvent
        if err := json.Unmarshal(params, &event); err != nil {
            return err
        }
        log.Printf("New message: %s", event.MessageID)
        return addMessageToUI(event)
    },

    "message.edit": func(params json.RawMessage) error {
        var event types.MessageEditEvent
        if err := json.Unmarshal(params, &event); err != nil {
            return err
        }
        log.Printf("Message edited: %s", event.MessageID)
        return updateMessageInUI(event)
    },
}

func handleMessage(data []byte) error {
    var msg struct {
        JSONRPC string          `json:"jsonrpc"`
        Method  string          `json:"method,omitempty"`
        Params  json.RawMessage `json:"params,omitempty"`
        ID      *int            `json:"id,omitempty"`
    }

    if err := json.Unmarshal(data, &msg); err != nil {
        return err
    }

    // Event (no ID field)
    if msg.ID == nil && msg.Method != "" {
        handler, ok := handlers[msg.Method]
        if !ok {
            log.Printf("Unknown event: %s", msg.Method)
            return nil
        }
        return handler(msg.Params)
    }

    // RPC response (has ID)
    return handleRPCResponse(&msg)
}

Next Steps