# Helicon: A Desktop Client for Meta's Muse Code

Canonical URL: https://www.harjotrana.com/blog/harness-locked-not-terminal-locked
Author: Harjot Singh Rana
Published: 2026-09-16
Reading time: 19 min

> Meta's Muse Code is terminal-only, has no Windows binary, and only bills your subscription through its own harness. The desktop client I built for it, and why that last fact decided everything.

Meta shipped Muse Code in August: a coding agent that lives in your terminal, paired with Muse Spark, with subscriptions starting at five dollars a month. The model is good enough that I moved real work onto it within a week. The harness runs subagents in parallel and keeps an append-only event log you can replay.

Two things about it bothered me enough to spend a fortnight on them.

It is terminal-only. And on Windows there is no native build at all — the installer targets macOS and Linux, so Windows means WSL2 or nothing. Comparison guides have already absorbed this: at least three now tell readers that if they are on Windows or want a desktop app, they should pick a competitor instead.

So I built **Helicon**, an open-source desktop and web client for the actual Muse CLI. The part worth writing about is not the interface. It is a single architectural decision that determines what your users get billed, and I got it wrong the first time.

## The version I shipped first was a terminal scraper

The obvious way to put a UI on a CLI is to spawn it in a pty, parse what comes out, and render that. I did this. It took an afternoon. It worked the way demos work.

Then I tried to implement approvals.

The agent wants to run `npm test`. The user has to allow or reject it. In a scraped terminal, "the agent is asking for approval" is a string you pattern-match out of ANSI escape codes, and your answer is a sequence of keystrokes written back into a pty. You are inferring a security decision from formatted text, and answering it by typing.

Every output tweak upstream becomes a correctness bug in your client. Some of those bugs run commands nobody approved. There is no version of that which is safe, and no amount of regex hardening that makes it safe later.

I deleted it.

## What replaced it

Meta publishes the Muse Code SDK, MIT licensed, for programmatically driving Muse over the Muse Session Protocol. The CLI exposes `muse serve`, which speaks that protocol over stdio.

So Helicon's daemon spawns one `muse serve` host per workspace and talks MSP to it through the official SDK. Muse is the agent. Helicon is a client. That sentence is the whole design.

*Diagram: Two architectures compared. Top: a scraper spawns the CLI in a pty, parses ANSI output, and guesses at approvals, with the failure noted as output format changes become correctness bugs. Bottom: Helicon's daemon spawns muse serve and exchanges typed MSP events through the official SDK, so approvals and diffs arrive as structured data.*

*The difference is not tidiness. In the top row an upstream formatting change can execute a command the user never approved.*

Approvals arrive as protocol events. Diffs arrive as structured data with the file and range attached. Sessions started in the terminal appear in the sidebar with full history, because the CLI already wrote them in a form the protocol reads back — I do not parse scrollback to reconstruct them.

The difference is clearest on a single approval. Here is the same user decision — allowing `npm test` — travelling through both designs.

*Diagram: One approval decision traced through both designs. In the scraper, the agent prints a prompt, the client regex-matches the words Allow and Reject out of ANSI text, guesses which keystroke corresponds to allow, and writes the letter y into the pty, so a changed prompt wording either hangs or runs the command unapproved. In the protocol client, muse serve emits a typed approval request event carrying the command, the working directory and a request id, the UI renders it, the user clicks allow, and the client replies with a resolution addressed to that same id, so a changed prompt wording changes nothing.*

*Both rows implement the same feature. Only the bottom one is still correct after an upstream copy edit.*

The rule I would hand anyone wrapping a CLI: if the vendor documents a protocol, that protocol is the only surface that stays where you left it. Output formatting is not an API, however stable it looks on the day you sample it.

## The part that decides what your users pay

Here is the property I did not expect to matter most.

Muse Code subscriptions bill through Muse's own harness. Point a generic OpenAI-compatible client at the API and you are on pay-as-you-go rates instead. Same model, different bill.

People are working this out in public and reaching the wrong conclusion. In one thread a user states, twice, that a Muse Code subscription "can not be used in any kind of GUI harness" and that you have to switch to pay-as-you-go to use it anywhere but the terminal. Another person in the same thread confirms the symptom precisely: using the vendor's own harness deducts from the plan, and every other option — three different agent frontends by name — lands on the API.

Their observation is correct. Their conclusion is not.

The subscription is not terminal-locked. It is harness-locked. Once you stop trying to replace the harness and start driving it instead, a GUI costs nothing extra, because the work still goes through Muse under the user's own `muse login`.

| Approach | Where the model call originates | What the user pays |
| --- | --- | --- |
| Reimplement the loop against the model API | your client | pay-as-you-go API rates |
| Generic OpenAI-compatible frontend | that frontend's harness | pay-as-you-go API rates |
| Drive `muse serve` over MSP | Muse's own harness | their existing subscription |

*Diagram: Three ways to reach the same model, and where each one is billed. Route one, a client that reimplements the agent loop, calls the model API directly and is billed at pay-as-you-go rates. Route two, a generic OpenAI-compatible frontend, goes through that frontend's own harness to the same API endpoint and is also billed pay-as-you-go. Route three, a client driving muse serve over the session protocol, passes through Muse's own harness carrying the user's muse login, and is billed against the existing subscription. The subscription meter sits behind the harness, not behind the API endpoint, which is why the first two routes miss it.*

*The two rows people reach for first are the two that move users onto a second bill for a model they already pay for.*

This is the argument for the architecture, and it is worth more than any feature I could have built into the interface. If your wrapper reimplements the agent loop, you have quietly moved your users onto a second bill for a model they already pay for. That is the difference between a client and a fork, and users feel it monthly rather than once.

It also means my client never holds a credential. Auth stays with `muse login`, where the user put it.

## Windows, honestly

Muse has no Windows binary. A client cannot fix that, and pretending otherwise ships something that fails on first launch for every Windows user.

What Helicon actually does is more boring than "Muse Code on Windows" sounds. The daemon runs natively on Windows. When it needs the agent, it runs the agent where the agent exists:

```ts
if (platform === "win32") {
  const distro = options.distro ?? "Ubuntu";
  return {
    command: "wsl",
    args: ["-d", distro, "--", "sh", "-lc", "muse serve"],
    cwd: options.cwd,
    viaWsl: true,
    distro,
  };
}
```

Then it translates paths in both directions, because the daemon thinks in `C:\Users\you\project` and the agent thinks in `/mnt/c/Users/you/project`. Get this wrong and every file operation lands somewhere plausible and wrong, which is the worst class of bug to debug from a user report.

```ts
export function toWindowsPath(wslPath: string): string {
  const match = wslPath.match(/^\/mnt\/([a-z])\/(.*)$/);
  if (!match) {
    throw new Error(`Cannot map to Windows: not a /mnt/<drive> path: ${wslPath}.`);
  }
  const drive = match[1].toUpperCase();
  const rest = match[2].replace(/\//g, "\\");
  return `${drive}:\\${rest}`;
}
```

Note the throw. An unmappable path is a bug in my translation layer, not an input to guess at.

*Diagram: The Windows split. On the Windows side sit the Tauri app, the Node daemon and the bundled Node runtime, all thinking in backslash paths like C colon backslash Users backslash you backslash project. On the WSL2 Ubuntu side sits the muse CLI and the user's muse login, thinking in forward-slash paths like slash mnt slash c slash Users slash you slash project. The daemon crosses the boundary by running wsl dash d Ubuntu dash dash muse serve, and every path crossing is rewritten in both directions: outbound, a Windows drive letter becomes a slash mnt mount point; inbound, a slash mnt mount point becomes a drive letter, and anything that is not a slash mnt path throws rather than being guessed at.*

*The boundary does not move. What moves is which side of it you have to sit on to get work done.*

So WSL2 is still required. I say that on the landing page, in the install steps, and in the FAQ, because the first reply to any Windows claim is going to be "but it still needs WSL," and being the one who said it first is the only version of that conversation worth having.

What changes is not the requirement. It is that you stop living in an Ubuntu terminal on your own machine to use a tool you pay for. The installer is signed and auto-updates, which on Windows matters more than people on other platforms expect.

## Removing the last prerequisite

The daemon is Node. Until this week that meant a user had to install Node 22 or newer before Helicon would start — and on Windows, specifically on the Windows side rather than inside WSL, which is exactly the kind of instruction that loses people halfway through a README.

The fix is a Tauri sidecar. A build script downloads a pinned Node build, verifies its SHA256 against the published checksums, and writes it to `src-tauri/binaries/node-<target-triple>`. For the universal macOS build, `lipo` merges the two architectures into one binary. At boot the app prefers the runtime sitting beside its own executable and falls back to a system Node only when that is missing, which keeps source builds working.

```rust
fn find_node() -> Option<PathBuf> {
    if let Some(bundled) = bundled_node().filter(|node| node_runs(node)) {
        return Some(bundled);
    }
    // fall back to the node a terminal would find: shell probes, then well-known homes
    ...
}
```

*Diagram: Bundling the runtime, shown as build time and boot time. At build time the script downloads a pinned Node build, verifies its SHA256 against the published checksums, and writes it to src-tauri slash binaries slash node dash target triple; for the universal macOS build lipo merges the arm64 and x86_64 binaries into one. At boot the app looks first for that runtime beside its own executable, uses it if it runs, and only otherwise falls back to the node a terminal would find through shell probes and well-known install locations, showing an error page if neither exists. The install instructions shrink from four steps to three, because installing Node yourself is no longer one of them.*

*30MB of installer buys the deletion of one instruction, and of every support thread that instruction was going to generate.*

The cost is about 30MB on the Windows installer and rather more on the universal macOS DMG, which now carries two architectures of Node. The benefit is one fewer step in the instructions and an entire category of "it doesn't launch" issue that no longer exists.

One detail worth copying if you do the same: the bundled runtime's folder is deliberately kept off the PATH handed to subprocesses. Users run shell commands through the app, and their `node` should be their `node`, not the one I shipped.

## What I would tell myself at the start

Three things, in order of how much time each would have saved.

The protocol exists — look for it before you write a parser. I burned a day building something I deleted, and the SDK was published the whole time.

Billing is an architectural property, not a pricing page detail. On any platform with a subscription and an API, where the call originates decides what the user pays. Decide that deliberately on day one, because it is not a thing you refactor later.

Say the awkward constraint first. Every honest sentence about WSL bought me more credibility than any feature claim, and the one dishonest line I shipped — a headline implying WSL was gone — was the thing I had to fix within a day of publishing.

Helicon is free, MIT licensed, and unofficial: not made, sponsored or endorsed by Meta. The macOS builds are not notarized yet, Linux is source-only, and it is not the only GUI in this space — there are ACP adapters for Zed and JetBrains, and an unofficial VS Code extension. If you want Muse inside your editor, use those. Helicon is for people who want a standalone workspace.

[github.com/HarjjotSinghh/helicon](https://github.com/HarjjotSinghh/helicon) · [helicon.sh](https://helicon.sh)

More writing: https://www.harjotrana.com/blog

---

Site guide for agents: https://www.harjotrana.com/llms.txt · Full site as Markdown: https://www.harjotrana.com/llms-full.txt · Sitemap: https://www.harjotrana.com/sitemap.xml · Developer resources: https://www.harjotrana.com/developers