Skip to content
Pre-release. v0.1.0 is not tagged and there are no published binaries yet. Install from source. Commands on this site were run against the current main.

Protocol

Write a client by connecting to the daemon’s Unix socket and exchanging length-delimited JSON frames. The CLI, the TUI and the agent skill are all clients of this and get no privileges you do not — that is the point of the socket existing.

You do not need this to use lazydap. You need it to build a frontend: an editor plugin, an MCP bridge, a web UI, language bindings.

The socket is at {runtime_dir}/lazydap-{instance}.sock. Find it without guessing:

Terminal window
$ lazydap logs --limit 1 --format json
{
"lines": [
"2026-07-30T20:34:44.459987Z INFO daemon.ipc: daemon listening instance=lazydap-demo-13cc8efcde46 socket=/tmp/lazydap-501/lazydap-lazydap-demo-13cc8efcde46.sock pid=43070"
]
}

LAZYDAP_RUNTIME_DIR moves the directory the socket lives in; LAZYDAP_INSTANCE changes which socket in it. There is no variable that sets the full socket path. The rest of the environment lazydap reads is LAZYDAP_DATA_DIR, LAZYDAP_LOG and LAZYDAP_TIMEOUT. See the daemon guide.

The daemon may not be running. A client is expected to spawn one, the way the CLI does, or to require the user to run any lazydap command first.

Each frame is a 4-byte big-endian length prefix followed by that many bytes of JSON.

+----------------+---------------------------+
| u32 big-endian | JSON body, `length` bytes |
+----------------+---------------------------+

Frames larger than 16 MiB are refused. A client claiming a 4 GiB message is broken or hostile, and the daemon should not allocate for it either way.

A frame the daemon cannot read ends the connection. It replies BadRequest on id 0 — id 0 because an unreadable frame has no id to answer in context — and then hangs up, rather than leaving you waiting for a reply that could never correlate. Reconnect and send a well-formed frame; there is nothing to recover on the old connection.

{
"version": 4,
"id": 1,
"payload": {
"Request": "Ping"
}
}
Field Notes
version LAZYDAP_PROTOCOL_VERSION. Read it from crates/protocol/src/types.rs — that constant is the source of truth, and this page will drift
id Correlates a response with its request. Monotonic per connection
payload One of Request, Response, Event, Error

Events use id: 0, because nobody asked for them. Anything non-zero is answering something you sent.

A request that does have fields looks like this:

{
"version": 4,
"id": 5,
"payload": {
"Request": {
"Doctor": {
"check_adapters": true,
"check_state": true
}
}
}
}

Send Ping first. The daemon answers Pong with its version, instance and uptime. A mismatch means you are talking to a daemon from a different build, and it says VersionMismatch rather than half-speaking an older dialect.

Terminal window
lazydap shutdown # what a user does about a mismatch

Twenty-one variants, grouped by the four buckets. Adding a fifth bucket needs a decision-log entry.

Bucket Variants
Diagnostics Ping, Status, Shutdown, Version, Doctor
Session lifecycle Launch, Disconnect
Stepping Continue, Step, Pause
Inspection Threads, StackTrace, Scopes, Variables, Eval, Output
Breakpoints (project state; no session needed) BreakpointList, BreakpointAdd, BreakpointRemove, BreakpointToggle
Subscription Subscribe

Three things a reader of the CLI might expect here and not find:

  • No StepIn or StepOut. One Step request carries kind, which serialises as "over", "in" or "out".
  • No SetBreakpoints. Breakpoints are project state, edited through the four Breakpoint* requests.
  • No Logs. lazydap logs reads the daemon’s log file directly rather than asking the daemon for it.

ClientSpecific is a design category for state that never leaves a client — pane focus, scroll offsets — not a request variant. Nothing sends it and the daemon has no case for it.

{
"version": 4,
"id": 6,
"payload": {
"Request": {
"Launch": {
"adapter": "codelldb",
"program": "/Users/you/lazydap-demo/hello",
"args": [],
"cwd": "/Users/you/lazydap-demo",
"env": {},
"stop_on_entry": true,
"adapter_command": null
}
}
}
}

Launch has no wait mode. It answers Response::Launched once the configuration phase is done, and that reply has its own shape — session id, state, capabilities, and the persisted breakpoints with whatever the adapter made of them. It is not a StableState.

Stepping requests do have a wait mode:

{
"version": 4,
"id": 7,
"payload": {
"Request": {
"Continue": {
"session_id": "1012b692-01c4-48e0-b8e1-4124163bafe3",
"thread_id": null,
"wait": {
"Wait": {
"timeout_ms": null
}
},
"all_threads": false
}
}
}
}

"wait": {"Wait": {"timeout_ms": null}} blocks with the daemon’s 30-second default; timeout_ms: 0 means no timeout at all. Not waiting is the bare string "NoWait":

{
"version": 4,
"id": 8,
"payload": {
"Request": {
"Step": {
"session_id": "99ede8a7-3727-4b36-8da9-a84bda1da86c",
"thread_id": null,
"kind": "over",
"wait": "NoWait"
}
}
}
}

Named variants rather than a generic envelope: Pong, Status, Version, Doctor, Launched, Disconnected, ShuttingDown, Continued, Stepped, Threads, StackTrace, Scopes, Variables, Evaluated, Output.

The one worth knowing about: a stepping request without a wait is answered Continued (the request was accepted), and with one is answered Stepped, carrying the full stable-state object. Same request, different reply, chosen by the wait mode. The shape of that object is on the JSON output page — the CLI prints it through without reshaping it.

Subscribe by sending Subscribe { channels: [...] }. Channel names are snake_case on the wire, even though the Rust variants are not:

{
"version": 4,
"id": 9,
"payload": {
"Request": {
"Subscribe": {
"channels": [
"stopped",
"output",
"session_ended"
]
}
}
}
}

The seven kinds, exactly as they serialise: session_started, session_ended, stopped, continued, output, breakpoint_updated, thread_changed.

Three things to know:

It is answered with a Status response, not a variant of its own. The snapshot is taken at the instant the subscription starts, so there is no gap between “what is the state now” and “tell me when it changes” for an event to fall into.

Nothing buffered is replayed. You get what happens from now on. Request::Output reads the debuggee’s earlier output without draining it.

Subscribing again replaces the set, rather than adding to it.

Events then arrive as ordinary frames with id: 0, interleaved with replies to your own requests. A client must be able to read a frame it did not ask for at any point.

{
"version": 4,
"id": 0,
"payload": {
"Event": {
"Continued": {
"session_id": "99ede8a7-3727-4b36-8da9-a84bda1da86c",
"thread_id": 27982711,
"all_threads_continued": true
}
}
}
}

BreakpointUpdated is the one whose shape changed in v3, and it is why the version went up. Its session_id is optional: Some is an adapter’s opinion about a live session, None is a change to the project’s list, which lazydap break can make with nothing running.

{
"version": 4,
"id": 0,
"payload": {
"Event": {
"BreakpointUpdated": {
"session_id": "4234e458-358b-4ba4-bf10-719a8653b0ea",
"breakpoint": {
"id": 1,
"adapter_id": 1,
"verified": true,
"line": 6,
"message": "Resolved locations: 1"
}
}
}
}
}
{
"version": 4,
"id": 0,
"payload": {
"Event": {
"BreakpointUpdated": {
"session_id": null,
"breakpoint": {
"id": 2,
"verified": false,
"line": 6
}
}
}
}
}

Note what the second frame leaves out. adapter_id and message are skipped when absent rather than sent as null, so a client must treat every field but verified as optional.

Both shapes would have claimed to be v2 and failed to decode each other, which is the hazard the version number exists to turn into a clean VersionMismatch and a restart.

This is exactly what the TUI does: Subscribe to the session events, then send the same Continue, Step and breakpoint requests the CLI sends.

What happens What you see
The adapter dies The debuggee is killed first, then a synthetic SessionEnded carrying a detail string that says so; any in-flight wait resolves adapter_died
The daemon dies Socket EOF. Retry Ping, then spawn a daemon
You send an unreadable frame BadRequest on id 0, then the connection closes
The adapter is missing AdapterNotFound, with the searched paths in details

Error payloads carry a code from a fixed set, a human message, and free-form details ({} when there is nothing to add). The codes are listed on errors and exit codes.

At most one of Continue, Step or Pause is in flight per session; the daemon queues the rest. Inspection requests run in parallel.

Do not work around this by opening a second connection. Pipelining execution requests to one adapter deadlocks when the request you are waiting on is blocked on an event that a queued request would have caused.