Every agent product I’ve seen begins life as a beautiful lie. You wire a model into a frontend, watch the tokens stream, hang a tool or two off the side, and it feels finished. Then you ask the agent to do something real on a real machine: edit a repo, run a build, or hold a session open for an hour. The lie quietly collapses. You were never building a chat box. You were building a runtime, and you’d been hiding it inside a React component, hoping nobody would ask it to survive a refresh.
I know this because I lived it. Gooselake came directly out of goldengoose, my multi-agent desktop workspace. Over 1,700 commits of building that app, I kept solving the same category of problem: durable sessions, event fanout under load, background processes, worktree ownership, agents messaging each other. Every solution was welded to one Tauri app. The lessons were hard-won and completely trapped. Gooselake is the extraction: take everything the desktop app taught me about running agents on real machines, and rebuild it as a standalone, headless Rust runtime with an HTTP+SSE contract. It’s the best server software I could build for reliably managing hundreds of concurrent agent processes on a single machine, so the next agent product I build, whatever it looks like, starts on this foundation instead of re-earning the same scar tissue.
The abstraction ladder I climbed to get here
I’ve been building AI products long enough to have climbed every rung of this ladder. First it was raw OpenAI and Anthropic API calls wrapped in a Next.js app. Then I wrote my own provider-agnostic layer: a basic universal interface for text generation, system-prompt templating, and placeholder substitution, just so I could swap models without rewriting call sites. Then the Vercel AI SDK arrived and I adopted it early, and for a while it was the best thing that could have happened to me: it solved model switching and gave me a unified interface I genuinely enjoyed building on. I shipped agent products on it for a long time.
Two things ended that era. The first was that LLMs stopped being text-generation machines and became agentic ones: models that do real work if you give them tools and an environment. The second I only saw clearly while building goldengoose: I was solving durability, persistence, resumable sessions, and background execution on the desktop, while simultaneously shipping AI web products where I was trying to coax the same agentic behavior, including orchestration, tool calling, and structured outputs, out of serverless functions. The day one of those products needed a real sandbox, somewhere to actually run code, a switch flipped. A serverless function was never going to be the place where an agent lives. That’s the day Gooselake started.
There was a deeper dissatisfaction underneath, too. Every unified SDK, mine included, had the same secret: beneath the nice interface, it was still the same raw completion APIs I’d been wrapping years earlier, in a fancier coat. What changed recently is that the model providers started shipping their harnesses. The Claude Agent SDK is the same harness Claude Code is trained in, with subscription auth supported; the Codex app server was open-sourced in Rust, the very surface Codex models see during reinforcement learning. For the first time you could build provider-agnostic and sit on the foundation the models were actually trained against, instead of pulling them out of their harness and serving them through a generic wrapper. Everything converged at once, and in retrospect the conclusion looks obvious: someone needed to build the durable runtime layer on top of that foundation. So I did. (The longer version of this argument is its own essay: The same API in a fancier coat.)
Deciding where the truth lives
The first real decision was the boundary. In goldengoose the runtime and the UI shipped as one binary, which meant the runtime’s lifetime was the window’s lifetime. For a standalone runtime I wanted the opposite property: the client is a remote control, and everything that has to survive (sessions, turns, approvals, process state, team messages, event history) lives on the machine, in SQLite, behind an API. Close the tab, kill the client, reconnect from a different frontend entirely; the work doesn’t care.
That sounds obvious written down, but it’s the decision most agent apps get to make only once, implicitly, on day one, and then spend the rest of their lives regretting. Frontend-first stacks don’t fall apart because they were naive; they fall apart because they worked, and each new capability (a second provider, resumable sessions, background execution) got bolted onto a layer that was never supposed to hold state. I’d already watched that pressure build once. This time I built the distributed system on purpose, and gave every operation the same shape: HTTP command in, runtime state transition, provider adapter, durable record, replayable stream out. That one shape repeating across sessions, teams, processes, and worktrees is most of what makes the architecture legible.
Making three providers speak one language
Codex, Claude, and ACP disagree on almost everything: transports, auth, model catalogs, what a “turn” even means. Between them they cover nearly the whole field: Codex and Claude directly, and ACP as the open protocol that agents like OpenCode, Gemini, and Grok speak. That matters because the best model changes every month, and I want swapping it to be a config edit, not a rearchitecture. The runtime normalizes all of them behind one provider contract, and the design question I spent the most time on was what the abstraction is allowed to hide.
Identity, it must hide. Session and turn IDs are runtime-owned; provider-native references are stored next to them as opaque details, because providers lose and rename their internal sessions and I refuse to let clients build against IDs that can vanish. That separation pays for itself in one specific move: when a provider reports a session as gone mid-turn, the runtime quietly resumes it from the stored reference and retries the send. The client never learns it happened.
Capability differences, it must not hide. Optional provider abilities default to a structured “unsupported” answer instead of being faked. ACP doesn’t pretend to do Claude-style auth import, and the docs say so plainly. I’ve been burned by abstractions that paper over real differences; they don’t remove the differences, they just relocate them somewhere you can’t see.
Events are receipts
The persistence model is the part of goldengoose I was most determined to get right the second time. Every event lands in SQLite as a scoped, sequenced record: a unique (scope, scope_id, seq) index, the next sequence number computed inside the same transaction that inserts it, idempotency checked by event ID so a retried append can’t double-write. I think of them as receipts. A client that disconnects remembers the last sequence it saw, reconnects with after_seq, replays exactly what it missed, and continues live. That four-step reconnect story is the whole reason the model exists.
The bug I was specifically designing against lives in the handoff between replay and live: an event that fires in the instant after you finish replaying history but before you attach to the stream is simply gone. So session streams subscribe to the live broadcast first, then replay, then discard live events at or below the replay high-water mark. And every event carries a criticality. Streaming output samples are droppable under pressure; lifecycle transitions never are. It’s an idea proven inside goldengoose’s event transport and promoted here into the durable schema itself. The broadcast layer is an optimization; SQLite is the truth.
Recovery runs at boot, because I don’t trust anything else to run it
A runtime that owns durable state has to answer for that state after a crash. I put recovery inside the runtime’s boot sequence rather than in an ops script, because a script is something you remember to run and boot is something that happens. At startup it hydrates everything from SQLite and reconciles: health-checks providers, attempts to resume every non-terminal session, clears stale active-turn pointers, preserves approvals that are still valid, fails the ones whose context is gone, respawns waiters for in-flight turns. The summary of what it did is queryable at a diagnostics endpoint, because recovery you can’t inspect is recovery you can’t trust.
The rule underneath all of it is honesty over continuity. If a provider resume fails, the session is marked failed with a reason, not presented to clients as live work that silently isn’t. The same instinct shows up in normal operation: a turn that fails to dispatch is persisted as a failed turn with a turn.failed event, so an attempted turn never just evaporates into a log line. The session lifecycle enforces one active turn at a time, centrally, so no provider’s concurrency quirks can corrupt the model. And if a provider reports a conflicting terminal state for a turn that already ended, the runtime poisons the session with an explicit conflict failure rather than overwriting. A protocol violation you silently absorb is a bug you will never find.
Team messaging is a delivery system, because prompt glue kept failing me
In goldengoose I learned that agents coordinating over messages works startlingly well, and that the messaging layer earns its keep in the failure cases, not the happy path. So in Gooselake a team message isn’t an appended chat row. It becomes a message record plus a per-recipient delivery record with its own state machine (pending, deferred, injecting, injected, failed, cancelled) and a policy chosen per message: don’t interrupt, interrupt at the next tool boundary, interrupt immediately, or wait for a fresh turn. Because the runtime owns the one-active-turn rule, “is this recipient busy” has exactly one answer, and per-recipient injection guards stop two deliveries from racing into the same session.
Deferral is the detail I care about most. A message that arrives while its recipient is mid-turn isn’t dropped or blindly injected. It’s queued runtime state, and the delivery service watches for that recipient’s turn to end and injects at the boundary. Restart the runtime and startup recovery picks the deferred deliveries back up. Messages carry idempotency keys so an uncertain client can retry without double-delivering, and every delivery transition is a replayable event, so a client renders delivery state instead of inventing its own. The surface is comprehensive on purpose, with direct messages and team-wide broadcasts, retries and cancellation, and controls like interrupting a whole team at once, because the applications I want built on this (supervisors, review pipelines, fan-out research crews) need those verbs as API primitives, not as prompt conventions. I first reached for this idea in Go with adm and learned a CLI couldn’t give it durability; goldengoose gave it a product home; this is the version built as actual transport infrastructure.
Spawning a teammate is a saga, so I wrote it as one
“Spawn a teammate in its own worktree” decomposes into at least eight steps: create or reuse the worktree, resolve provider and permission mode, create the session, assign the worktree, join the team, claim the worktree, deliver onboarding. Any step can fail after earlier ones succeeded. There is no atomicity to borrow here; it’s a distributed transaction across runtime state, a provider session, git, and messaging. So the flow runs against an operation journal, and on mid-flight failure a compensating rollback walks it backwards: remove the membership, close the spawned session, release claims, delete the worktree it just created, and write diagnostics an operator can actually read. A partial spawn leaves an explanation, not wreckage.
Worktrees are another goldengoose export: the entire worktree management system that ran under the desktop app, now drivable by anything that can speak HTTP. Create, claim, release, and cleanup are each an explicit API call. They get the saga-and-repair treatment because they’re mutable host resources, and mutable host resources drift. Ownership is runtime records and session claims, never “a directory exists.” Deletion is policy, claims are separate from creation, and claim, release, and cleanup are explicit API operations rather than side effects. Startup repair normalizes whatever drift happened anyway: deduplicating worktree identities, tombstoning malformed records, releasing claims held by dead sessions.
Logs are truth, events are samples
The process manager is goldengoose’s Rust-native process manager, extracted and open-sourced, minus the UI, because there is no UI. That removal forced a design I ended up liking better: humans over HTTP and agents over MCP tools get exactly the same surface. Same ownership rules, same live status checks, same streamed output events, same log files. Nobody gets a privileged window into the machine.
Background processes are runtime records (owner session, command, status, exit code, timeout, log paths) with concurrency limits, ownership checks so one session can’t kill another’s process, and kill_on_drop so nothing outlives its supervisor. The decision I’d defend hardest: stdout and stderr go to bounded log files, which are authoritative, while the event stream carries sampled output plus critical lifecycle transitions. I tried the other way first. An event stream is a terrible place to store a build log. Output is enormous and lossy; “the process exited 1” must never disappear. The split gives each kind of data the storage it deserves. And after a crash, previously-running processes are marked failed instead of cosmetically resumed, the same honesty rule as everywhere else.
Quarantining the unstable parts
The bundle ships as one binary plus two sidecar processes: a Claude bridge and a gg-mcp-server. The sidecars exist to quarantine the fastest-churning, most provider-specific code behind process boundaries, so an SDK update can’t reach into the core. The rule is strict: sidecars do protocol adaptation, durable truth stays in the runtime. Even tool calls coming back from a sidecar are checked against it. The MCP gateway requires an active caller session, so every tool invocation traces to a live, auditable runtime identity instead of becoming an anonymous machine action. That’s one piece of a trust boundary documented as deliberately as the API.
The same boundary discipline runs through the workspace: runtime-core for the state machine and teams, one adapter crate per provider, runtime-store-sqlite isolating persistence, runtime-tools for processes, worktrees, and spawning, and runtime-server as nothing but axum transport over the HTTP contract. Deployment splits along the line that matters operationally: the release bundle (binary, sidecars, systemd units) is replaceable, the state root (SQLite, config, token, worktrees) is not. That’s what makes staged upgrades and rollbacks a file swap instead of a migration. And because a control plane whose docs lie is worse than none, the repo has a hard guardrail: API changes and doc changes ship together, enforced by a sync check, not by good intentions.
What it’s for
Gooselake is the layer I never want to write again. goldengoose proved the ideas; this makes them deployable on a laptop, a VPS, or a dedicated box, behind a contract any frontend can target: a web app, a CLI, a desktop app, a mobile app. And the surprising part is how little of it is about coding. What the runtime actually provides is agents that live on a real machine, with files, a terminal, processes, and other agents to work with, which is a general superpower wearing a coding-tool costume. The same deployment could back a legal AI workbench, a sports-analytics app, or a cloud-hosted goldengoose where the agents run on a server instead of my laptop. Whatever I build next gets durable sessions, replayable streams, real process execution, and a working team-communication layer on day one, and gets to spend its entire complexity budget on being a product. I’d rather keep improving one runtime than keep rediscovering it, badly, inside every product I ship.
