dlgt local RPC v1
This document is the normative programmatic interface. Command syntax and exit statuses belong in CLI; provider integration and security rationale belong in Design.
Transport and framing
dlgt uses newline-delimited JSON over a mode-0600 Unix socket. dlgt rpc --stdio proxies only the public methods in this document. Each non-streaming request produces exactly one response line.
Request:
{"id":"req_1","method":"session.fetch","params":{"session":"codex:019f6307-341e-7e81-8a33-7ab61e804345","until":"result","wait_ms":900000}}Success:
{"id":"req_1","result":{"schema_version":1,"reason":"result","cursor":"4","sessions":[]}}A successful non-streaming response may also carry an informational notice without changing the result:
{"id":"req_1","result":[],"info":{"code":"UPDATE_AVAILABLE","current_version":"0.1.4","latest_version":"0.2.0","command":"dlgt update"}}info is advisory. Clients should present it separately from the result, and must obtain user confirmation before acting on UPDATE_AVAILABLE.
Failure:
{"id":"req_1","error":{"code":"CURSOR_EXPIRED","message":"this daemon no longer holds position 3"}}Raw RPC responses do not use the CLI's ok:true or ok:false wrapper. Blank input lines are ignored. Invalid JSON, framing failures, and a closed transport terminate the stdio proxy.
A request id is echoed in the response envelope, so it is bounded: an id longer than 200 bytes is rejected with INVALID_ARGUMENT, and the rejection echoes only a truncated prefix of it.
Public methods
session.create Create a Session with its required initial prompt
session.restart Replace a Session process and resume provider context
session.send Accept work on an existing idle Session
session.fetch Read everything new since a cursor, optionally long-polling
session.cancel Interrupt active work, bounded by timeout_ms
session.list List active or all Sessions
session.read Read live Session state and latest retained result
session.stop Stop the Harness process group
event.read Read normalized versioned lifecycle events
event.subscribe Stream normalized lifecycle events
scrollback.read Read VT-rendered plain-text rows
transcript.read_raw Read explicitly requested raw PTY pages
model.list Discover Harness models
profile.list List client-side Profile names
harness.list Read Harness capabilitiessession parameters accept a provider-qualified codex:<thread-id> or claude:<session-id> Session ID, or the active human alias. The following parameter shapes are stable for v1:
| Method | Parameters |
|---|---|
session.create | title, optional alias, harness, cwd, optional model, optional effort, optional harness_options, optional auto_approve (default true), required non-empty prompt, required request_id, startup_timeout_ms, launch environment, rows, cols |
session.restart | session ID, startup_timeout_ms, fresh launch environment, rows, cols |
session.send | session, prompt, required request_id; with resume:true, the same provider-qualified Session ID and launch options are accepted |
session.fetch | exactly one of session or all:true; optional cursor (a position, as a number or a string of digits), wait_ms (max 86,400,000), until (any or result), screen (boolean or stable-line count), max_bytes |
session.cancel | session, optional timeout_ms with a 30-second default |
session.list | optional all boolean |
session.read | session |
session.stop | session, optional force boolean |
event.read | optional session, optional global after sequence |
event.subscribe | optional session, optional global after sequence |
scrollback.read | session, optional lines, optional opaque before cursor |
transcript.read_raw | session, optional byte offset after, optional limit_bytes |
model.list | harness, optional include_hidden |
profile.list | no required parameters |
harness.list | optional harness |
Profiles are expanded by the client. profile.list is implemented by the stdio proxy rather than delegated to the daemon, so the daemon does not reread mutable client configuration.
request_id is a required caller-chosen idempotency key: non-empty and at most 128 bytes. A missing, empty, or over-long key fails with INVALID_ARGUMENT. The daemon retains the last 1,024 acceptance receipts for its lifetime. Repeating an ID with the same payload returns the original receipt with replayed: true; repeating it with a different payload fails with INVALID_ARGUMENT.
It is required rather than optional because a key that first appears on the retry cannot deduplicate anything: the original attempt has already created a Session. This is separate from the RPC envelope's id, which correlates one request and is capped at 200 bytes.
Payload identity is the canonical form of the RPC parameters:
- every parameter except
environment,rows,cols,correlation_id, andrequest_id, which legitimately differ between retries of the same acceptance; - object keys in sorted order, so a client that emits parameters in a different order still matches;
- array values in the order given, so
harness_optionsmust be repeated in the same order; promptcompared byte for byte. A trailing newline is significant, so--stdinfrom a heredoc and the same text passed after--are different payloads.
An acceptance reserves its request_id before it runs. A second call arriving while the first is still launching blocks until the winner settles and then replays its receipt, so two concurrent calls can never create two Sessions. A different payload is rejected against an in-flight reservation as well as a stored receipt. A failed acceptance stores no receipt, so the same ID may be retried.
Successful session.create and session.send receipts include submission: "confirmed" | "pending". The daemon waits up to five seconds for the provider lifecycle acknowledgement. pending is still success: local delivery occurred, so the caller must observe or replay the identical request ID rather than submit with a new one. A replay refreshes this field from the live execution and can change pending to confirmed without executing the prompt twice. Pending receipts also include a human-readable hint and an action containing a bounded fetch; these are recovery guidance, not additional RPC state.
harness_options is an array of explicit KEY=VALUE Claude Code CLI options. The daemon converts each entry to --KEY=VALUE, rejects dlgt-managed arguments, and retains the array so session.restart reuses the same launch behavior. When the array carries no permission-mode entry and auto_approve is true, dlgt adds --permission-mode=auto; auto_approve: false keeps Claude Code's own permission default. Codex Harness options are not currently supported.
Session and result schemas
A public Session contains its provider-qualified ID, active alias and title, Harness, working directory, model selection, state, and timing. The suffix of session.id is the Codex thread ID or Claude session ID, so the same value correlates provider-native logs, addresses a live Session, and explicitly resumes it after daemon exit. Provider turn IDs, internal launch IDs, and internal execution row IDs are excluded.
When state is exactly busy, the Session snapshot also includes two integer diagnostics:
{
"state": "busy",
"busy_for_ms": 183000,
"pty_quiet_for_ms": 72000
}busy_for_ms is elapsed time since the current turn was reserved (its local creation timestamp), so provider startup or binding cannot make it move backwards. pty_quiet_for_ms is elapsed time since the latest PTY output, clamped to the current busy interval; if no output has been observed it equals busy_for_ms. These values are diagnostic only and never transition a Session or permit another send. Both fields are omitted for every non-busy state.
CLI send scans live versioned daemon sockets before dispatch. Raw JSONL RPC is intentionally scoped to the selected socket; callers using RPC directly must select the owning versioned socket themselves.
The public state set is:
starting idle busy blocked canceling stopping restarting stopped failedEvery accepted execution receives a per-Session monotonic execution_seq. This is correlation data, never an RPC selector or public resource ID. The returned Session state is a snapshot taken when the response is built; use lifecycle events or a later session.read for current state.
A retained result has this shape:
{
"execution_seq": 2,
"status": "completed",
"final_text": "Review result...",
"final_text_source": "hook",
"error": null,
"started_at_ms": 1784024104395,
"completed_at_ms": 1784024252019,
"usage": null
}status is completed, failed, canceled, or interrupted. final_text is always a string for a completed execution, although it may be empty. Other terminal states may include partial text and provide a structured error. Usage is nullable because provider support differs.
final_text_source is hook when the Harness lifecycle event reported the text, transcript when the Harness reported nothing and dlgt recovered the text from that Session's own provider transcript within this execution's boundary, and missing when no text was recovered. A failed recovery never changes the execution status.
session.create and session.send return {session, execution_seq, cursor}. The cursor is the Session's next observation position, taken under the runtime lock immediately before the acceptance is recorded, so the first session.fetch from it cannot miss output produced between acceptance and the caller's next request.
Composite reads
session.fetch returns one document per request. There is no streaming variant and no partial output.
{
"schema_version": 1,
"runtime": {"version":"0.4.0","instance_id":"1f2c…"},
"reason": "result",
"has_more": false,
"gaps": [],
"cursor": "4",
"sessions": [
{
"session": {"id":"claude:8bc7859c","state":"idle"},
"events": [
{"schema_version":1,"seq":104,"type":"session.idle","session_id":"claude:8bc7859c","execution_seq":7,"result_status":"completed"}
],
"results": [
{"execution_seq":7,"status":"completed","final_text":"Review result...","final_text_source":"hook","final_text_offset":0,"final_text_complete":true,"error":null,"started_at_ms":1784024104395,"completed_at_ms":1784024252019,"usage":null}
],
"screen": {"epoch":3,"reset":false,"reset_reason":null,"stable":["Checking tests..."],"live":["Writing final review..."],"live_truncated":false},
"gaps": []
}
]
}reason is snapshot, change, result, blocked, page_full, gap, or timeout. Every one of them is a successful response; results[].status remains the authority on whether an execution succeeded.
Rules:
- Without
cursor, the response is a bounded baseline: current state, the latest retained result, a stable tail, the live screen, and a fresh cursor. until:"result"binds to the execution active, or latest, at the first evaluation. A later execution never extends the bind, and blocked input, a page-full response, a gap, or the deadline returns early.- Replaying a cursor replays the same immutable events, results, and stable rows. The Session snapshot and live screen are current snapshots and may be newer on replay. Nothing is advanced or consumed server-side.
- A live-screen repaint alone never completes a wait.
all:truecovers only the addressed daemon, pages at 32 Sessions, and rejects both screen aggregation anduntil:"result". A cursorless call enumerates every Session, carries its enumeration position in the cursor, and stays in baseline mode withhas_more: trueuntil it finishes; later calls return only changed Sessions.
Bounds are part of the contract: 32 KiB serialized by default and 256 KiB hard, 64 events, 4 results, 128 stable lines (512 on request), and 40 live rows per response. has_more: true means data already exists beyond the returned cursor, and the next request returns immediately even with wait_ms.
max_bytes bounds two things: the result document itself, and the complete compact response line the CLI prints, whose {"ok":true,...} wrapper and newline the daemon reserves before committing content.
It does not bound the raw JSONL envelope. That envelope carries a caller-supplied request id, so its size is the caller's to control; the id is capped at 200 bytes before JSON escaping; an id full of control characters can escape to about six times that, so the excess stays under roughly 1.3 KiB over the result document. An optional info notice and pretty-printing are also outside the bound. The cursor is derived from what the document actually carries, so it can never advance past omitted content. Progress under a tight budget comes from chunking: a long final_text is chunked at a UTF-8 boundary and continued with final_text_offset and final_text_complete, and a screen row wider than the remaining budget is chunked mid-line. Every has_more response advances at least one watermark. A max_bytes too small for the envelope plus one chunk fails with INVALID_ARGUMENT naming a verified workable value instead of emitting an oversized document. That value is verified by actually rendering a response at it, so retrying at exactly it succeeds; it is a verified candidate, not a guaranteed minimum, because progress is not monotonic in the budget.
screen.stable contains complete rows only. A split row is carried in one of two ordered slots, each shaped {"row_id":8412,"offset":4096,"text":"...","complete":false}:
screen.fragment_beforecontinues the row the previous response split. It precedesstable[0]and is the only slot wherecompletecan be true.screen.fragment_afteris the row this response split. It follows the last entry ofstableand is never complete.
The screen delta reads in that order: fragment_before, stable, fragment_after. Clients must retain every piece and concatenate the pieces for a row_id in offset order until complete is true; a completing piece carries only its own tail, so discarding earlier pieces loses data. A row never appears in both stable and a fragment slot.
Lifecycle events are committed as a strictly ascending prefix across every Session in the response, so a Session that did not fit the page can never park an earlier event behind a later one that keeps redelivering.
A cursor is an ordinal position. Responses serialize it as a string of digits for field stability; requests accept either spelling, a JSON number or a string of digits. Absent or null requests a baseline. Any other type, and any string that is not a position, is CURSOR_INVALID rather than a silent baseline. Leading zeros are accepted and canonicalized: responses always spell the position without them. Every acceptance, and every fetch response whose watermark vector advanced, mints the addressed scope's next position; a fetch whose vector did not move returns the caller's own position, though an empty public delta can still mint a new position when internal bookkeeping advanced beneath it. all numbers independently of any Session. The daemon holds the watermarks behind each position and never mutates them, which is what makes replaying a position return an identical window.
The position is an ordinal within the scope addressed, not a capability token: using one Session's number against another resolves that other Session's own position of the same number, which is a legitimate replay. Idempotency belongs to request_id.
Positions bind to an internal Session identity, so a Claude provider-ID rotation keeps them valid.
A position is meaningful only within one daemon lifetime. Nothing in the value marks which daemon minted it, and resolution is (scope, number) alone, so a number from a previous daemon is not rejected: it names this daemon's window of that position, or fails CURSOR_EXPIRED if this daemon has not minted that far. What comes back is always current data, never a stale world, but the behavior is defined rather than safe: used as a starting position, a stale number skips whatever this daemon recorded before that window, so a client must not carry positions across a restart. After RPC_UNAVAILABLE, SESSION_NOT_RUNNING, or any daemon restart, discard remembered positions and re-enter through a resume acceptance cursor or a cursorless baseline.
The daemon retains the most recent 64 positions per Session and 256 for all, within a global cap of 4,096 across every scope; beyond either bound the oldest are dropped. A response whose watermark vector did not move keeps the caller's position rather than minting a new one, so an idle long poll normally spends none of them; internal bookkeeping can still advance the vector beneath an empty public delta and mint. One all position keeps entries only for Sessions that still exist and carry state, and addresses at most 256 of them; a daemon holding more rejects all with INVALID_ARGUMENT and must be read one Session at a time.
Retention is bounded to 10,000 stable rows per Session, 50,000 lifecycle events per daemon, and 128 results or 16 MiB of result bodies per Session. A cursor that predates an eviction returns reason:"gap", a gaps entry of {"component":"screen"|"events"|"results","reason":"retention_overrun"}, a bounded baseline, and a fresh cursor. Per-Session components (screen, results) are reported on the Session bucket and scope-wide components (events) in the top-level gaps array. dlgt never silently resets a cursor.
Lifecycle events are materialized when they are recorded and are scoped by an internal Session identity, so replaying a cursor returns byte-identical events even after the execution they describe has been evicted or the public Session ID has rotated. session_id on an event is the ID that was published when the event happened.
Lifecycle events
event.read returns a JSON array. event.subscribe returns an initial response and then one normalized NDJSON event per line until interrupted or the connection closes.
{"schema_version":1,"seq":101,"type":"session.created","session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345"}
{"schema_version":1,"seq":102,"type":"session.ready","session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345"}
{"schema_version":1,"seq":103,"type":"session.busy","session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345","execution_seq":1}
{"schema_version":1,"seq":104,"type":"provider.retrying","session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345","execution_seq":1,"attempt":1}
{"schema_version":1,"seq":105,"type":"session.blocked","session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345","execution_seq":1,"reason":"user_input"}
{"schema_version":1,"seq":106,"type":"session.resumed","session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345","execution_seq":1}
{"schema_version":1,"seq":107,"type":"session.idle","session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345","execution_seq":1,"result_status":"completed"}
{"schema_version":1,"seq":108,"type":"session.stopped","session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345"}
{"schema_version":1,"seq":109,"type":"session.restarting","session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345"}
{"schema_version":1,"seq":110,"type":"session.ready","session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345"}The complete v1 event type set is:
session.created
session.restarting
session.ready
session.busy
session.blocked
session.resumed
session.canceling
session.idle
session.stopping
session.stopped
session.failed
provider.retryingEvery event contains schema_version, a global monotonic seq, type, and when applicable session_id and execution_seq. Type-specific fields include attempt, reason, and result_status.
The stream contains lifecycle and actionable state, not token or terminal text deltas. event.subscribe is the extension point for notification adapters; session.fetch is the read path for agents, and raw output is observed through scrollback.read, transcript.read_raw, or interactive attach.
Output readers
scrollback.read returns the VT-rendered screen and history:
{
"session_id": "codex:019f6307-341e-7e81-8a33-7ab61e804345",
"screen": {"rows":24,"cols":120},
"lines": ["Review complete.","","Main concerns:"],
"truncated": true,
"before": "scr_84A2"
}The default is the latest 100 rows. Reads are clamped to 1 through 10,000 rows, and before is an opaque cursor for older pages. Rows come from the persistent per-Session stable-row store that also backs session.fetch, so a read no longer re-renders the retained raw ring. before cursors are opaque and are not comparable across daemon instances.
transcript.read_raw is an explicit diagnostic method. It returns a bounded base64 page and byte cursor:
{
"session_id":"codex:019f6307-341e-7e81-8a33-7ab61e804345",
"data_base64":"...",
"byte_len":4096,
"next_after":8192,
"has_more":true
}The default raw page limit is 1 MiB and the server caps it at 8 MiB. Callers must follow next_after while has_more is true.
Error contract
RPC failures contain a stable code and human-readable message. The v1 code families are:
INVALID_ARGUMENT Request cannot be retried unchanged
NOT_FOUND Session or configuration object does not exist
NO_RESULT Session has never accepted work
ALIAS_IN_USE Exact alias belongs to a non-terminal Session
SESSION_BUSY Active execution; retry after it terminalizes
SESSION_BLOCKED Human input is required
SESSION_ATTACHED Exclusive attach lease prevents semantic send
SESSION_UNAVAILABLE Session state cannot accept the operation
ALREADY_ATTACHED Another client owns the attach lease
CURSOR_EXPIRED Cursor position was never minted or is no longer held
CURSOR_INVALID Cursor value is not a position number
CANCEL_TIMEOUT Cancel wait expired; cancellation continues
LAUNCH_FAILED Harness startup or initial prompt acceptance failed
RPC_UNAVAILABLE Daemon transport is unavailable; retry may succeed
INTERNAL dlgt runtime invariant failureMethods may add contextual error fields but must not overload a code with a different retry or human-action policy. A session.create failure before provider binding includes an internal:<short-id> as launch_id, never as session_id. A failure after binding includes the canonical session_id. CLI exit-status mapping is defined in CLI.
Security boundary
rpc --stdio uses a fixed allowlist. Internal methods such as provider hooks, terminal input, resize, and private execution operations are unavailable even if a caller names them directly.
Provider turn IDs, internal Session UIDs, and internal execution row IDs never appear in public responses or normalized events; a cursor is a bare position number and carries no identity data. Launch environment values travel in RPC memory but are not directly serialized into Session metadata, errors, Profiles, or events. Results and terminal output remain untrusted and potentially sensitive because a provider can echo its environment.
Raw transcript pages are deliberately separate from rendered scrollback. They may contain control bytes, redraw noise, and provider-emitted secrets; clients should request and retain them only for explicit diagnosis.