Agent profiles, part one: one profile is not enough
Running two coding agents across personal, mobile and security work broke down under one shared config. Here is the profile split that replaced it, and the launcher boundary that makes it safe.
RevisedCorrects the provider-name claim: Codex checks the display name case-sensitively, so lowercase was opting every proxied profile out. Adds the experimental-reset eligibility.
I use two coding-agent CLIs, Claude Code and Codex, across several kinds of work: my own repos and homelab, iOS development, and security review. One shared config for all of that stopped working quickly. A skill written for App Store Connect has no business loading into a security review. A plugin that talks to my home automation has no business being reachable from a review session that is meant to be as close to stateless as I can make it. And sharing one OAuth session between machines is actively unsafe rather than just untidy, because the refresh token rotates on use.
The fix was to stop sharing anything. There are eight separate, isolated agent homes on my machine, each a full git repository, each backed up to my own git server. This is the first of a series on that setup. This one covers the profiles themselves and the boundary that stops you starting the wrong one. Part two covers how the shared configuration behind them gets built and kept in sync, and the review gate that stands in for a pull request. Part three covers backup, a homelab knowledge repo, and the telemetry that made the rest of it measurable. Parts four and five are the model I use for running long multi-agent workflows and everything that still goes wrong when it works, and part six is the telemetry underneath all of it.
Eight homes, not one
There is no default Claude Code profile on my machine and no ~/.codex home either. ~/.claude exists, but it belongs to the Claude desktop apps, not to Claude Code, and it cannot hold an isolated profile. Every real profile lives in its own directory:
| Profile | Runtime home | What it is for |
|---|---|---|
| Codex Personal | /Users/rob/.codex-personal |
my own repos and homelab, full tool inventory |
| Codex Mobile | /Users/rob/.codex-mobile |
Apple and iOS development |
| Codex Security | /Users/rob/.codex-security |
security reviews, deliberately telemetry-free |
| Codex Nonproxy | /Users/rob/.codex-nonproxy |
direct OpenAI routing on its own separate login |
| Claude Personal | /Users/rob/.claude-personal |
my own repos and homelab, full tool inventory |
| Claude Mobile | /Users/rob/.claude-mobile |
Apple and iOS development |
Two more, Claude Work and Codex Work, exist for keeping a separate employer’s tooling apart from all of this. You could use exactly the same split to keep work and personal cleanly separate; I am not going to describe those two here, because they belong to somebody else’s tooling, not mine.
Mobile owns its own copies of the App Store Connect, SwiftUI, SwiftData, widgets, App Intents and accessibility skills, plus the tools that only make sense for iOS work. It does not read another profile’s skill tree at runtime, so a change to my personal skills cannot leak in. Security is the strictest: no telemetry exporter, no rollout identity, its OAuth and conversations stay local and are excluded from git entirely. Nonproxy routes straight to OpenAI on its own separate ChatGPT login.
Every one of these paths is identical on every machine I use. A hook or a rule that hard-codes /Users/rob/.claude-personal/hooks/machine-auth.py therefore runs unmodified on my Mac and on a homelab box, because the path is a fixed convention rather than something resolved from an environment variable that might not be set. Boring, but it is the reason the same rule file and the same checker run against eight homes with no branch anywhere asking which machine it is on.
What isolation actually means
Each home is a real directory, mode 0700, and a real git worktree. There is no shared rules directory, no shared skills directory, no shared cache, no shared auth, and no shared transcript history between any two of them. Claude Personal cannot read a rule written for Claude Mobile even by accident, because there is no path that would let it.
Credentials get the strictest treatment of all. Claude’s OAuth refresh tokens are machine-scoped: seeding one machine’s session from another machine’s saved credentials can invalidate both, because the token rotates on use. So the live credentials file on disk is git-ignored, and each profile’s repository instead stores a real, mode-0600 copy per hostname, restored by the launcher before it starts and snapshotted again when the session ends. Sharing a single set of credentials across machines is not a shortcut, it is a way to lock yourself out.
The launcher boundary
None of this isolation is worth anything if you can still type claude or codex and land in whichever profile happened to be active last. So neither of those bare commands does anything useful on my machine: both are shell functions that deliberately reject the launch and print the explicit choices you actually have. There is no default profile to fall into by accident.
The real entry points are generated executables in ~/.local/bin, never aliases and never symlinks:
codex-personal codex-mobile codex-security codex-nonproxy
claude-personal claude-mobile
clp / clm Claude Personal / Mobile
cop / com Codex Personal / Mobile
cdp / cdm the matching Finder launcher apps
Each one clears every inherited Codex, Claude, OpenAI, Anthropic and telemetry environment variable, sets exactly one CODEX_HOME or CLAUDE_CONFIG_DIR, restores that machine’s credential sidecar for that profile only, and then executes that profile’s own standalone binary. Nothing is inherited from whatever shell state happened to exist before you ran it, so a leftover ANTHROPIC_API_KEY from an earlier experiment cannot silently redirect a later session.
There are also Finder launcher apps that open a fresh ChatGPT desktop instance pointed at the matching CODEX_HOME, its own Electron user-data directory and its own telemetry identity. They run as background apps so the wrapper does not clutter the Dock with a second icon for something that is meant to feel like the same app.
What the profiles actually talk to
Codex Personal and Codex Mobile do not talk to OpenAI directly. They point at codex-lb, my fork of Soju06/codex-lb, running on a box at home:
[model_providers.codex-lb]
name = "OpenAI" # Codex 0.154.0 native compaction capability; routing and auth unchanged
base_url = "https://codex-lb.internal/backend-api/codex"
wire_api = "responses"
env_key = "CODEX_LB_API_KEY"
supports_websockets = true
requires_openai_auth = false
That name field is doing more than labelling the provider, and I had it backwards when I first wrote this article. It said the field had to be lowercase openai to enable remote /responses/compact. That was wrong. Codex 0.154.0 compares the provider’s display name against OpenAI case-sensitively when it decides whether a provider gets native-compaction capability, so lowercase was silently opting every proxied profile out of the thing the comment claimed it was enabling. The configuration comment asserting otherwise had been sitting there being believed for months, which is the ordinary way a wrong belief survives: nothing fails, so nothing asks.
What settled it was a round trip rather than a reading. On 2026-09-10 a /responses/compact call plus a continuation went through the proxy on gpt-5.6-sol with an existing profile key, and the opaque compaction item came back and was passed on unchanged. The name then changed from openai to OpenAI in the three proxied profiles, as the only semantic change in each file: every other value in the block above, supports_websockets included, was already there and compared equal before and after. Security and Nonproxy were left alone because neither uses the proxy. The limit on that evidence is worth stating: it proves the endpoint completes the exchange, not that a client which has already loaded the setting goes on to compact natively on its own. That part is still unobserved.
requires_openai_auth = false means the CLI authenticates with nothing but the launcher’s CODEX_LB_API_KEY, and each profile carries its own.
codex-lb pools several of my own ChatGPT accounts behind one endpoint, with per-key rate limits and usage tracking on top. My own accounts, my own box. It is a routing and telemetry layer, not a way round anybody’s rate limit.
Two profiles skip it. Security is pinned to gpt-daybreak-blue-latest, Nonproxy to gpt-6-astra so I can try OpenAI’s new enhanced compaction against it, and both sit on the built-in openai provider instead. Account routing and those pins do not mix. Security therefore holds no proxy key at all and takes the shortest path to the provider of anything on the machine, which is about right for the profile that reviews security.
Nonproxy earns a second mention because it is the only profile in the fleet eligible for the experimental context-management mode, where a context transition installs a fresh working context instead of summarising the old one. Eligible is not the same as active. The profile’s identity reliably decides eligibility, and the running model and its capabilities decide whether any particular transition actually takes that path, which is why anything written for the proxied profiles has to assume the notes and history tools that mode provides simply do not exist. Across September’s sessions that profile recorded 41 native compactions and 64 fresh-context resets, so both paths are live on the same profile in the same month.
The proxy also observes every frame of the websocket session. That capture is the most useful telemetry in the whole setup and the most awkward thing in it, which is part three’s problem.
The vendor rewrite trap
Config you do not own keeps changing underneath you, and a desktop app is exactly where that shows up. The ChatGPT desktop app once rewrote its own notify command, wrapping the existing notifier with a Computer Use helper, while Computer Use itself reported enabled = false. That spawned hundreds of helper processes and ran the CPU flat out, from a setting that was supposedly off.
So a sanitiser now runs for the lifetime of that profile’s exact desktop process, and again on every backup push, and treats a wrapped notify line as drift to be corrected. The general version of that: config you care about has to be re-asserted on a schedule, because setting it once only tells you what was true then.
Same reasoning covers file permissions. Each profile home gets its mode reasserted after every session rather than set once at creation, because a runtime will happily loosen one the moment it touches a file.
Which leaves eight copies of everything
Eight isolated homes solve the wrong-profile problem and immediately hand you a new one. Eighteen rule files, fifteen reference documents and several skill trees now exist in eight copies each, and a fix applied to one copy is a fix that has not happened to the other seven.
None of it is maintained by hand. It is rendered from one neutral source repository, with a receipt that says which commit produced what is running. That is the next part: config as build output.