Local Studio: a technical report

Local Studio is a native macOS and Linux app for running many coding-agent threads at once, on local and cloud models. It runs on Eukhe, a coding agent whose memory is one endless chat log shared by every session.

This report records the decisions: why the app stopped forking T3 Code, why it runs on Eukhe, why the shared logic is Rust, why the macOS client is Swift and the Linux client is GTK, why it did not use a cross-platform toolkit, and what makes it feel like a tool a person uses all day. Each choice names its alternatives and the reason for the pick.

The old app was a thin fork of T3 Code, a 1.2-million-line tree it had to keep merging. The new app is 84,000 lines. One agent core (Eukhe), one shared logic core (Rust), two thin native GUIs (Swift, GTK). The agent logic lives in Eukhe. The shared app logic lives in Rust. Each GUI does only native chrome.

1. What the app is

One window. Every agent thread across every project in one sidebar, with a live status on each: working, needs approval, done. Open a project, start a thread, the thread runs an agent. The agent reads and edits files, runs commands, and streams its work into a native timeline. Steer a running thread, queue follow-ups, approve tool calls, watch many threads work at once.

Three things set it apart from a chat client:

The app is a client. The agents run in the Eukhe daemon, out of process. The app talks to the daemon over a versioned protocol. That separation drives everything else.

2. Where it started: the T3 Code fork

Local Studio began as a fork of T3 Code. T3 Code is a coding agent for people who run their own models. The upstream, sybil-solutions/local-studio, tracked T3 Code closely. A file pinned the upstream import; a check kept Local Studio's own source under 4,000 lines on top of it.

The T3 stack it inherited:

Part                   What it was
---------------------  ----------------
apps/web               the React web app
apps/desktop           the Electron shell
apps/server            the Node server
apps/mobile            the mobile app
apps/local-controller  the local-LLM controller
apps/marketing         the site
packages/              the shared libraries

Local Studio's additions on top of T3:

The model: the app is a client, the agents are external CLIs. The user installs the CLIs before first use. The app talks to them through T3's provider layer.

A hard rule: a script kept Local Studio's own source under 4,000 lines on top of T3 Code. Upstream updates had to stay easy to merge. The app could not grow its own logic. It could only wrap T3 Code. The agent harness was whatever CLI the user installed. The UI was a browser page in an Electron window.

The T3 GUI structure is sound: the sidebar of threads, the timeline, the composer, the right panel, the settings. The layout is good. The problem is what the layout runs on, and what it wraps.

The direction was already clear to you, Sherif Cherfa (0xSero), the original maintainer. In your words:

I'm going to erase all the nonsense out of Local AI. No more -exl3-3bpw-tensor-gcq. Click button, have AI. All devices. Not a harness, but works with every harness out there.

Less complexity, less nonsense, a button that just works. The fork model, with its 4,000-line budget, its eight provider CLIs, and its web page in an Electron window, cannot get there. The standalone rewrite does.

3. Why the fork failed

Four walls.

3.1 It was an Electron app, and you could tell

A web page in an Electron window. The "native" feel came from CSS. A web UI follows web conventions, and web conventions are the wrong conventions for a desktop tool: hover states that do not belong on a desktop control, a pointing cursor, popovers that clip at the window edge, transitions that flicker, a visual language that is the web's, not the operating system's. The result is a design that feels incoherent. Misalignments, unnecessary cognitive load, interactions that are not as intuitive as they could be. Nothing is broken. Everything is slightly off, and the sum of the slight-offs is an app that feels like a website you run from a dock icon.

A native app starts from the platform's conventions, not the web's. Scroll, focus, keys, menus, popovers, and animation follow the operating system's model, because they are the operating system's model. The app feels like part of the OS, not a page the OS hosts. That is the biggest reason the rewrite was worth it.

3.2 Harness quality varied

Each provider CLI is a different harness. Pi, Oh My Pi, Codex, Claude, Cursor, Grok, OpenCode, Antigravity. Each has its own session model, tools, memory, failure modes. The app adapts to eight harnesses. Quality depends on the CLI the user picks. The app inherits the worst one. Not all harnesses are equally good, and a fork cannot fix the harness it wraps.

3.3 The 4,000-line budget blocked growth

New logic had to fit under the budget, or go upstream to T3 Code, which Local Studio does not control. The app could not grow its own identity. It could only wrap.

3.4 Two codebases for one app, on a 1.2-million-line base

The web app and the desktop shell shared packages/app-core, but the share was Node modules. A third surface (a native client) would need the same Node runtime. Every platform paid the web cost. The whole thing sat on a T3 Code tree that Local Studio had to keep merging.

Measured from the git trees:

                             Fork (T3)                Standalone (Eukhe)
---------------------------  -----------------------  ---------------------
Total source lines           1,233,099                83,647
Total source files           21,193                   263
Lines Local Studio owned     ~4,000 (budget-capped)   83,647 (all of it)
Upstream to track and merge  T3 Code                  none
Agent harnesses              8 provider CLIs          1 (Eukhe)
GUIs                         1 web app (Electron)     2 native (Swift, GTK)
Runtimes shipped             Chromium + Node + React  none (native)
Platforms                    macOS, Windows, Linux    macOS, Linux

The distinction is owned versus tracked. The fork was a 1.2-million-line tree, but Local Studio owned about 4,000 of those lines and had to keep the other 1.23 million in sync with T3 Code. The new app is 84,000 lines, and Local Studio owns all of them, with nothing to track. The project went from managing 1.2 million lines of mostly someone else's code to owning 84,000 lines of its own. A 93% reduction in the code the project manages, and it removes a whole class of work: no upstream to merge, no budget to fit under, no provider layer to keep normalizing eight CLIs. The complexity did not move. It was deleted.

The fix is a different shape: one agent core, one shared logic core, two thin native GUIs.

4. The decision: stop forking, build standalone

Stop forking T3 Code. Build a standalone app on a strong core. The clean break is one commit: chore: clean break from the T3 Code stack. The 4,000-line budget drops in the same move. The web, desktop, mobile, and marketing apps are removed. The vendored reference repos are removed. What remains is a native app and a shared core.

The new shape has three layers:

+---------------------------------------------------------------+
|  GUI layer (thin, native)                                      |
|  macOS: Swift + AppKit/SwiftUI    Linux: Rust + GTK4          |
|  native chrome only: windows, menus, drawing, input           |
+---------------------------------------------------------------+
|  Shared logic core (Rust, crate local-studio-core)            |
|  inbox, transcripts, state, git, files, terminals, usage,     |
|  fleet client, settings. One copy, linked by both GUIs.       |
+---------------------------------------------------------------+
|  Agent core (Eukhe, out of process)                           |
|  eukhe-daemon: supervisor + one worker per session.           |
|  sessions, durable storage, harness, memory, tools, inference.|
+---------------------------------------------------------------+

The rule that holds the layers together: logic needed by both GUIs goes in the Rust core. Platform behavior (windows, menus, toolbars, notifications) stays in the GUI. Agent logic never leaves Eukhe. The GUIs never reimplement the agent.

This is the same shape as Ghostty. Ghostty is a native terminal whose hard part, terminal emulation, lives in a core library (libghostty). The GUI is thin. Local Studio is the same: the hard part, the agent, lives in Eukhe. The GUIs are thin. Eukhe is to Local Studio what libghostty is to Ghostty.

5. Picking the stack

The architecture is only as good as the choices that fill its three layers. Each choice names its alternatives and the reason for the pick. The constraints that drive all of them: two platforms (macOS and Linux, no Windows), no third-party extension ecosystem, one maintainer. Those constraints are the opposite of the ones that pushed Raycast toward a web stack, and they are why the right answer here is native.

5.1 Why the shared core is Rust

The shared logic is Rust, in the crate local-studio-core. Rust for the layer both GUIs link, for four reasons:

The alternative is a TypeScript core (the T3 way) with a Node runtime on every platform. That is what the fork had, and it is what made every platform pay the web cost. A Rust core is the same "one shared logic layer" idea, without the runtime tax.

5.2 Why the macOS client is Swift

The macOS client is Swift, using AppKit for the window, menus, and input, and SwiftUI for the views. The reason is the same reason Raycast v1 was Swift on AppKit: it is the platform's own toolkit, and a keyboard-first power tool needs the platform's own input, menus, and accessibility.

AppKit gives, for free and correctly:

Raycast found the standard components were not built for the keyboard-first workflows it cared about, so it built its own rows, shortcuts, and behaviors on top of AppKit. Local Studio does the same: AppKit for the platform integration, the dense keyboard-driven UI built itself. SwiftUI where it is good (the views), not where it fights the app (the input and the window).

The alternative is to run the same web UI on macOS that the fork had. That is the Electron path, and it is the incoherent web-convention UI in section 3.1. On macOS, where the platform toolkit is mature and the app is a daily driver, there is no reason to pay the web cost.

5.3 Why the Linux client is GTK4

The Linux client is Rust, using GTK4 and libadwaita. The same choice Ghostty makes on Linux: the platform's own toolkit, so the app is a native Linux app, not a web page.

The alternative is a cross-platform toolkit (section 5.5) or a web view. On Linux, GTK4 + libadwaita is the boring, correct, native answer.

5.4 Why the agent core is Eukhe

The agent core is the most important choice. The app is only as good as the harness it runs. The decision: use Eukhe, and use it as the single harness for every model and every provider.

What Eukhe is. Eukhe (Greek εὐχή, a wish) is a coding agent, a fork of Prime Agent by Prime Intellect. Its defining feature is memory: one endless chat log shared by all sessions. No empty new session, no compaction that drops the start. Each turn starts from a compact view of the full log. The memory design is Victor Taelin's OptChat: an append-only log at ~/.eukhe/chat/, a compactor that writes one-line summaries into a binary tree over the log, a per-turn view of 64 to 128 KB of lines.

Why one harness, and why this one. The fork ran eight harnesses of varying quality. Eukhe removes that variance: every model, every provider, every thread runs through the same session model, tools, memory, failure handling. The app adapts to one harness, and it is the good one. This is only possible because the model-serving layer is harness-agnostic (section 6.4): the controller serves models to any harness, so the app picks the best one for the agent role instead of supporting all of them.

Eukhe's core rules are not just tested, they are proven. Eukhe states its rules as laws, models them in Bend, and proves them. The supervisor's restart budget, the timeouts, the revival veto, the orphan exit, and the replacement gate all run as compiled models: Rust gathers the facts and acts on the answer the compiled model gives. There is no Rust copy of the rule. The proven model is the running code. For a harness that runs model-generated code with the user's permissions, a proven invariant is stronger than a test that happens to pass.

Why the daemon. Eukhe runs out of process as eukhe-daemon, a supervisor with one worker per active session. The GUIs reach it through the eukhe-client crate over a versioned protocol. Two properties the fork could not:

Why not keep T3's harness. T3's harness is a provider layer over external CLIs. A good design for a product that wants to support many CLIs. The wrong design for an app that wants one high-quality harness. To keep it, Local Studio keeps the CLIs, the provider layer, and tracking T3. To get one good harness, it drops all of that and runs its own. That is what the clean break did.

5.5 Why not a cross-platform GUI toolkit (GPUI)

The first native attempt was GPUI, Zed's Rust GUI toolkit. A GPUI client was built, restructured, and driven against a headless core over stdio. Abandoned in favor of the native toolkits, for four reasons:

The GPUI work was not wasted. It proved the core-over-a-boundary design: a headless core with a generated contract and a thin client. That design survived into the final architecture, where the client is Swift or GTK instead of GPUI.

5.6 Why not Electron or a web stack

The fork was Electron. The new app is not, and this is a deliberate reversal.

Raycast's deep dive is the best recent statement of the web-stack case, and it is honest about the trade-offs. Raycast chose a web frontend because it needed Windows, a huge third-party extension ecosystem, and a large hiring pool, and because most of its code is UI that must be identical on both platforms. Those are real reasons, and they are the reasons a web stack is the right call for Raycast.

Local Studio's constraints are the opposite:

The web stack's advantages do not transfer, and its costs do. The costs are the ones section 3.1 lists: the incoherent web-convention UI, the memory baseline (Raycast measures its own v2 at 350–450 MB versus v1's 200–300 MB, and calls the increase real), and the IPC infra that has to be built and debugged per platform. A native app on macOS and Linux pays none of those, because the platform is the UI.

The decision is not "native is always better." It is "for these constraints, native is better." Raycast's constraints make web right. Local Studio's constraints make native right. Both are defensible; only one fits this app.

6. The architecture in detail

6.1 The shared core

The shared logic lives in core/, the Rust crate local-studio-core. Its description is the whole point: "Shared Local Studio logic: the Eukhe daemon client, session model and transcript reducer for the native GUIs."

The core holds everything both GUIs need:

The core depends on Eukhe by path. It links eukhe-client and eukhe-types. It does not copy Eukhe code. Eukhe changes land in the Eukhe repo, not as copies or patches in Local Studio.

Two things run in the core that used to run in the web page:

6.2 The kit: one core, two bindings

The core is exposed to the GUIs through a kit. On macOS, the kit is a static library with Swift bindings, generated by UniFFI. The Swift app links it as LocalStudioKit. On Linux, the GTK app is Rust, so it links the core as a crate directly.

The UniFFI boundary is the contract. The core's public types (ThreadRow, Item, Update, ModelRef, AccessMode, and the rest) are the records and enums the GUIs see. The GUIs never touch the daemon protocol directly. They call core methods and read core types.

6.3 Core to GUI: one direction, one mailbox

The protocol from core to GUI is one-directional and simple. Core methods are fire-and-forget. The core calls Waker.wake() when it has work. The GUI drains Studio.tick() on its main thread and gets a batch of Updates. The GUI applies the updates and redraws.

This keeps the main thread in control. The core does its work on its own tasks (daemon events, terminal threads, the highlighter). It hands the results to the GUI as a batch. The GUI decides when to apply them. On macOS, core updates are drained and lifted from the FFI on a background queue, with at most one batch awaiting the main thread, so a flood of terminal frames does not cost main-thread decoding.

6.4 The controller and the fleet

The local-LLM side is a small Bun service, controller/. Each machine runs its own controller. The controller watches the inference servers on its machine and serves them to the rest of the fleet. Controllers that share a fleet key link into one graph.

The controller:

The core talks to the controller over HTTP. It can start a bundled copy of the controller with its own data root. The app's Settings → Local page manages machines, loaded models, and the registry through the controller. The Local page is a per-machine dashboard built from the controller's telemetry.

The fleet is how a local model becomes a first-class option in the model picker. A machine with a GPU runs an engine. The controller exposes it. The app lists it next to the cloud providers. The user picks it like any other model.

This is the part of the architecture that delivers your "no more -exl3-3bpw-tensor-gcq. Click button, have AI." The model picker shows a clean model name, not a quantization-tagged string. The registry launches a recipe with one click. And the controller is exactly what you meant by "not a harness, but works with every harness out there": it is a model-serving gateway, not an agent. It serves models to whatever harness wants them. Eukhe is one such harness. That separation is what lets the app commit to a single proven agent harness without the model-serving being locked to it.

7. The native split: what is shared and what is not

The split is strict. The core holds the logic. The GUIs hold the chrome.

Layer   Owns
-----   ----------------------------------------------------------
Eukhe   sessions, memory, tools, inference
core/   inbox, transcripts, state, git, files, terminals,
        highlighting, usage, fleet client, settings
GUI     windows, menus, toolbars, notifications, drawing,
        input

A rule enforces the split: never port core logic into Swift or GTK code. If a behavior is needed on both platforms, it goes in the core. If it is platform behavior, it stays in the GUI. This is what keeps two GUIs maintainable. A change to the inbox logic is one change in Rust, and both GUIs get it. A change to the macOS menu bar is one change in Swift, and Linux is untouched.

The app is macOS and Linux only. The fork model shipped Windows (an unsigned installer, per the upstream README). The new app drops it, for the same reason it is two native GUIs: a third platform means a third GUI, and the value of the native split is that each GUI is small. Windows is not where the app is used daily, and a third native toolkit (or a web fallback) would reintroduce the cost the rewrite removed. Eukhe itself runs on macOS and Linux, so the agent core has no Windows build either. Windows is a limitation, stated in the README, not a deferred feature.

8. Why native matters

A web app in a window is a browser that happens to show your UI. It pays for the browser: the rendering engine, the layout engine, the JavaScript runtime, and, in development, a React runtime that runs in development mode. The upstream README called the development-mode penalty "many times slower." For a single chat, that is tolerable. For many long threads, each with a timeline, a terminal, and a right panel, it is not.

A native app pays for the platform once, and the platform is fast. The specific wins in Local Studio:

A coding tool is used for hours, many times a day, with many threads open. The cost of a slow frame is paid thousands of times. A native app makes the common case fast, and the common case is the whole product.

9. The intuitiveness changes

The T3 GUI structure was a good starting point, and the new app keeps it. The sidebar of threads, the timeline, the composer, the right panel, the settings. What changed is how the app behaves inside that structure. The goal: make the app feel like a tool a person uses all day, not a demo. The work fell into a few groups.

Keyboard first. The defining property of the app, and the one that most clearly separates it from the fork. Everything works from the keyboard. F6 or the arrow chords move focus between regions. Arrows, Return, Space, and Delete work in the lists. A command palette reaches every command. The composer, header, and pickers share one popover panel with keyboard navigation. Vim mode adds a modal composer with a NORMAL/INSERT indicator. Keybindings are indexed by command and chord, so the menu bar does not scan every binding per item. A web app can simulate keyboard shortcuts. A native app has the platform's real key handling, focus rings, and menu integration, which is what makes keyboard-first feel native instead of bolted on.

The working state is honest. A working thread shows a working row with a start time that never steps back, even when the core reports the same turn's start a moment later. The sidebar badge ticks every second through its own timeline, so the row does not re-render. The working indicator is drawn from the app's logo: leaves pulse left to right over the cloud for working rows and tools, and the full mark flips like a coin for loading states. Core Animation only, paused when hidden, static under Reduce Motion.

Sending feels instant. A follow-up in an existing thread is drawn the moment Return is pressed. The message shows as a local bubble after the last timeline row, outside the indexed transcript, which the core keeps addressing by position. It gives way to the core's user item when that arrives. If the core rejects the send, the bubble goes, the error shows, and the prompt returns to the composer. The newest-turn tail is laid out eagerly below the lazy rows, so an appended message is realized at once instead of staying unrealized below a long reply.

The composer stays put. The composer collapses on scroll without moving the chat or its scroll position. The composer band keeps its expanded height, the card folds inside it, and the send button stays. A thread switch keeps the composer still, reusing the last thread info.

Deliberate motion. Four deliberate motions, played through the app-wide animation switch-off so they can be captured frame by frame: sidebar rows slide to their new order, Copy swaps to a checkmark, a thread's badge bounces once when it turns to Approval or Failed, and the toast slides in from the top and back out. Motion is a feature, not a default. Off by default where it would add noise, and testable.

The details that make it feel made. Small non-zero costs show their value instead of rounding to zero. The thread header shows the sidebar row's title so it does not lag with a pending start. The browser surface observes the page title, so titles a page sets from script reach the tab. The dead composer and model-picker bindings got real handlers. A chord whose bound key needs Shift on the current keyboard layout matches through the shifted character, so the bindings work on German layouts.

Each one is the difference between an app that feels like a prototype and an app that feels like a tool. The structure came from T3. The feel is new.

10. What was removed

The clean break removed the fork model's weight:

What was kept:

The app is smaller because the hard part is not in it. The hard part is in Eukhe, and it is shared.

11. Maintenance: why this shape lasts

The shape is built to be maintained by one person across two platforms.

The cost of the shape is up front: two GUIs to build, a core to design, a kit to generate. The payoff is that the steady state is cheap. A new feature is a core change plus two renderings. A platform bug is a one-platform fix. A harness improvement is a dependency bump. And the project owns 84,000 lines instead of tracking 1.2 million.

12. The decision

The fork model was the wrong shape for the goal. It was an Electron app you could tell was an Electron app, with an incoherent web-convention UI. It ran eight harnesses of varying quality. It shipped a web app in a window. It capped its own growth at 4,000 lines on top of a 1.2-million-line T3 tree it had to keep merging. The T3 GUI structure was good, and the new app keeps it. Everything else was rebuilt.

The new shape: one agent core (Eukhe), one shared logic core (Rust), two thin native GUIs (Swift, GTK). The agent logic is proven and shared. The app logic is shared. The chrome is native and per-platform. The app is a client to a daemon, so threads outlive the window and every frontend sees the same sessions. The local fleet makes a local model a first-class option. The intuitiveness work, led by keyboard-first, makes the app feel like a tool a person uses all day.

A coding tool that runs many long threads, on local and cloud models, for hours a day, needs a harness that is good and proven, a core that is shared and fast, and a surface that is native and pleasant. Forking T3 Code could not give any of those three. Building on Eukhe gives all three.

References