Architecture
August 6, 2026 · View on GitHub
Five layers, each replaceable:
┌──────────────────────────────────────────────┐
│ TUI shell (rendering, layout, keymap) │ emacs default; vim profile
├──────────────────────────────────────────────┤
│ Command + keybinding kernel │ every action is a command
├──────────────────────────────────────────────┤
│ Session manager (state, events, broadcast) │ daemon-side
├──────────────────────────────────────────────┤
│ Agent Harness Protocol (AHP) — JSON-RPC │ stable wire contract
├──────────────────────────────────────────────┤
│ Harness adapters (separate processes) │ plugin boundary
│ shell claude codex <your-harness> │
└──────────────────────────────────────────────┘
- Daemon owns sessions, spawns adapters, persists transcripts. Speaks JSON-RPC over a Unix socket to clients. Run it with
construct daemon run(the TUI also auto-starts one when none is running). - Client (
construct) is the TUI plus a set of one-shot subcommands and protocol-facing entrypoints such asconstruct acp. Multiple clients can attach concurrently. - Adapter binaries are independent processes. They implement the AHP over stdio. Anyone can ship one in any language.
Everything ships as one binary: construct runs the TUI by default, the daemon under construct daemon, the ACP bridge under construct acp, adapters under construct __adapter <name>, and the MCP server under construct __mcp. The daemon's runtime lives in the agentd library crate; adapter and MCP logic live in their own library crates linked into construct. The daemon and client are not merged into one process — the daemon stays a separate long-lived process that many clients attach to — only into one shipped executable. See specs/0026-single-binary-daemon-and-client.md.
Crates
| Crate | Binary | Purpose |
|---|---|---|
crates/protocol | — (lib) | AHP + IPC types, transport, adapter SDK |
crates/daemon | agentd (lib only) | Session supervisor + IPC server runtime. No standalone binary — driven by construct daemon |
crates/cli | construct | TUI client + control subcommands + construct daemon (runs the daemon via the agentd lib) + construct acp |
crates/adapter-shell | — (lib, via construct __adapter shell) | Generic shell command runner |
crates/adapter-claude | — (lib, via construct __adapter claude) | Wraps the claude CLI |
crates/adapter-codex | — (lib, via construct __adapter codex) | Wraps the codex CLI |
crates/adapter-opencode | — (lib, via construct __adapter opencode) | Wraps the opencode CLI |
crates/adapter-antigravity | — (lib, via construct __adapter antigravity) | Wraps the agy CLI |
crates/adapter-kimi | — (lib, via construct __adapter kimi) | Wraps the kimi CLI (Kimi Code) |
crates/adapter-pi | — (lib, via construct __adapter pi) | Wraps the pi CLI (pi coding agent) |
crates/adapter-prime-agent | — (lib, via construct __adapter prime-agent) | Wraps Prime Agent through the shared Pi-compatible session/event core |
crates/adapter-smith | — (lib, via construct __adapter smith) | Built-in multi-provider agent (OpenAI / Anthropic / Gemini / Meta / Grok / Ollama) |
crates/mcp | — (lib, via construct __mcp) | MCP stdio server for agents running inside sessions |
Adapter protocol (AHP)
The daemon spawns one adapter process per session and speaks JSON-RPC 2.0 over the adapter's stdin/stdout, one message per line.
Methods the adapter implements:
| Method | Payload |
|---|---|
initialize | {protocol_version, client_info} → InitializeResult |
session.start | {session_id, cwd, prompt?, model?, mode?, pty_size?, env, args} |
session.input | {session_id, text} — line-oriented input |
session.pty_input | {session_id, data} — base64 raw bytes for the PTY master |
session.pty_resize | {session_id, cols, rows} — SIGWINCH equivalent |
session.interrupt | {session_id} |
session.stop | {session_id} |
shutdown | {} |
Notifications the adapter emits:
session/event— oneSessionEvent.Pty {data}(base64 bytes) is the hot path for PTY-backed sessions; structured variants (Message,ToolUse,ToolResult,Cost,Diff,Status,Done, ...) are emitted alongside when the adapter has them.log— free-form line for the daemon's log.
Adapters that own a PTY can opt into a shared runtime helper:
use construct_protocol::adapter::pty::{run_session, PtySpec};
// in your run(metadata, |params, ctx| async move { ... }) closure:
let spec = PtySpec {
bin: "bash".into(),
args: vec!["-il".into()],
cwd: params.cwd.into(),
env: params.env.into_iter().collect(),
size: params.pty_size.unwrap_or(PtySize { cols: 100, rows: 30 }),
status_detail: Some("bash -il".into()),
};
let _ = run_session(spec, ctx).await;
(Enable the pty feature on agentd-protocol to pull in portable-pty.)
Writing an adapter in Rust is roughly:
use construct_protocol::adapter::run;
use construct_protocol::{Capabilities, InitializeResult, MessageRole, SessionEvent, SessionState};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let metadata = InitializeResult {
name: "demo".into(),
version: env!("CARGO_PKG_VERSION").into(),
capabilities: Capabilities { supports_input: true, ..Default::default() },
};
run(metadata, |params, mut ctx| async move {
ctx.emit.emit(SessionEvent::Status { state: SessionState::Running, detail: None });
ctx.emit.emit(SessionEvent::Message {
role: MessageRole::Assistant,
text: format!("got prompt: {:?}", params.prompt),
});
ctx.emit.emit(SessionEvent::Done { exit_code: 0 });
}).await
}
Adapters in other languages just need to speak the same JSON shapes.