Skip to content

Architecture at a Glance ​

Ship of Tools is three processes plus your REPL, talking over a socket — even when everything runs on one machine. This page covers what each process owns, why the split falls where it does, the one serialization seam between Rust and Julia, and how the same architecture runs a remote project with almost no extra plumbing.

The processes ​

ProcessLanguageOwns
FrontendRust (winit + wgpu)the native window, chrome, preview rendering, keystrokes. Stateless about the project — it renders what the backend sends and forwards input.
Backend daemonRust (tokio)project state, file watching, process supervision of the kernels, the REPL and the agent sessions, the comm relay. Exposes a JSON line protocol to the frontend.
Julia kernelJuliathe FileType dispatch table and Julia-aware previews, project scans (the Modules tree), the file content hash behind the drift check. Loads the project's Project.toml environment.
REPLJuliayour interactive session, supervised by the backend, with a display shim that emits structured frames (stdout, stderr, value, image, error) over stdio.

The frontend holds no project knowledge. The backend is the single owner of project state and the one protocol entry point. The Julia kernel is where every extensible, Julia-aware decision is made. The REPL is a separate process so a crash or a long computation in your interactive session never takes down the daemon that supervises it.

Why client/server, even on one machine ​

The three processes communicate over a socket locally exactly as they would across a network. That looks like overhead on a single box, but it is the whole point: the same protocol serves local and remote; only the transport differs. The plumbing cost is paid once, up front, instead of being retrofitted later when local-only assumptions have already hardened.

This directly serves a requirement: local and remote operation must offer the same user experience. A design that special-cased "local" would have to grow a second path for "remote" and keep the two in sync forever. One socket-based protocol avoids that split entirely.

Why the language split ​

The boundary between Rust and Julia is not arbitrary — each language does what it is best at.

Rust is for plumbing. Rendering, IPC, file watching and process supervision all want a single statically-linked, cross-platform binary with predictable performance. tokio, notify, winit, and wgpu cover that stack. Keep this layer boring and predictable; it should rarely need to change when the feature set grows.

Julia is for everything plugin-extensible and Julia-aware. JuliaSyntax.jl parses Julia source — reimplementing a Julia parser in Rust is a non-starter, and it is the parser the language itself uses. More fundamentally, the unique value proposition here is multiple dispatch as the plugin mechanism: a small set of abstract types whose methods are the extension ABI. That only works in Julia. See The Dispatch ABI.

The serialization seam ​

The Rust↔Julia boundary is crossed by exactly two generic structs, both carrying opaque, kernel-defined payloads:

julia
struct TreeNode
    id::String                # opaque to Rust
    label::String
    kind::Symbol              # :module, :function, :pngfile, ...
    has_children::Bool
    badges::Vector{Symbol}    # design of the unbuilt per-entity layer: :stale, :user_edited, :immutable, ...
    payload::Dict{String,Any} # opaque, kernel-defined per kind
end

struct PreviewPayload
    mime::String
    data::Vector{UInt8}
    extras::Dict{String,Any}
end

Of the two, PreviewPayload is built (file-type previews return it). Core's Julia TreeNode is declared but not built: no Julia code constructs one today. The window's trees are built by the daemon and from the kernel's project scan, and travel as the Rust wire type TreeNode with the same fields.

The frontend draws whatever tree the kernel sends and dispatches on PreviewPayload's mime to pick a renderer. It never inspects id or payload; it never learns what a :pngfile is. The consequence is the load-bearing property of the whole design: adding a new FileType needs no Rust change unless its output must be bounded, in which case the daemon's preview gates also name its extensions. Navigation modes are native Rust views; future view extensions follow the mod design in ADR 0051, which is not built. A plugin that teaches the kernel to preview a new file format ships entirely in Julia, and the frontend renders it because the MIME type tells it how. See TreeNode and PreviewPayload in the API reference.

Deployment topology ​

The architecture's payoff shows up in the standard remote deployment: a Windows or local frontend driving a Linux backend where the Julia kernel and GPU live.

  • The backend runs as a long-lived daemon on the remote — the systemd --user unit sotd.service — so it survives SSH disconnects.

  • It listens on a per-session Unix socket, not a host-allocated TCP port.

  • The frontend makes one ssh login to the remote, running sotd stdio-bridge there, and carries its control connection, its panes and its pages over that one login as channels of the same JSON line protocol — no forward, no local port. See Transport.

  • On reconnect (after a laptop sleep, a wifi flap, or a fresh client), the frontend re-attaches by session id; the kernel keeps its state — variables, loaded modules, in-flight computations — across the gap.

A single daemon can host one Julia kernel per workspace, routed by workspace_id, so switching the active project is fast and never tears the kernel down. Each agent session runs in its own capsule supervisor, which the daemon starts and lists but does not hold up, so sessions survive frontend relaunches and daemon restarts alike (closing the window ends this computer's sessions; see ADR 0050) — see Sessions and persistence.

On Linux the daemon runs as the child of a small guard process, which is the process the launcher or the unit starts. The kernel, the REPL, Pluto and every other process the daemon starts, at any depth and however they detach, end within a few seconds of the daemon's end, even when the daemon is killed; a capsule does not, by design. A crashed Pluto server can leave one Julia worker per open notebook, which ends when the daemon ends. The guard exits with the daemon's own status. A process a broker starts, the loss of the guard itself and a process stuck in an uninterruptible kernel call are outside it, and macOS has no guard: only a controlled end of the daemon ends its children there, and not a process that left a child's process group, such as Pluto's notebook worker (ADR 0050).

Where to go next ​

An agentic development environment for Julia. Released under the AGPL-3.0-or-later, with a commercial license on request.