Skip to content
Harjot Singh Rana

Full-Stack & AI Product Engineer

All writing

Helicon: A Desktop Client for Meta's Muse Code

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.

The version I deletedUIregex over ANSIptykeystrokes backmuse (TUI)approvals inferredfrom formattingWhat shippedUIrenders eventsdaemon + SDKone host per workspacemuse serveMSP events,both directions

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.

Scraper: a security decision inferred from formattingmuse (TUI) prints"Allow? [y/N]"regex over ANSIis this an approval?write "y" to ptyhope it landsprompt reworded= hang, or rununapprovedProtocol client: a decision addressed by idmuse serve emitsapproval/requestedid, cmd, cwdUI renders the cardnothing runs yetuser clicks allowreply to that idallow once / reject/ always allowprompt reworded= nothing changesThe event carries the command, so the UI can show exactly what will run.The id carries the answer, so the reply cannot be misdelivered to a different request.

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.

ApproachWhere the model call originatesWhat the user pays
Reimplement the loop against the model APIyour clientpay-as-you-go API rates
Generic OpenAI-compatible frontendthat frontend's harnesspay-as-you-go API rates
Drive muse serve over MSPMuse's own harnesstheir existing subscription
reimplement the loopyour own agent codegeneric OpenAI clientsomeone else's harnessdrive muse serveMSP + your muse loginmodel API endpointno plan attachedMuse's own harnessthe metered pathpay-as-you-goa second billyour subscriptionthe plan you boughtSame model in all three rows. The meter is attached to the harness, not to the endpoint.That single fact is why a GUI can be free of charge, or quietly cost your users money.

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:

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.

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.

WindowsTauri app + signed installerdaemon on bundled Nodethinks inC:\Users\you\projectWSL2 Ubuntumuse serve, the real agentyour muse login lives herethinks in/mnt/c/Users/you/projectwsl -dUbuntu --Every path that crosses is rewrittenoutboundC:\src\app.js → /mnt/c/src/app.jsinbound, or throw/mnt/c/src/app.js → C:\src\app.js

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.

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
    ...
}
Build timedownload pinnedNode v22.23.2verify SHA256against SHASUMS256lipo, macOS onlyarm64 + x86_64sidecarnode-<triple>Boot, in order1. runtime beside the appships with every install2. the node a terminal findsshell probes, fnm, nvm, brew3. explain, do not hangnamed error pageInstall steps before: install Node 22+ · install muse in WSL2 · muse login · run the installerInstall steps after: install muse in WSL2 · muse login · run the installer

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 · helicon.sh

More in writing, or back home.