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

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.

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.
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.
| App | Signed-in session | Agent home: sessions, plugins, config |
|---|---|---|
| Claude | --user-data-dir=<…>/desktop | CLAUDE_CONFIG_DIR=<…>/home |
| Codex | --user-data-dir=<…>/desktop | CODEX_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.
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.
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.
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.
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.
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.
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.
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.
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.
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./Applications. Other locations are not detected yet.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.Worth knowing before you run an unsigned app that touches your accounts.
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.
/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.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.
claude auth login or codex login inside that profile. This is what enables the usage bars.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.
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.
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.
--user-data-dir flag or the usage endpoints. The Default profiles keep working regardless.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.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.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 carhooks/register.ts 169 lines1// 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