SLOPSHOPPER

switchboard-remote

Lets Switchboard's phone app reply in this session and answer its approvals and questions while you are away from the Mac

guardnetworktimer
A shopper browsing a rack in a slop shop
README

Switchboard

Run multiple Claude accounts at the same time on one Mac, and multiple ChatGPT or Codex accounts too. Two, or as many as you have memory for. Keep a personal account and a work account both signed in, in their own desktop windows and their own terminals, instead of logging out and back in every time you switch. Each account also gets a live rate-limit bar, so you can see which one has headroom left before you start.

Switchboard showing four accounts, each with its own usage bars

It is for accounts you own. Native profiles use their own account. Codex desktop profiles can also use a shared proxy bucket, which selects accounts and handles inference quota failover. See Proxy buckets.

Can you run two Claude accounts at once? Or more?

Yes to both, and the same goes for Codex. There is no limit built in. Both desktop apps are Chromium-based, so each account gets its own user-data directory and runs as a genuinely separate window with its own session. The command-line tools are separated the same way, with an environment variable per account.

Each profile is isolated twice over, because the two halves hold different state. Both desktop apps embed an agent that reads the same config-home variable the command-line tool does, so the flag alone is not enough.

AppSigned-in sessionAgent home: sessions, plugins, config
Claude--user-data-dir=<…>/desktopCLAUDE_CONFIG_DIR=<…>/home
Codex--user-data-dir=<…>/desktopCODEX_HOME=<…>/home

Every account is a profile owning an isolated directory tree under ~/.switchboard/<vendor>/<profile>/. Profiles never share cookies, tokens, chat history or session state, and you can run as many at once as you have memory for.

The "Default" profiles point at the normal locations, ~/.claude, ~/.codex and ~/Library/Application Support/{Claude,Codex}. Switchboard never writes to them itself.

Which Claude app is which?

Two Claude apps on different profiles look the same, so Switchboard marks every Claude profile but the Default one. When it launches that profile's desktop app it passes a small Claude Code plugin (plugins/profile-band), and each Code session shows the profile band: a bar in the profile's colour just above the prompt. On the left are its name in bold, its plan, how full this session's context window is and what the session has cost. On the right, each of the 5-hour and 7-day limits shows a bar of what is left, when it resets (a countdown for the 5-hour window, a local day and time for the 7-day one), and its pace: "on track" for the 7-day window when less of it is used than of the week, or "runs out Fri 14:00" for either window when use so far would empty it before the reset. A narrow window gives up the cost, the "on track" note, the 7-day reset, the context, the 5-hour reset, the bars and the plan, in that order, then whole windows, keeping a window with a pace warning longest; the name always stays. The limits refresh every five minutes from Switchboard's cache, fetched live when that is over 15 minutes old, and need the switchboard command installed; context and cost update after every turn, and a compaction hides the context until the next one. The Default profile's app has no band. A rename or recolour shows after the profile's next launch.

Does it work with Claude Code and the Codex CLI?

Yes. Each profile gets its own CLI login, and the Terminal button opens a shell already inside that account, so claude or codex in that window uses it. The copy icon on each profile copies the one-line command, which can go into an alias.

Profiles show as a list or as cards; pick either with the toggle above them. Drag a profile to reorder it within its app; the Default profile stays first.

Signing a profile's CLI in is also what enables its usage bars.

What are the rate-limit bars?

Every window the account has: the 5-hour and 7-day windows for Claude, including model-scoped ones such as a separate Opus pool, and the weekly and model-specific pools for Codex. Each shows how much is used, or how much is left if you prefer, with the time until it resets. They turn amber and then red as a window runs out.

You can read them in the menu bar without opening the window.

Can I use a saved usage reset?

Choose Usage resets… in a signed-in profile’s menu or a bucket account’s menu. A dialog opens in the window with the account’s current usage bars and each saved reset: how many are left, when it expires, which windows it clears, and why it can’t be used yet when it can’t. Pick one and choose Use reset… to see a confirmation that names the account and what will be spent; only Use reset on that screen spends it. Closing at any earlier point spends nothing. The last screen shows the provider’s answer beside the refreshed bars, with the old percentages struck through.

Claude’s next eligible grant and ChatGPT’s Codex reset credits are supported. A routed profile labels its own action Native account resets…. To reset a proxy account, use that account’s menu in Proxy buckets. The proxy supplies the selected account’s token; no native profile is required. After the vendor confirms a reset, Switchboard clears that account’s proxy cooldown, rechecks usage and updates its routing weight. Paused accounts stay paused. Other accounts keep their existing cooldowns. It never buys credits or changes a plan. A recent Claude CLI must be installed for Claude reset checks.

Existing bucket workers need a restart after installing this feature. Until a worker supports the targeted refresh, its reset dialog shows grants but does not allow redemption. Switchboard does not restart an active bucket for you.

These are private vendor interfaces and can change. Unknown responses stop the action; failed requests are never retried automatically. Pending request IDs are stored locally so an uncertain reply can be retried without spending a second reset. Check the vendor’s Usage page if a result is uncertain.

The CLI uses the same checks and confirmation flow. Listing never spends a reset; select an exact grant ID from the output to redeem it. resets PROFILE always targets the native account, even when that profile routes through a bucket. Bucket accounts can be selected by their exact name or an unambiguous email.

switchboard resets claude
switchboard resets codex --redeem GRANT_ID --yes
switchboard bucket resets BUCKET ACCOUNT
switchboard bucket resets BUCKET ACCOUNT --redeem GRANT_ID --yes

Results are JSON by default; add --human for readable output. Each offer carries remaining, expiresAt, clears and, when it can’t be used, reason. Without --yes, redemption prompts in a terminal and refuses in noninteractive calls. Check the JSON outcome for the vendor result and proxyRecovery for not-needed, refreshed, deferred or unconfirmed. An exit of zero alone does not mean quota was reset. Run switchboard usage PROFILE --refresh for fresh native usage. bun run test:usage-resets runs the isolated app and proxy worker integration checks.

Agent instructions

skills/switchboard/SKILL.md is the canonical agent guide. The CLI tab's Copy agent prompt button copies that file, and switchboard guide prints the same Markdown from the installed app. Packaging includes the guide, so instructions and executable commands update together.

Agentfiles and other skill managers can use a short entrypoint that tells the agent to run switchboard guide before operating Switchboard, rather than maintaining a separate copy of the operating instructions. For older installations without guide, update Switchboard; switchboard --help can identify the available commands.

Is the app running?

Each profile shows whether its desktop app is running: a green dot in the list, a pill on a card, and "(not running)" after its line in the menu bar. It keeps up on its own. Launching or quitting an app from anywhere, the Dock, Cmd+Q or the switchboard command, shows up within about a second, so there is no need to press Refresh, which is for the usage numbers.

Launch is the black play button and Quit the red power button. While one is on its way the button spins and the profile reads Starting… or Quitting…. An app still running ten seconds after Quit reads Won't quit, usually because it is asking you to confirm; switch to the app and answer it, or use Force quit, which loses anything unsaved in it.

Closing an app's window with its red button leaves the app running. Turn on Notice closed windows in Settings and such a profile reads No window, with a hollow dot, instead of Running. This needs Accessibility permission: turning the switch on asks macOS for it, and the switch stays off until you allow Switchboard in System Settings, then turns on by itself. Because the app is ad-hoc signed, macOS forgets the permission after each rebuild; the switch then reads off again, with a link to the settings pane, where you turn Switchboard off and on. A window that is minimised, hidden or on another desktop still counts as open. Whether or not the setting is on, Show window (a button on a No window row, and in each running profile's menu) brings that profile's window back, as clicking its Dock icon would.

Can I check on my agents from my phone?

Yes, and reply and approve from it too. Open the Phone tab, turn on phone access, press Publish to put it on your tailnet, and scan the pairing QR code with your phone. The phone app lists what needs you and what is working across every profile, each profile's usage, and each session's transcript with its steps, diffs and the PRs it opened. Reply in a session, answer its questions and approve its commands from the phone, and turn on notifications for when one needs you or finishes. It reads the session files the desktop apps already write, so the apps work exactly as before. An app you opened from the Dock can be watched but not replied to until you relaunch it from the Phone tab or the phone; Default Claude can instead take replies however it was opened, with Default Claude from the Dock in the Phone tab. Read Phone access for setup and how it is secured.

Keep the Mac awake

Use Keep awake beside Refresh to turn macOS's system sleep setting on or off. macOS handles administrator authorization. The control reads the actual setting, including after you reopen Switchboard; closing or crashing the app leaves the setting as you chose it.

For implementation details and closed-lid hardware verification, read Keep awake.

Requirements

  • A macOS whose open supports --env, which is how a profile's config home reaches the app. Confirmed on macOS 26; I have not established the earliest version that carries the flag, so check with man open if you are on something older.
  • Claude Desktop, the ChatGPT app, or both, installed in /Applications. Other locations are not detected yet.
  • The claude or codex CLI for the usage bars. Switchboard asks your login shell for its environment, so a version manager such as nvm, fnm, volta or mise is fine, and so is fish or bash rather than zsh.
  • Apple Silicon has been tested. Intel should build, but has not been tried.

What it reads, and what leaves your Mac

Worth knowing before you run an unsigned app that touches your accounts.

  • Your keychain. To show Claude usage, Switchboard runs `security

find-generic-password` for the entry the Claude Code CLI already created. macOS will ask for your password the first time. It asks again after each rebuild, because ad-hoc signing produces a new code hash and the keychain entry's permission no longer recognises the app. Codex tokens are read from a file instead, so they produce no prompt.

  • App launches and quits. A small helper bundled with the app listens for macOS's notice that some app started or stopped, so the window can show a profile's app opening or closing at once. It reports only the process and its path, to Switchboard alone, and exits with it.
  • Other apps' windows, only if you turn on Notice closed windows. The helper then counts the Claude and ChatGPT windows each profile has open, through the Accessibility API and an undocumented macOS call that says which desktop a window is on. It reads counts, not window contents.
  • Usage requests per signed-in profile, on a timer. Normally one call to the same endpoints the two CLIs use for their own usage screens, authorised with that profile's existing CLI token. The interval in Settings is the rate while you are using Switchboard; left alone it slows to every 15 and then 30 minutes, never polls faster than the setting on battery, and stops while the Mac is asleep or locked. Who is signed in is only re-checked hourly, on a manual refresh, or when a usage call says the token is gone. Codex tries a second URL only if the first answers 404. Neither endpoint is documented by its vendor, so both can change without notice and the bars can go blank. Claude usage may retry once after a token change. Switchboard has no telemetry; the Claude CLI has its own network behaviour when started for session renewal.
  • Tokens never leave the main process. They are read, used for that one request, and dropped. What the window receives is the profile's own name and colour, its directory paths and the CLI command to enter it, whether the app is running, and from the account: the email address, plan name, organisation name, and the usage percentages with their reset times.
  • Never credentials. Switchboard does not write logins, and it will not copy API keys or credential helpers between profiles. When a Claude subscription token expires, Switchboard briefly starts Claude Code in an isolated empty directory so the CLI can renew that profile's OAuth session, then rereads the credential and fetches usage. The hidden terminal uses macOS /usr/bin/expect, safe mode, no tools or MCP servers, and no model prompt. Only the trust menu for that empty directory is accepted. Recovery has a 20-second deadline plus bounded process cleanup; failed attempts have a one-minute cooldown. If Claude requires interactive sign-in, renewal stops and the profile must be opened in Terminal. A rejected token that has not expired also requires sign-in, unless another CLI has already replaced it.

Install

bun install
bun run install-app

That builds Switchboard.app, copies it to /Applications, ad-hoc signs it and opens it. Launch it like any other app afterwards. Re-run the same command after changing the source. For a dev run without installing, use bun start.

The app is not signed with a Developer ID and not notarised, so macOS may warn about it. Building it yourself, as above, is the intended path.

It also lives in the menu bar with launch, quit and usage per profile, so you can close the window and leave it running. Settings has an "Open at login" switch; started that way it stays in the menu bar until you click it.

Adding an account

  1. Click + Profile in the Claude or Codex panel and name it, then choose which existing profile to start from and what to bring over.
  2. Click the launch button (the filled play icon on the row). A fresh window opens with no session. Quit the other windows of that app first, using the "Quit others now" link on the card. The sign-in link is delivered to whichever window macOS picks.
  3. Click Sign in CLI on the card's warning line (or in its ⋯ menu). A terminal opens running claude auth login or codex login inside that profile. This is what enables the usage bars.
  4. The terminal icon opens a shell already inside the profile, in whichever terminal you choose in Settings. Terminal.app, iTerm2, Ghostty, Warp, kitty, Alacritty and WezTerm are supported, and only the installed ones are listed.

Remove on a card deletes the profile and everything under its directory: the CLI login, the desktop session, history and settings. There is no way to remove a profile and keep its data. The Default profiles cannot be removed.

Keep in sync, or copy once

Items you bring over can be linked or copied, and the difference matters.

Keep in sync makes the new profile's file a symlink to the source profile's. One edit then applies to both, which is the point for skills you maintain in one place. It also means the reverse: if the second account's agent rewrites a file, for example when it updates CLAUDE.md or AGENTS.md from a memory shortcut, that write lands in the source profile too. Choose Copy once for anything you want the two accounts to be able to change independently.

Chat history is always copied, never linked, because two accounts writing into one session folder would corrupt each other's resume lists. For Codex it also carries the sidebar's project list and which project each thread sits in, since the app keeps that separately from the sessions and would otherwise show the threads unplaced or, for ones run in worktrees, not at all.

Logins, memories and session state are never brought over at all. Connectors and plugins are offered but off by default, because they reach the source account's Slack, Notion and so on.

Driving Switchboard from a terminal or an agent

Everything the window does is also a switchboard command, with results as JSON so an agent can read them and a person can pipe them into jq. The common questions have one-line answers:

switchboard list                      # profiles, running state, signed-in account
switchboard usage --max-age 15m       # rate-limit windows, refreshed if older than 15 minutes
switchboard pick claude               # the Claude profile with the most quota left
switchboard exec work -- claude -p "…"   # run anything inside a profile's account
eval "$(switchboard env work)"        # put the current shell into a profile
switchboard launch work               # open its desktop window
switchboard focus work                # show its existing window; never launch another profile
switchboard quit work --force --yes   # force quit; reports whether it actually stopped
switchboard awake status              # read the system keep-awake setting
switchboard accessibility status      # permission of the running Switchboard app
switchboard export work --format t3   # print a T3 provider configuration
switchboard add codex Client --from codex   # a new profile, set up like the Default
switchboard routed work app-server    # Codex through work's proxy bucket, as its window runs

awake status|on|off|recheck, accessibility status|open, focus and forced quit use the running Switchboard app through a local socket accessible only to your user account. Start the matching app first; these commands never start it implicitly. Keep-awake shares the window's controller and macOS authorization. Its result reports the observed state and any cancellation or failure notice; exit 0 alone does not mean the requested setting was applied. Accessibility reports the app's executable identity, not the terminal's permission. open opens the macOS Accessibility pane. Force quit asks for confirmation unless --yes is supplied and waits for the result; stopped: false means the app has not exited. Ordinary quit still works without Switchboard running.

export PROFILE --format t3 prints a providerInstances fragment containing the profile's name, colour and actual CLI home, with driver claudeAgent or codex. It contains no credentials and never edits T3 settings. IDs longer than T3’s 64-character provider-key limit are refused with manual setup guidance. Proxy-routed Codex profiles are refused because exporting their native home would silently lose routing. Use a native-account profile for export, or the routed command below.

A Codex profile assigned to a proxy bucket reaches the pool through a local port that changes whenever the bucket's worker restarts. A client Switchboard does not launch, such as T3 Code or a script, should run switchboard routed PROFILE as its Codex binary; it starts the worker if needed and finds the current port on every call.

Install it from the CLI tab in the window, or once with switchboard install-cli (or node out/cli.js install-cli from this checkout) after bun run install-app. The command runs on the installed app's own runtime and code, so it is always the same version as the app, and bun run install-app updates both. bun run cli -- list runs it from source without installing.

Profiles are addressed by id (claude-work), by vendor (claude means that vendor's Default), by vendor/name, or by a name that only one profile has. switchboard --help lists every command.

The contract, for anything that parses the output: a successful command prints its result as JSON on stdout and exits 0. A failed one prints nothing on stdout, one JSON object on stderr with a stable error code, a message and usually a hint, and exits 1 (it ran and failed), 2 (usage), 3 (not found) or 4 (refused by a safety rule, such as removing a profile whose window is open, or --yes missing). exec, cli and routed exit with the child's own status. Per-profile usage errors are data inside a successful result, with a status of ok, stale, not-signed-in, error or none.

The CLI reads the app's cached usage numbers by default and fetches live only with --max-age or --refresh; it never polls. It writes profiles.json directly, and a running app notices and reloads within about a second, so the window and the command line never disagree about which profiles exist.

Caveats

  • Neither vendor supports any of this. An update to either app could break the --user-data-dir flag or the usage endpoints. The Default profiles keep working regardless.
  • Launch extra profiles from Switchboard rather than from the Dock. A Dock launch carries neither the flag nor the variable, so it opens the Default profile regardless of which window you meant.
  • Codex and ChatGPT are the same ChatGPT.app bundle, and every profile launches that one bundle, so an update to it applies to all of them. macOS registers each instance separately, which means one Dock icon per running profile rather than one for the app.
  • Claude token renewal depends on the installed CLI's interactive startup behaviour and may break after a CLI update. If automatic renewal reports that sign-in is required, open that profile in Terminal and sign in there. Organisation-managed Claude policies can still affect this isolated startup.
  • ANTHROPIC_API_KEY in your shell makes Claude Code bill the API instead of your subscription. Switchboard never sets it; check your own shell config.

Development

The source is TypeScript. The main process is compiled by tsc into out/, which is what Electron runs. The window is a small Preact app under src/renderer/, bundled by bun build into out/renderer/main.js; its styling is one stylesheet of tokens and primitives modelled on Wispr Flow, with Figtree and EB Garamond bundled. Tests import the .ts files directly.

bun test          # profile isolation, setup logic and the usage parsers
bun run test      # the same, after oxlint and a type check
bun run test:claude-pty # compiled PTY integration checks under Node, on macOS
bun run typecheck # tsc --noEmit
bun run lint      # oxlint
bun format        # oxfmt; bun run fmt is also supported (CSS/HTML excluded by config)
bun start         # build, then run without installing
bun run demo      # regenerate the screenshot and social car
Source 1 files
hooks/register.ts 169 lines
1// A Claude Code mod that lets Switchboard's phone app act in this session.
2// Switchboard passes it to every Claude profile it launches while phone
3// access is on (CLAUDE_CODE_PLUGIN_DIRS, see src/launch.ts), with the
4// control socket and the profile's id in the environment, and Default Claude
5// reads the same from ~/.claude/settings.json when that is switched on
6// (src/remote/reach.ts). Without the socket it does nothing.
7//
8// - A reply typed on the phone comes in as the person's own prompt
9//   ($.prompt.submit with asUser), a turn of its own once the session is
10//   idle. The plugin asks the hub for replies with a long poll.
11// - A tool call that would ask the person (tool.check settles on "ask",
12//   from the session's mode rather than a settings rule, which a plugin may
13//   not lift) and
14//   a question the model puts to them (AskUserQuestion) go to the phone, but
15//   only while nobody is at the Mac: the hub says so. Holding the call is the
16//   only way to wait for the phone, and it keeps the desktop's own dialog
17//   from showing, so the hub hands it back the moment someone is at the Mac
18//   again, and the dialog shows as usual. Anything that goes wrong does the
19//   same: the desktop is always the fallback. In auto mode the hub hands
20//   approvals straight back: the classifier decides them, and only once
21//   this hook returns.
22//
23// The hub side is src/remote/control.ts.
24
25import type { EngineInterface, Register } from 'claude-code';
26
27type Decision = { choice: string } | { answers: Record<string, string[]> };
28type Ask = { route: 'desk' } | { route: 'phone'; id: string };
29type Outcome =
30  | { status: 'pending' }
31  | { status: 'decided'; decision: Decision }
32  | { status: 'release' }
33  | { status: 'gone' };
34
35interface Link {
36  socket: string;
37  // Empty when the app was not launched by Switchboard (Default Claude
38  // opened from the Dock, reading the socket from settings.json): the hub
39  // then works the profile out from configDir.
40  profileId: string;
41  configDir: string;
42  sessionId: string;
43}
44
45// After the hub cannot be reached, wait this long before asking again.
46const QUIET_MS = 10_000;
47
48let link: Link | null = null;
49let polling = false;
50let quietUntil = 0;
51
52async function call<T>($: EngineInterface, path: string, body: unknown): Promise<T | null> {
53  if (!link) return null;
54  const res = await $.http.fetch(`http://switchboard${path}`, {
55    method: 'POST',
56    headers: { 'content-type': 'application/json' },
57    body: JSON.stringify(body),
58    socketPath: link.socket,
59  });
60  return res.ok ? (JSON.parse(res.text) as T) : null;
61}
62
63async function poll($: EngineInterface): Promise<void> {
64  if (!link || Date.now() < quietUntil) return;
65  try {
66    const reply = await call<{ commands: { type: string; text?: string }[] }>($, '/claude/poll', {
67      profileId: link.profileId,
68      configDir: link.configDir,
69      sessionId: link.sessionId,
70    });
71    for (const c of reply?.commands ?? [])
72      if (c.type === 'prompt' && c.text) await $.prompt.submit({ text: c.text, asUser: true });
73  } catch {
74    quietUntil = Date.now() + QUIET_MS;
75  }
76}
77
78// Puts a call to the phone and waits for its answer; null hands it back to
79// the desktop.
80async function viaPhone(
81  $: EngineInterface,
82  kind: 'approval' | 'question',
83  tool: string,
84  input: unknown,
85  signal?: AbortSignal,
86): Promise<Decision | null> {
87  if (!link) return null;
88  try {
89    const ask = await call<Ask>($, '/claude/ask', {
90      profileId: link.profileId,
91      configDir: link.configDir,
92      sessionId: link.sessionId,
93      kind,
94      tool,
95      input,
96    });
97    if (!ask || ask.route !== 'phone') return null;
98    for (;;) {
99      if (signal?.aborted) {
100        await call($, '/claude/done', { id: ask.id });
101        return null;
102      }
103      const out = await call<Outcome>($, '/claude/wait', { id: ask.id });
104      if (!out) return null;
105      if (out.status === 'decided') return out.decision;
106      if (out.status !== 'pending') return null;
107    }
108  } catch {
109    return null;
110  }
111}
112
113export const register: Register = (on) => {
114  on('session.start', async ($, e, next) => {
115    const socket = await $.env.get('SWITCHBOARD_CONTROL_SOCKET');
116    if (socket) {
117      link = {
118        socket,
119        profileId: (await $.env.get('SWITCHBOARD_PROFILE')) ?? '',
120        configDir: (await $.env.get('CLAUDE_CONFIG_DIR')) ?? '',
121        sessionId: await $.session.id(),
122      };
123      $.clock.every(1000, () => {
124        if (polling) return;
125        polling = true;
126        void poll($).finally(() => {
127          polling = false;
128        });
129      });
130    }
131    return next(e);
132  });
133
134  on('tool.check', async ($, e, next) => {
135    const verdict = await next(e);
136    // Only what would ask the person; a question or a plan is theirs to see.
137    // An ask a settings rule or hook made stays on the Mac: Claude Code does
138    // not let a plugin lift it, so the phone's answer would not count.
139    if (
140      verdict.decision !== 'ask' ||
141      !link ||
142      verdict.rule ||
143      verdict.hook ||
144      e.tool === 'AskUserQuestion' ||
145      e.tool === 'ExitPlanMode'
146    )
147      return verdict;
148    const decision = await viaPhone($, 'approval', e.tool, e.input, next.signal);
149    if (!decision || !('choice' in decision)) return verdict;
150    return decision.choice === 'allow'
151      ? { decision: 'allow', reason: 'Allowed from your phone' }
152      : { decision: 'deny', reason: 'Denied from your phone' };
153  }).catch(($, e, next) => next(e));
154
155  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
156    if (!link) return next(e);
157    // A tool call's arguments sit on the event itself.
158    const questions = e.questions;
159    const decision = await viaPhone($, 'question', e.tool, { questions }, next.signal);
160    if (!decision || !('answers' in decision)) return next(e);
161    const answers: Record<string, string> = {};
162    for (const q of questions) {
163      const picked = decision.answers[q.question];
164      if (picked?.length) answers[q.question] = picked.join(', ');
165    }
166    return { result: { questions, answers } };
167  }).catch(($, e, next) => next(e));
168};
169