Skip to content

Real-Time Communication

Editions: OSS. Contributor documentation for this repository.

Preloop uses a single WebSocket per client with pub/sub routing. This chapter covers MessageRouter, topics, and the unified realtime architecture.

Preloop uses WebSocket connections for real-time updates:

Unified WebSocket Architecture

Single WebSocket connection per client with pub/sub message routing:

MessageRouter (backend/preloop/services/message_router.py): - Routes messages to topic-based subscribers - Supports wildcard subscriptions ('*' topic) - Optional per-subscriber filter functions - Topics: flow_executions, approvals, system

Benefits: - Single WebSocket reduces connection overhead - Scalable pub/sub pattern - Easy to add new message types/topics - Clear separation of concerns

Enterprise Features: Preloop Cloud and Preloop Enterprise add RBAC and approval workflows with quorum, escalations and AI gates. Contact sales@preloop.ai for more information.

Execution log persistence

Account events and gateway execution events are encoded before submission to a process-local realtime publisher. API, scheduler and worker startup attach its single drain task to the event loop that owns the shared NATS client. Calls from worker threads or other event loops enqueue bytes without waiting for NATS, so gateway bookkeeping cannot hold a database connection during a forced flush on the wrong event loop.

The publisher bounds pending work by both message count and payload bytes, including in-flight messages and thread hand-offs. Each publish has a timeout; shutdown drains within a deadline and rejects new work. Rejected messages and publish failures are counted and logged. This is best-effort live delivery: overflow, timeout or shutdown can lose a notification, while persisted activity remains available through history reads. It does not replace durable task publication or its JetStream acknowledgement.

Each API process receives realtime flow updates through Core NATS. A separate log-persisters queue subscription assigns each log to one process for database persistence. Each process has one writer with a bounded queue of 10,000 entries and one active batch of at most 500 entries. Producers await queue capacity; these bounds count entries, not payload bytes. A short coalescing window gathers logs arriving on separate event-loop ticks into one database transaction.

The pinned nats-py client dispatches messages into each subscription's bounded pending queue without awaiting that subscription's callback. One worker per subscription invokes its callbacks serially. Waiting for log queue capacity therefore pauses the log-persisters worker while the separate flow, account, approval and admin realtime subscriptions continue processing. No per-message producer tasks are created. If the persister's NATS pending buffer also fills, the NATS client reports a slow-consumer error and drops that subscription's overflowing messages; it cannot provide durable backpressure to publishers. This transport limit is separate from retention of logs already accepted into the writer queue.

Database sessions exist only during a write attempt. The CRUD layer scrubs each batch and inserts it with stable row IDs, so retrying after an ambiguous commit response cannot duplicate the same accepted event. Pool checkout timeouts and connection failures trigger bounded synchronous retry cycles followed by an asynchronous delay. The writer retains the batch until the database recovers; exhausting one retry cycle does not drop it. Known logs for deleted executions are discarded, while non-retryable data errors still produce a loss alert.

Shutdown stops subscriptions and allows accepted logs to drain. Cancellation joins an active database write and retains unfinished batches for a writer restart on the same event loop. A drain timeout reports both queued and active counts. The queue and retained batch are process memory: a process exit, an extended outage that exceeds Core NATS client buffering, or a failed publish can still lose logs. Core NATS provides no durable acknowledgement or redelivery. Restart-safe delivery requires a separate durable transport change. Limiting writer concurrency reduces pool pressure but does not reserve capacity away from API requests.

Execution snapshots and sequence domains

/api/v1/ws/flow-executions/{execution_id} authenticates the owning account, subscribes to Core NATS, and flushes the subscription before reading the database. It sends connected, then snapshot, then live events. The send gate prevents an event received during the database read from overtaking the snapshot. Snapshot fields are execution_id, flow_id, status, agent_session_reference, start_time, end_time, stop_requested_at, stop_confirmed_at, seq, lifecycle_seq, and sequence_domain. Datetimes are ISO strings; absent values are null. Lifecycle events at or below the snapshot watermark are superseded by the authoritative state. Activity payloads are not contained in that snapshot: queued log, gateway and tool events still pass through after the snapshot.

Every orchestrator, runner and model-gateway execution update allocates seq through an atomic PostgreSQL upsert in the CRUD layer. The independent execution_realtime_cursor row avoids contention with business writes on the execution row. seq is an allocation order, not database state version, delivery order, durable acknowledgement or proof of authenticated completion. Publishers can deliver out of order; failures after allocation leave holes. Cursors survive worker restarts and are scoped to an execution, never an account. Deploy migration 20261120_execution_realtime_seq before the new publishers. During a rolling deployment, old publishers can still emit unsequenced events. If allocation is unavailable, publishers preserve best-effort delivery with an explicit unsequenced resync_required marker instead of fabricating a cursor or dropping a log notification. The console coalesces recovery and admits at most one recovery request group per two seconds, including ongoing gap bursts.

For recovery, GET /api/v1/flow-executions/{execution_id}/lifecycle reads state and allocation watermarks in one authorized database statement. Keep the largest seen sequence and coalesce a refresh after a jump greater than one. Suppress older lifecycle state, but do not discard unseen activity solely because its sequence is below a snapshot watermark. The console tracks the most recent 4,096 activity sequences for duplicate suppression and merges retained logs, bounded gateway metadata and tool activity during recovery. Overlapping log pages are deduplicated while their pagination offset advances by stored rows read. Runner log broadcasts and stored-log responses share log_id, the stable persisted row ID, so deduplication survives the line/message payload difference and storage/publish timestamp differences. Distinct row IDs preserve identical consecutive lines. Legacy sources without a shared ID remain best-effort; the console does not collapse them by text alone. Discard a late snapshot whose cursor is below the local cursor. The console refreshes on initial subscription and reconnection as well. Core NATS provides no durable replay; a missed final event with no later sequence cannot itself announce a gap. Consumers needing eventual terminal state must also perform a bounded authoritative status check after their idle deadline. A socket event does not substitute for the separately authenticated terminal webhook or validated result read.

Bounded flow lifecycle startup

For clients with a small parser limit, use /api/v1/ws/flows/{flow_id}/lifecycle. This endpoint authorizes view_flows and account ownership before subscription, and rechecks permission before sending lifecycle data. It does not register for unified account broadcasts. It filters both account and flow before projecting an allowlist of lifecycle scalars. Logs, results, prompts, tool arguments and fleet context never enter an outgoing frame. Every outgoing lifecycle frame is at most 8,192 UTF-8 bytes; an unusually large scalar projection becomes an explicit resync_required invalidation.

Wait for connected with stream: lifecycle, then trigger that flow. No trigger or inference occurs merely by connecting. Send text ping at least every 30 seconds to keep the idle connection alive. The startup stream has no historical execution enumeration; use the execution returned by the trigger for subsequent snapshot reads. Account/flow IDs missing from a publisher event fail closed.

This filtered stream exposes lifecycle_seq as seq, with sequence_domain: lifecycle. Only lifecycle event allocation advances that counter. Intentionally suppressed log/tool/model events therefore create no false gaps. Recover using the same GET endpoint with ?sequence_domain=lifecycle; never compare cursors from different domains.

The general /ws/unified remains useful for the console: authenticate, send {"type":"subscribe","topic":"flow_executions"}, wait for its subscription acknowledgement, then trigger. That stream is account-wide and has no small-frame guarantee. Client-side filtering cannot prevent an oversized frame from reaching the parser; bounded automation clients should use the flow lifecycle endpoint.