flowlayer
// WebSocket Protocol V1

Protocol

WebSocket protocol V1 specification for building FlowLayer clients. Everything needed to connect, authenticate, send commands, and consume events in any language.

Session Model

Each WebSocket connection is an independent session. Sessions are ephemeral — there is no server-side persistence and no automatic replay after reconnection.

link
Transport: WebSocket over ws://<host>:<port>/ws
shuffle
Each connection is independent. Multiple clients can connect simultaneously.
history
No session persistence. A reconnect requires a full handshake and log replay via get_logs.
handshake sequence
Client → WebSocket connect with Authorization: Bearer <token>
Server → event: hello (protocol version, capabilities)
Server → event: snapshot (full service state)
→ ready Session operational. Send commands and consume events.
Wait for both hello and snapshot before sending any commands.

Authentication

vpn_key Bearer token on upgrade request

Authentication happens at the HTTP level during the WebSocket upgrade, before the connection is established.

Authorization: Bearer <token>
auth responses
Missing Authorization headerHTTP 401
Invalid tokenHTTP 403
Valid tokenUpgrade proceeds

Session API Endpoints

favorite GET /health

Authenticated liveness check. Returns {'\"ok\": true'} when the server is running.

Response: {"ok": true}

cable GET /ws

WebSocket upgrade endpoint. All protocol V1 communication happens over this connection.

Transport: WebSocket — bidirectional, message-based

Message Envelope

Every message in both directions follows this structure. Fields use omitempty — absent when not applicable.

envelope.json
{
  "type": "<message_type>",
  "id": "<correlation_id>",
  "name": "<command_or_event_name>",
  "payload": {}
}
type Always required

One of: command, ack, result, event, error

id command, ack, result

Correlation identifier. UUID recommended. Used to match ack and result to the originating command.

name command, event

Command or event name, e.g. get_logs, service_status.

payload Varies

Content specific to the message type. Absent when not applicable.

Message Types

command Client → Server

Client request. Requires a unique id and a name. Routed to a matching handler.

ack Server → Client

Immediate acknowledgment. Private to the sending client. Contains accepted boolean. If accepted = false, the rejection is final for that command and no result follows.

result Server → Client

Final command outcome after an accepted ack. Private to the sending client. Only sent when ack.accepted = true, including runtime failures with ok = false.

event Server → Client

Asynchronous notification. Runtime events (service_status, log) are broadcast to all connected sessions. Handshake events (hello, snapshot) are private to the connecting session.

error Server → Client

Protocol-level error. No correlation — not tied to a specific command.

Command Flow

command lifecycle
1.
Client sends command with unique id
2.
Server replies with ack for that id
accepted: true→ result will follow
accepted: false→ rejected, no result
3.
If accepted: server sends result for that id
ok: true→ success, data in result
ok: false→ failure, error field
ack payload
// Accepted
{ "accepted": true, "error": null }

// Rejected
{
  "accepted": false,
  "error": { "code": "unknown_service", "message": "..." }
}
result payload
// Success
{ "ok": true, "data": { ... }, "error": null }

// Failure
{ "ok": false, "error": { "code": "...", "message": "..." } }
Command completion rules

If ack.accepted = false, that rejected ack is the final response for the command. No result follows.

If ack.accepted = true, the server later sends result for the same id, with ok = true or ok = false.

Set a client-side timeout (5 seconds is reasonable). The server does not impose command timeouts. If no response arrives, treat the command as failed.

Commands

get_snapshot No payload

Returns the current state of all services. Services are sorted alphabetically.

// result.data
{ "services": [
  { "name": "billing", "status": "ready" },
  { "name": "users", "status": "starting" }
]}
get_logs Optional payload

Returns log entries stored in memory.

Request fields (all optional)
serviceFilter by service name. Omit for all services.
limitMax entries. Omit to let server decide (uses logView config).
after_seqOnly return entries with seq > this value.
Response fields
entriesLog entries ordered by seq.
truncatedtrue if entries were dropped to fit within limit.
effective_limitLimit actually applied. Use this for buffer sizing.
// Entry fields
{
  "seq": 42,              // global monotonic sequence number
  "service": "billing",
  "phase": "running",     // informational lifecycle phase
  "stream": "stdout",     // or "stderr"
  "message": "GET /healthz 200",
  "timestamp": "2026-04-19T09:21:11Z"
}

Limit resolution when client omits limit:

All-services: logView.all.maxEntrieslogView.maxEntries → 500

Per-service: services.<name>.logView.maxEntrieslogView.maxEntries → 500

Truncation: without after_seq, tail truncation (last N entries). With after_seq, entries with seq > after_seq, then tail truncation.

start_service stop_service restart_service payload: {service: string}

Start, stop, or restart a named service. May be rejected with unknown_service or service_busy. Both come as ack.accepted = false.

{"type": "command", "id": "...", "name": "restart_service", "payload": {"service": "billing"}}
start_all stop_all No payload

Start or stop all services.

Events

hello Handshake · Private

First event on connection. Announces the protocol version and available commands.

{
  "protocol_version": 1,
  "server": "flowlayer",
  "capabilities": ["get_snapshot", "get_logs", "start_service", "stop_service", "restart_service", "start_all", "stop_all"]
}
snapshot Handshake · Private

Full initial service state. Sent immediately after hello. Use this as the authoritative starting point; then apply incremental service_status events.

{ "services": [{ "name": "billing", "status": "ready" }, ...] }
service_status Runtime · Broadcast

Service state transition. Broadcast to all connected clients. Apply as incremental update on top of the initial snapshot.

{ "name": "billing", "status": "ready" }
log Runtime · Broadcast · Best-effort

Live log entry. Broadcast to all connected clients. Delivery is best-effort — slow clients may miss events. Deduplicate by seq and use get_logs with after_seq to recover gaps.

{
  "seq": 42, "service": "billing", "phase": "running",
  "stream": "stdout", "message": "GET /healthz 200",
  "timestamp": "2026-04-19T09:21:11Z"
}

Service States

starting

Server accepted start request. Launch sequence is in progress. Not yet operational.

running

Process spawned. Terminal state for daemons without a readiness probe, and for oneshots on exit 0. Transitional for daemons with a probe.

ready

Readiness probe passed. Service is considered fully operational. Terminal startup state for services with a probe.

stopping

Stop sequence is in progress. Concurrent actions on this service are rejected.

stopped

Service has terminated. Process group no longer active and port (if any) released.

failed

Startup or shutdown failed. Clients should surface this to the user.

unknown

No state recorded yet. Appears only in snapshot responses for services not yet started.

Note on running vs ready running does not always mean full operational readiness. For services without a readiness probe, running is the terminal startup state. For services with a probe, running is transitional — only ready indicates full availability.

Error Codes

Errors can appear either as an error message or as a rejected ack with accepted = false. If a command is accepted and later fails during execution, the failure appears in result with ok = false. The error message type is used for protocol-level failures before a command is parsed; ack carries command rejection; result carries runtime failure after acceptance.

error_codes
Code Where Meaning
invalid_jsonerrorMessage could not be parsed as JSON
malformed_messageerrorMessage parsed but required envelope fields are missing or wrong type
unknown_commandackCommand name not recognized by the server
invalid_payloadackCommand payload is structurally invalid or missing required fields
unknown_serviceackService name not found in config
service_busyackService is mid-start or mid-stop; concurrent action rejected
internal_errorresultUnexpected server-side error

Building a Client

The only requirements for building a FlowLayer client are a WebSocket library and a JSON parser. The official TUI is the reference implementation. Below is the minimal client checklist.

minimal client checklist
check_circle
Connect with Authorization: Bearer <token>
check_circle
Wait for hello and snapshot before sending commands
check_circle
Dispatch messages by type, then by name for events
check_circle
Correlate ack / result to commands by id
check_circle
Track lastSeq for log continuity
check_circle
Deduplicate logs by seq
check_circle
Use effective_limit from get_logs for buffer sizing
check_circle
Reconnect with exponential backoff; do full handshake; replay logs with after_seq

warning Log management complexity

Log management is the most complex part of a FlowLayer client. Getting it wrong leads to missing logs, duplicates, or unbounded memory growth. Key rules:

  • Never assume a fixed limit — always use effective_limit from the server response
  • Without after_seq, the server returns the last N entries (tail truncation)
  • Live log events are best-effort; gaps are normal under load

build Reference implementation

The TUI is the reference client. Its log deduplication, reconnect logic, and state merging are a practical example of correct protocol usage.

github.com/FlowLayer/tui open_in_new