Skip to content
Brad Johnson, home
All words · 8 min read

From a Conversation to a Commit on Main

Tags:#ai#agents#tauri

aludra runs coding agents on my own machines. A session keeps going with no client attached, its tools can run in a stedding sandbox, and aludra can ship the result to GitHub. What it needed was a desktop app where I could start a conversation, watch it work, answer what it asks, read the diff and ship it, on the Mac and on my Linux desktop. loial is that app.

I wrote the plan, the protocol notes, the screens and the architecture on November 5 before any code, then built it in six milestones (A to F), each ending with a check I could run. The first commit is from that morning and the 50th from November 6.

A conversation is the whole surface

Every conversation runs in one of two places, picked once in the composer: a sandbox (the default) or directly on the machine. The idea comes from how Amp handles it, where a thread has an executor chosen before the first message and fixed after. In aludra’s terms the host is the runner and a sandbox is the orb.

Both kinds get the same view. While a run is going, its plan is pinned above the composer. When it needs approval for something like a git push, the request sits at the bottom of the transcript with Allow, Allow for this conversation and Deny:

loial with a conversation titled push it on a demo project. The header reads Waiting for your approval, a bash call to git push –dry-run is marked waiting for you, and an amber card offers Allow, Allow git push for this conversation, and Deny. The list on the left shows it under needs you, and the status bar says 2 need you.

All screenshots here are from loial’s Playwright tests, against a stand-in aludra with a scripted model, fake sandboxes and a made-up demo project.

Return sends, Option-Return steers the running work, and Esc stops it. When an answer ends with a question, its options become reply buttons:

A sandbox conversation in sandbox/0 of the demo project. The agent asks Which layout should the list use?, with three buttons under it: Group it by day, Keep one long list, and Leave it as it is. The composer says Reply, or pick an option above.

When a run ends, one line says how many files and lines changed and whether the checks passed, and it opens the changes panel. For a sandbox the diff comes from task.code against the sandbox’s base. For a conversation on the machine it comes from what changed since the session started, which is one of three small additions I made to aludra first so loial would have one diff call for both:

The changes panel beside a conversation called write a file for demo. It lists two files, notes-demo.md with +3 and demo.txt with +1 -1, and the patch for demo.txt replaces the line Started. with Changed by the session.

Why the socket is in Rust

aludra serves its client protocol at /amyrlin on localhost, with tailscale serve in front of it adding the Tailscale login of whoever is connecting. It refuses any WebSocket upgrade that carries an Origin header. A native app sends none and a browser always does, so a web page I happen to have open can’t open that socket with my identity.

A Tauri webview is a browser, and it always sends Origin. So loial opens its sockets in Rust, one per host, and the Rust side does nothing else with them. From the top of src-tauri/src/socket.rs:

// One WebSocket per host, opened here and not in the webview: aludra refuses an upgrade with an Origin header, and a
// webview always sends one. Thin on purpose: it relays text frames both ways and reports the close, and knows nothing
// of the protocol (app/src/protocol/client.ts owns that).

tokio-tungstenite sends no Origin, and a test holds it to that by recording the headers a local server sees:

let headers = handle.await.unwrap();
assert!(!headers.iter().any(|(k, _)| k == "origin"), "no Origin: {headers:?}");

Frames reach the webview through a Tauri channel as { frame }, a ping or pong as { alive: true }, and the end as { closed: { reason, code? } }. A refused upgrade comes back with its HTTP status, so a 403 can say the login isn’t on that host’s list instead of retrying. Besides the sockets, the Rust holds the tray, OS notifications, single-instance and the pages the agent publishes.

In a plain browser during development, Vite’s dev server does the same job with a small relay at /__relay, which only accepts its own page’s origin. Playwright drives the app through that relay.

The protocol and state have no UI in them

app/src/protocol and app/src/state are TypeScript with no React. They run under Node’s test runner with no window, which is where I wanted the bugs to show up. This split comes straight from amyrlin, my earlier Mac client, whose Swift core had no SwiftUI in it.

The client owns the connection rules from the protocol: hello offering version 1, request IDs, subscriptions with a seq per frame, a dead connection after 45 seconds without a frame or pong, and reconnecting with backoff:

/** The delay before reconnect `attempt` (0-based): 0.5 s doubling to 30 s, with full jitter. */
export function backoff(attempt: number, random: () => number = Math.random): number {
	return Math.floor(random() * Math.min(BACKOFF_MAX, BACKOFF_MIN * 2 ** attempt));
}

After a reconnect it watches everything again and gets fresh snapshots. Nothing is replayed. A frame whose seq isn’t the one due means it missed something, so it watches that subscription again instead of guessing.

The client also decides what happens to a request whose socket dropped before the answer came. submit and conversations.create carry a requestId that aludra deduplicates, and abort, configure, approval.answer and archiving are idempotent, so those get sent again. sandbox.ship, sandbox.promote and projects.create never are. For those the client says the outcome is unknown and lets the next summary show what happened:

/** A request whose socket dropped before its answer, which isn't safe to send again. */
export class OutcomeUnknown extends Error {
	constructor(method: string) {
		super(`The connection dropped before ${method} answered, so it may or may not have happened. The next update will show.`);
	}
}

A transport can also hand over several frames in one read, so event frames can arrive before the watch request’s own response has been handled. The client holds those frames by subscription ID and applies them in order once it knows the subscription.

Tested against recorded wire traffic

The conversation reducer takes a snapshot and then applies each event in order, as section 7.3 of the protocol defines. It’s pure: apply returns a new state and leaves the old one alone, so React can compare them. An event that doesn’t fit the state, like a delta for a block that isn’t there, returns a resync instead of a best guess.

It’s tested against the 23 wire fixtures amyrlin recorded: real Pi Durable event streams for a plain answer, parallel tools, a steer mid-run, an abort mid-tool, a crash and resume, approvals, retries and more, translated to the wire format by aludra. Every fixture has to apply cleanly and end idle, and streamed text has to be a prefix of the message that gets committed. Three recordings of one run, attached at different moments, have to reach the same state. A capture of aludra’s own /amyrlin endpoint is in there too.

pnpm test is 125 tests and takes about six seconds. The store test starts a real aludra from the checkout beside loial with its faux model, kills it and starts it again, and checks the store reconnects. During milestone B the same store read my main machine’s real aludra over loopback: 67 projects and 15 conversations.

Shipping from the conversation

A sandbox conversation with work home and nothing running gets a Ship tab beside Changes. It draws the route the work takes: the sandbox’s branch and its commits, the rebase onto the upstream, the project’s checks, the push from the host, and where it lands. I pick To main or As a pull request and can add a note for the agent.

Pressing Ship sends sandbox.ship, and sending it is the approval of the push. The ship runs as a turn of the same conversation, so it streams like any other work, and its prompt shows as one line in the transcript. aludra fetches the upstream into the sandbox. The agent commits what belongs to the work, rebases, resolves conflicts, runs the checks and says whether it’s ready. Then the host checks the result itself and pushes without force, using its own git credentials. The sandbox never holds a key.

loial reads the outcome from the sandbox.shipped event and from the summary’s shipped and shipStopped, so it still knows after a restart. The result sits in the transcript where it happened: “Shipped to main · 1 commit” with the commit, or the pull request’s link. A sandbox’s commits come home a moment after its run ends, so loial reads the changes and result again 3, 8, 20 and 45 seconds after.

pnpm ship-check does it for real. In milestone E it made a new private repository on GitHub with projects.create, had a sandbox commit a README line, and the run landed the commit on GitHub’s main in 149 seconds. Then it turned a scratch sandbox’s commit into a second repository with sandbox.promote, and the conversation moved over to the new project and carried on.

Pages and the desktop

The agent can publish a page, an HTML report with its own script and style. loial draws it inline where the call happened, in an iframe sandboxed to allow-scripts with no same origin, so it can’t reach the window, Tauri’s IPC or the hosts. A srcdoc frame inherits the window’s Content Security Policy, which blocks inline script. So in the Tauri window the page goes to Rust, which serves it on a custom loialpage: scheme with a policy of its own:

const CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:";

Nothing on the page can load from the network.

The tray keeps loial running when the window closes, so notifications keep coming. They’re worked out from two readings of a conversation’s summary on host.watch: something needs me, a run finished or got stuck, or a ship landed or stopped. None comes for the conversation I’m looking at in a focused window, and clicking one opens its conversation. pnpm build:mac makes the .app, and pnpm install:linux puts the binary, a desktop entry and the icon under ~/.local. I installed both builds on November 5, closed the window mid-run, and the notification came and opened the conversation.

In the Wheel of Time, Loial is an Ogier who leaves his stedding to travel with the others.