Setup and command reference for this project.
Start and stop the whole stack with ./start.sh and ./stop.sh (QUICK-SETUP.md
for the short version, "Step 5" below for the mechanics). Always-on requires
power, caffeinate -dims, Remote Login, and Wake for network access. start.sh
starts caffeinate for you but cannot flip Wake for network access - enable it
once in System Settings and verify pmset -g | grep womp shows 1. With those
set, locking the screen and closing the lid survive as long as the Mac stays on power.
Two independent halves, each behind its own flag in .env. The default startup
runs T3 alone, through T3 Connect; everything else is opt-in.
CLAUDEX_ENABLED=0 # Docker, CLIProxyAPI, Codex token, Codex Web GPT
T3_ENABLED=1 # the T3 lifecycle, owned by t3-start.sh / t3-stop.sh
T3_MODE=connect # connect | custom
T3_CUSTOM_ACCESS=direct # direct | full; custom mode onlyT3_MODE has no off value - use T3_ENABLED=0. CODEX_WEB_GPT_MANAGED is
subordinate to the Claudex path and ignored while CLAUDEX_ENABLED=0.
| Configuration | T3 server | Direct access | T3-managed connection | Cloudflare tunnel | Activity publishing |
|---|---|---|---|---|---|
T3_ENABLED=0 |
off | off | off | off | off |
T3_MODE=connect |
on | managed by T3 | on | off | managed by T3 Connect |
custom + direct |
on | localhost, plus LAN/Wi-Fi and VPN with T3_BIND=all |
off | off | optional |
custom + full |
on | localhost, plus LAN/Wi-Fi and VPN with T3_BIND=all |
off | on | optional |
connect. T3 Connect owns remote access. t3-start.sh reads
t3 connect status, signs in and links this environment when either is missing,
replaces a link it can explicitly identify as publish-only with a full Connect
link, starts the server detached, and then reads this boot's link reconcile
result out of the log. You reach the machine by signing in at app.t3.codes or
in the T3 Code iOS app - there is no pairing code. None of the custom networking
configuration is read, validated, or exported in this mode: no hostname, port,
bind, tunnel name, Access email, or Cloudflare team, no cloudflared, and no
CodeLaunch pairing QR codes. T3_PUBLISH_ACTIVITY is ignored too, since the full
Connect link already publishes agent activity.
Sign-in and linking prompt for an out-of-band authorization code, so the first
run needs a TTY. Over SSH that is fine - the CLI prints a URL to open on any
device. Without a TTY, t3-start.sh prints the two commands to run by hand.
custom/direct. The server runs with no managed T3 link and no tunnel.
T3_PORT and T3_BIND still apply, and the pairing token points at this machine
rather than at a tunnel hostname. Cloudflare-only variables are ignored.
T3_BIND=all is not VPN-only exposure. It binds 0.0.0.0, so anything that
can route to this machine can reach the port - on a home or office network that
means every other device on the same Wi-Fi. The pairing code is the only gate.
t3-start.sh warns about this on every start. With the default
T3_BIND=loopback, direct mode only serves this machine.
custom/full. The previous CodeLaunch setup, unchanged: the same server plus
the Cloudflare Access/Tunnel lifecycle in Step 4.
Because the server is bound for direct access, it stays reachable over LAN/Wi-Fi
or a VPN whenever T3_BIND=all and those routes exist.
Steps 1 and 2 below apply only with CLAUDEX_ENABLED=1; Step 4 applies only to
custom/full; Step 6 applies only with CLAUDEX_ENABLED=1.
Claudex path only - skip this step entirely with CLAUDEX_ENABLED=0.
Prereq: Docker Desktop running (docker version, docker compose version).
umask 077
cp cliproxy/example.config.yaml cliproxy/config.yaml
mkdir -p cliproxy/auth
chmod 600 cliproxy/config.yaml
chmod 700 cliproxy/auth
openssl rand -hex 32 # generate a key, paste into config.yaml api-keysSet the KEY var to your shell:
KEY=$(grep -oE '[0-9a-f]{64}' cliproxy/config.yaml | head -1)Codex OAuth login (opens a URL for the host browser, callback -> 127.0.0.1:1455):
./cliproxy/login.shStart / status / stop:
./cliproxy/start.sh # up + waits for 127.0.0.1:8317
./cliproxy/stop.shVerify:
lsof -nP -iTCP:8317 -sTCP:LISTEN # expect 127.0.0.1 only, never 0.0.0.0
curl -s -H "Authorization: Bearer <KEY>" http://127.0.0.1:8317/v1/models | head
docker compose -f cliproxy/docker-compose.yml logs --tail=30The image is pinned by digest in docker-compose.yml rather than by latest or a version tag, since tags are mutable upstream and a silent pull could change routing behavior. Check the running build with docker exec cliproxyapi ./CLIProxyAPI --version. To move, pull the new image, re-verify /v1/models and effort, then update the digest and TOOL-VERSIONS.md together.
Models are exposed under two sets of aliases (oauth-model-alias), and the raw upstream ids no longer route:
- Friendly names -
GPT-5.6 Sol,GPT-5.6 Luna,GPT 5.5,GPT 5.4 mini. Required by theANTHROPIC_DEFAULT_*_MODELrole mappings inclaudex, and the only labels that clearly show which model is actually serving you. - Claude slugs -
claude-fable-5,claude-opus-5,claude-sonnet-5,claude-haiku-4-5and friends, each pointing at the same upstream model as its friendly counterpart. These exist only so T3 will pass reasoning effort. See Step 3.
One upstream id can carry many aliases and they all coexist - /v1/models lists all 18. Use the friendly names from the CLI and the Claude slugs from T3.
Export the key for later steps (Claude Code / T3):
export CLIPROXY_LOCAL_API_KEY=<KEY> # keep out of the repoNotes:
-
config.yamlandcliproxy/auth/are gitignored (key + OAuth creds). Never commit them. Keep.env,config.yaml, and auth files mode600, and auth directories mode700;start.sh,cliproxy/start.sh, andcliproxy/login.shenforce this on future runs. The alias/effort setup itself is in git -example.config.yamlis identical toconfig.yamlapart from the placeholder key and a leading comment, so a lostconfig.yamlis onecpaway from working. The local API key is regenerable in seconds (openssl rand -hex 32). Only the Codex OAuth credentials underauth/need a re-login to replace. Keepexample.config.yamlin sync wheneverconfig.yamlchanges. -
Aliases replace the upstream id, but many aliases can share one id. After aliasing, the raw id returns
unknown provider. Unaliased models keep their own ids. Keepoauth-model-aliasin sync withANTHROPIC_DEFAULT_*_MODELin../claudex, or role lookups fail with a502 unknown providerthat Claude Code retries as if it were transient.customModelsin~/.t3/userdata/settings.jsondoes not need to match - T3 lists built-in Claude models regardless, and custom entries there are actively harmful (Step 3). -
config.yamledits need a container restart. The file is a read-only bind mount (./config.yaml:/CLIProxyAPI/config.yaml:ro), and the process reads it at startup. Run./cliproxy/stop.shthen./cliproxy/start.sh(docker compose down, thenup -dplus the listener poll).start.shon its own does not recreate an already-running container, so the old alias table stays live. -
An alias differing only by case is ignored, with no warning.
gpt-5.6-luna->GPT-5.6-Lunais a no-op with nothing logged and the original left in place.GPT-5.6 Lunaworks. Every alias must differ by a real character, which is whyGPT 5.5carries a space. -
payload.defaulteffort is a no-op here. It fillsreasoning.effortonly when the client omits it, but the/v1/messages-> codex translation always derives a level first. Withdebug: trueon, a request with no thinking field arrives aslevel=medium, and Luna logsmediumdespite itshighbaseline. Effort is controlled by the client (/effort, per-agent frontmatter), not the proxy. Do not switch tooverride- it force-replaces client values and would break per-agent effort. -
To inspect applied effort, uncomment
debug/logging-to-file/request-loginconfig.yamland restart. Each call writescliproxy/auth/logs/v1-messages-*.logcontaining both the incoming Claude request and the translated upstream codex body:for g in $(ls -t cliproxy/auth/logs/v1-messages-*.log | grep -v count_tokens | head -3); do echo "== $(basename $g)" grep -oE '"model":"[^"]*"' "$g" | head -2 | tr '\n' ' '; echo grep -oE '"reasoning":\{"effort":"[a-z]*"' "$g" | head -1 done
First model is what the client asked for, second is what actually ran. Turn logging back off when done - these files hold full request bodies, including whole conversations. Background calls Claude Code makes on its own (session titles) send no thinking field and land at
medium. That's normal and not a sign effort failed. -
Re-auth: rerun
./cliproxy/login.sh, then./cliproxy/start.sh.
Claudex path only - skip this step entirely with CLAUDEX_ENABLED=0.
Profile lives in ~/.claudex (isolated via CLAUDE_CONFIG_DIR): CLAUDE.md
(Luna-primary, imports shared ~/.claude/CLAUDE.md), settings.json (model
opus, native allowlist, no effortLevel), skills symlink, and agents/
(gpt-5-6-sol -> fable, gpt-5-6-luna -> opus, gpt-5-5 -> sonnet,
gpt-5-4-mini -> haiku). Launcher: ./claudex (repo root) sets the proxy env
and runs Claude Code in the current dir.
Add the launcher to your shell once. Example for ~/.zshrc (pick one):
# Option A: alias
alias claudex="$HOME/GitHub/CodeLaunch/claudex"
# Option B: put the repo dir on PATH
export PATH="$HOME/GitHub/CodeLaunch:$PATH"
# Option C: symlink into a dir already on PATH (no zshrc edit)
# ln -sfn "$HOME/GitHub/CodeLaunch/claudex" ~/.local/bin/claudexReload with source ~/.zshrc. Then, with the proxy up (cliproxy/start.sh),
from any project directory:
claudex # Luna (opus) primary
claudex --model fable # Sol
claudex --model haiku --effort low # 5.4 mini, low effortIn the session, verify:
/status-CLAUDE_CONFIG_DIRis~/.claudex, base URLhttp://127.0.0.1:8317, model resolves viaopus(Luna)./model- four friendly names appear (GPT-5.6 Sol / Luna, GPT-5.5, GPT-5.4 mini)./agents-gpt-5-6-sol,gpt-5-6-luna,gpt-5-5,gpt-5-4-minidiscovered./effort- levels are selectable and do apply.ANTHROPIC_DEFAULT_*_SUPPORTED_CAPABILITIESin the wrapper is what tells Claude Code these custom model names accept effort. Without it the flag is ignored.claudex --model haiku --effort lowarrives upstream asreasoning.effort: low, and--effort maxarrives asxhigh. There is no proxy-side backstop -payload.defaultis a no-op.
Prove routing from the proxy side while testing:
docker compose -f cliproxy/docker-compose.yml logs -fDelegate once to each agent and confirm the child requests resolve to Luna / GPT-5.5 / GPT-5.4 mini in the logs (not just the agent's self-report).
Install the desktop app and confirm the CLI entrypoint:
brew install --cask t3-code # stable; nightly channel: t3-code@nightly
npx --yes t3@$T3_CHANNEL --version # channel must match the cask you installedThe app keeps the old T3 Code (Alpha) name internally as
legacyUserDataDirName, so it still shows up as a userdata directory under that
name. The cask has auto_updates on, so re-check the version and backend port
after each update.
T3_CHANNEL must match the installed desktop app - this is enforced, not
advisory. The CLI and the desktop backend share the ~/.t3 store and the CLI
runs schema migrations against it, so a mismatch can migrate the store to a
schema the other side cannot read.
t3-pair.sh reads T3_CHANNEL from .env (latest | nightly, default
latest), uses it for every npx t3@<channel> call, and refuses to run on a
mismatch:
REFUSING: T3_CHANNEL=latest but the desktop app is 'nightly'.
app: /Applications/T3 Code (Nightly).app
Fix: set T3_CHANNEL=nightly in .env, or install the latest cask.
The channel is read from the app's CFBundleShortVersionString
(0.0.29-nightly.* -> nightly), not its bundle name, so a renamed .app is
still classified correctly. Detection prefers the app currently listening on
T3_PORT - the backend the CLI will actually migrate against - and falls back
to scanning /Applications and ~/Applications when nothing is up. With no
desktop app installed there is nothing to match, and T3_CHANNEL simply selects
the headless backend's channel. Escape hatch, which warns loudly:
T3_CHANNEL_SKIP_CHECK=1.
start.sh runs the same guard as ./t3-pair.sh --check-only in its prereq step
- before Docker, the proxy, or the tunnel start - so a mismatch fails in a
second rather than after a full bring-up.
--check-onlystarts no backend and mints no token.
stop.sh does not check the channel. Teardown has to work even when the config
is wrong, and since it never invokes the CLI there are no migrations to guard.
It reads the desktop app's bundle name back out of the running process and is
correct regardless of T3_CHANNEL.
npm carries both dist-tags (npm view t3 dist-tags): latest is 0.0.28 and
nightly is 0.0.29-nightly.*. .env.example ships latest because a fresh
setup should use the stable channel. Set T3_CHANNEL=nightly in .env if
you're running the nightly cask (t3-code@nightly).
Configure two providers in T3 Desktop.
The example settings enable the native Codex provider with
providerInstances.codex.enabled=true, config.enabled=true, and
binaryPath: "codex". Choose one Codex mode at a time:
-
For normal/native Codex models through T3 Code and CodeLaunch, the ChatGPT desktop app must be quit. T3 uses the Codex app server and must own the
codexprocesses; the ChatGPT app conflicts with that ownership. CodeLaunch checks this duringstart.sh- in every mode, since T3 needs the app-server whether or not Claudex is running - and warns without quitting the app. Quit it gracefully with:osascript -e 'tell application "ChatGPT" to quit'A process diagnostic is:
ps -ax -o pid,ppid,command | grep -E 'ChatGPT.app./codex|codex-code-mode-host'
-
CODEX_WEB_GPT_MANAGED=1selects the Web GPT integration instead. The service may be started headlessly by CodeLaunch, but it requires the ChatGPT desktop app and is not usable as a T3 Code/CodeLaunch provider: Web GPT models cannot make tool calls there. KeepCODEX_WEB_GPT_MANAGED=0for the native T3 Codex provider.
Claude Native - leave as installed. T3 resolves the bare claude name on
its own.
Claudex - point at the wrapper, and leave every other field empty:
Display name: Claudex
Binary path: /Users/<username>/.local/bin/claudex
Claude HOME path: empty
Environment variables: none
Two things this gets right, both of which fail with no error message otherwise:
- The path must be absolute. T3 spawns the binary directly, with no shell,
so
~is never expanded and~/.local/bin/claudexfails withspawn ... ENOENT. Bareclaudeworks only because T3 has its own lookup for the default name. A custom name gets no such treatment. - Claude HOME must stay empty. The field is inert either way - the wrapper's
exec env CLAUDE_CONFIG_DIR=...overrides whatever T3 puts in the child environment - so filling it in can only mislead. On0.0.29-nightly,makeClaudeEnvironmentreturns the base environment untouched when the field is blank and otherwise setsCLAUDE_CONFIG_DIRto the resolved path. It never setsHOME, despite the app's description ("Custom HOME used when running this Claude instance"). On 0.0.28 the field setHOMEinstead, which would have made the wrapper computeCLAUDE_CONFIG_DIR="$HOME/.claudex"=~/.claudex/.claudex. Re-check after a cask update in case it changes again.
No environment variables go in T3. claudex supplies the base URL, key, and
model mappings, so the proxy key never enters T3's secret store and the mapping
has one source of truth. T3 passes the picked model as a --model launch
argument, which overrides the wrapper's ANTHROPIC_MODEL=opus, so the model
picker still works.
T3's environment fields do not just go unused here - they are overridden.
The wrapper ends in exec env -u ANTHROPIC_API_KEY VAR=... "$CLAUDE_BIN" "$@".
Those are literal assignments on the env command line, so they win over
anything T3 exports into the child. Setting ANTHROPIC_BASE_URL or any
ANTHROPIC_DEFAULT_* in T3 has no effect at all, and nothing logs to say so.
The wrapper is the only place those values can be changed. ANTHROPIC_API_KEY
is explicitly removed with env -u, so an inherited key cannot leak in either.
Three variables are read from the environment on purpose, before the exec:
CLIPROXY_LOCAL_API_KEY- overrides the key scraped fromconfig.yaml.CLAUDE_BIN- overrides whichclaudebinary is launched.HOME- the wrapper computesCLAUDE_CONFIG_DIR="$HOME/.claudex"from it, so anything that movesHOMEmoves the whole profile. The current build's Claude HOME path field does not touchHOME, but leave it empty per the rule above.
Provider config is stored at ~/.t3/userdata/settings.json. T3 rewrites that
file on quit, so edit it only while the app is closed - otherwise use the GUI.
.t3/ in this repo holds working copies of both files T3 reads. Quit T3
completely first. client-settings.json works differently from the settings file
above: there is no quit-time flush, it is read once at startup and rewritten
atomically on the next settings change. A copy made while the app is running is
therefore ignored until a restart, then clobbered by the next favorite toggle or
word-wrap change.
From the repo root:
mkdir -p ~/.t3/userdata
cp .t3/example.settings.json ~/.t3/userdata/settings.json
cp .t3/example.client-settings.json ~/.t3/userdata/client-settings.jsonThis overwrites the existing T3 settings, including every other configured provider and all favorites. Back both files up first if there is a T3 setup worth keeping.
Then fix binaryPath in ~/.t3/userdata/settings.json. The template ships the
literal placeholder /Users/<username>/.local/bin/claudex. Left as-is, T3 fails
with spawn ... ENOENT for the absolute-path reason above. Replace it with the
real path.
The template also enables the stock codex provider with binaryPath: "codex",
which is what Step 6 needs. It costs nothing
if you never turn Codex Web GPT on - the provider just lists the native Codex
models.
example.client-settings.json carries picker state rather than provider config:
favorites for claudeAgent and claudex on claude-fable-5 and
claude-opus-5, and one native codex favorite on gpt-5.6-sol, plus
providerModelPreferences.claudex.hiddenModels, which hides claude-opus-4-8,
claude-opus-4-7, claude-opus-4-6, claude-opus-4-5 and claude-sonnet-4-6
from the claudex picker.
The Opus 5 row needs Claude Code >= 2.1.219; below that T3 filters it out of the picker entirely and shows an upgrade message instead.
claude-sonnet-5 stays visible: it is the Sonnet route to GPT-5.5, and it is
also DEFAULT_MODEL_BY_PROVIDER[claudeAgent], so a fresh claudex thread lands on
it. config.yaml aliases both claude-sonnet-5 and claude-sonnet-5[1m] - the
latter is required because that row carries a contextWindow selector (200k
default / 1m) and picking 1M makes T3 send the id with [1m] appended. Without
the second alias, that selection returns 502 unknown provider, which Claude Code
retries as if it were transient.
claude-opus-5 is the Opus row to use, and the other four are hidden. It is the
only Opus slug that is both exempt from the xhigh -> max rewrite below and
carries a contextWindow selector (200k/1m, defaulting to 1M), so it
strictly supersedes them:
claude-opus-4-8matches it on effort but has nocontextWindowselector, so it is capped at 200k. It was the favorite before Opus 5 shipped; now it is a strictly worse duplicate of the same upstream model, which is exactly the kind of near-identical second row that leads to picking the weaker one by accident.claude-opus-4-7andclaude-opus-4-5are plain duplicate aliases of Luna, so hiding them only removes clutter.claude-opus-4-6was previously the only 1M-context route to Luna in T3 and hiding it cost that. Opus 5 defaults to 1M, so the tradeoff is gone.
Because that default is 1M, the first message of any Opus 5 thread sends
claude-opus-5[1m], not claude-opus-5. Both are aliased in config.yaml;
dropping the [1m] one breaks the row on message one with 502 unknown provider.
claude-sonnet-4-6 is the last hidden row. It is still aliased to GPT-5.5 and
works, but claude-sonnet-5 supersedes it: 4.6 is the one slug where
normalizeClaudeCliEffort rewrites max -> high, so its top effort level is
unreachable with no warning. Two identical Sonnet rows with different effort
ceilings is a trap, so the weaker one is hidden.
Drop any of these from hiddenModels to make them pickable again.
T3 drops effort for custom models with no warning. Its claudeAgent driver only
passes effort to the Agent SDK for built-in Claude model slugs. Custom models
resolve to DEFAULT_CLAUDE_MODEL_CAPABILITIES, an empty descriptor list, so
resolveClaudeEffort returns undefined and the reasoning selection never leaves
T3. No proxy setting can recover it. This is why a thread on GPT-5.6 Luna
always ran at the default no matter what the reasoning control said.
The fix is the second alias block in config.yaml: the upstream models are also
aliased to built-in Claude slugs, so selecting one makes T3 attach effort.
- In the model picker choose the Claude-named entries, not the
GPT ...ones.Claude Opus 5-> Luna,Claude Fable 5-> Sol,Claude Sonnet 5-> GPT-5.5,Claude Haiku 4.5-> 5.4 mini. - Empty
customModelsfor the claudex instance. TheGPT ...entries are the only way to pick a model that ignores effort, and they look identical to the working ones in the UI. Removing them from T3 costs nothing -claudex --model 'GPT-5.6 Sol'still works from a terminal. - T3 remaps effort before sending it. The selector offers
low,medium,high,xhigh,max,ultracodeandultrathink, butnormalizeClaudeCliEffortrewrites the selection on the way out (both the CLI and SDK paths, viagetEffectiveClaudeAgentEffort):ultrathinksends nothing at all,ultracode->xhigh, andxhigh->maxfor every model exceptclaude-fable-5,claude-opus-5,claude-opus-4-8andclaude-sonnet-5.low,mediumandhighpass through unchanged, andclaude-sonnet-5additionally passes bothxhighandmaxthrough untouched - it is exempt from thexhigh->maxrewrite and from themax->highrewrite. maxdowngrades onclaude-sonnet-4-6only.max->highthere, so picking "Max" on that row actually sendshigh.xhighmaps up tomax. That row is hidden by default for this reason.- Do not pick "Max" on the Sonnet 5 row - it fails. Upstream
gpt-5.5supportsnone, low, medium, high, xhighand has nomax. Onclaude-sonnet-4-6T3'smax->highrewrite accidentally hid that.claude-sonnet-5has no such rewrite, so selecting "Max" sends a literalmaxto a model that does not advertise it. The request errors rather than falling back to a default (thepayload.defaulteffort only fills an omitted level, and "Max" sends one explicitly, so there is no backstop).xhighis the top usable level on the Sonnet 5 row. T3 gives no way to removemaxfrom the picker - the effort list is baked into the built-in slug's descriptor andhiddenModelsonly hides whole rows - so this is a discipline constraint, not something the config can enforce. - Haiku 4.5 exposes only a thinking toggle in T3, so 5.4 mini gets no effort control through the app. Use the CLI for that.
- Fast Mode on the Opus entries is untested against the proxy. Leave it off.
The model will lie about its identity under these slugs. Claude Code's
system prompt asserts it is Claude and the model id now agrees, so asking
"what model are you?" returns a confident wrong answer - a thread on
claude-opus-5 is really GPT-5.6 Luna. Anything keyed on model identity
(the claude-api skill, model-conditional agent frontmatter) will misfire
toward Claude behaviour. The proxy logs are the only ground truth. This is the
unavoidable cost of getting effort through T3. The CLI keeps honest labels.
Verify a T3 thread end to end by enabling request logging (Step 1 notes), then
sending one message at low reasoning. Expect the incoming model to be the Claude
slug, the upstream model to be the GPT id, and reasoning.effort to be low.
Verify, with the proxy up:
- Native thread: Fable/Opus/Sonnet/Haiku keep their normal Claude mappings.
- Claudex thread:
/statusshows~/.claudexand base URL127.0.0.1:8317, and/agentslists the four GPT-named agents. - Claudex thread on a Claude-named model with reasoning set to low: the request
log shows
reasoning.effort: lowagainst the GPT upstream id. - Confirm routing in
docker compose -f cliproxy/docker-compose.yml logs -f, not the client's self-report.
Confirm the backend port and that it is loopback-only:
lsof -nP -iTCP -sTCP:LISTEN | grep -i t3Expect 127.0.0.1:3773, matching the documented default, with Desktop
Network access off. Re-check after a cask auto-update. If this ever shows
0.0.0.0, stop and turn Network access back off before running the tunnel.
Headless fallback, if Desktop's managed backend does not work out:
npx --yes t3@$T3_CHANNEL serve --host 127.0.0.1Run only one backend at a time. Pairing tokens and sessions are managed with
t3 auth pairing and t3 auth session. Treat a pairing URL as a password.
T3_MODE=custom with T3_CUSTOM_ACCESS=full only. Connect mode delegates remote
access to T3 Connect, and custom/direct serves the machine without a tunnel;
neither reads any of the configuration below. Sections 4D and 4E also apply to
custom/direct, with the pairing URL pointing at this machine instead of the
tunnel hostname.
The Access app + policy, tunnel, and proxied CNAME can all be created through the
Cloudflare REST API with a scoped token instead of cloudflared tunnel login.
That means no ~/.cloudflared/cert.pem needs to exist - the account-wide,
~10-year "manage all tunnels" credential is never written. The tunnel is
locally-managed (config_src: local), so cloudflared tunnel run works from
config.yml and the <UUID>.json credentials file alone. Unauthenticated
requests get a 302 to the Access login with the app's aud. Real IDs and
secrets live only on the host and in Cloudflare, never in git.
The API path, for reference (token was a scoped, short-lived custom token with
Access: Apps and Policies edit, Access: Organizations, IdPs, and Groups edit,
Cloudflare Tunnel edit, and zone DNS edit):
. ./scripts/env.sh
codelaunch_load_env T3_HOSTNAME T3_PORT TUNNEL_NAME ACCESS_EMAIL CLOUDFLARE_TEAM
CF=$(tr -d '[:space:]' < ~/.ssh/CloudFlare_API_KEY/cloudflare-tunnel-api.key)
H="Authorization: Bearer $CF"
# account_id from the zone, plus an org + IdP sanity check
curl -s "https://api.cloudflare.com/client/v4/zones?name=$(echo $T3_HOSTNAME | cut -d. -f2-)" -H "$H"
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT/access/organizations" -H "$H"
# Access first: reusable policy, then app (auto_redirect to the one IdP)
curl -s -X POST ".../accounts/$ACCT/access/policies" -H "$H" \
--data '{"name":"...","decision":"allow","include":[{"email":{"email":"'$ACCESS_EMAIL'"}}],"session_duration":"1h"}'
curl -s -X POST ".../accounts/$ACCT/access/apps" -H "$H" \
--data '{"name":"T3 Code","type":"self_hosted","domain":"'$T3_HOSTNAME'","session_duration":"1h","auto_redirect_to_identity":true,"allowed_idps":["<idp>"],"policies":[{"id":"<policy>","precedence":1}]}'
# Tunnel: generate secret, create local tunnel, hand-write credentials JSON
SECRET=$(openssl rand -base64 32)
curl -s -X POST ".../accounts/$ACCT/cfd_tunnel" -H "$H" \
--data '{"name":"'$TUNNEL_NAME'","config_src":"local","tunnel_secret":"'$SECRET'"}'
# -> ~/.cloudflared/<UUID>.json = {"AccountTag","TunnelSecret":SECRET,"TunnelID"}
# DNS last (equivalent to `route dns`, no cert.pem needed)
curl -s -X POST ".../zones/$ZONE/dns_records" -H "$H" \
--data '{"type":"CNAME","name":"code","content":"<UUID>.cfargotunnel.com","proxied":true,"ttl":1}'Real values live in .env (gitignored), not in this file:
umask 077
cp .env.example .env # then fill in, and load for the commands below
chmod 600 .envOrder is load-bearing: Access first, DNS and tunnel second. Creating the DNS route before the Access policy exists publishes an unauthenticated agent backend for however long the gap lasts. New hostnames show up in Certificate Transparency logs within minutes, so that window is real. Do not reorder for convenience.
Everything below assumes these hold. Each one fails late and unhelpfully if it does not.
Domain is an active zone in Cloudflare. The registrar's nameservers have to be delegated to Cloudflare and the change has to have propagated. Check before anything else:
dig +short NS <your-domain> @1.1.1.1Query a public resolver explicitly - a local or ISP resolver can still be serving
a cached pre-delegation answer. Expect Cloudflare nameservers
(*.ns.cloudflare.com). If the registrar's own nameservers come back, delegation
has not landed yet and cloudflared tunnel login will likely not offer the zone
at all. Expect an absence from the zone list rather than an outright error.
A Cloudflare Zero Trust organization exists. One-time setup in the
dashboard, producing a team domain <team>.cloudflareaccess.com. The Access
application in 4A cannot be created without it. The free tier covers this
setup.
cloudflared is on PATH.
cloudflared --versioncloudflared tunnel login is the auth boundary. It opens a browser, asks
which zone to authorize, and writes ~/.cloudflared/cert.pem - the certificate
every later tunnel create/route dns call uses. Pick the zone verified above.
The command itself is run in 4B.
Export the .env values into the current shell. Step 4 refers to
$T3_HOSTNAME, $T3_PORT, $TUNNEL_NAME and $ACCESS_EMAIL throughout, but
cp .env.example .env only creates the file - it exports nothing, so those
commands would run with empty strings substituted in. Load it explicitly, from
the repo root, in every shell that runs a Step 4 command:
. ./scripts/env.sh
codelaunch_load_env T3_HOSTNAME T3_PORT TUNNEL_NAME ACCESS_EMAIL CLOUDFLARE_TEAMT3 must already be serving on $T3_PORT. cloudflared tunnel run connects
to the edge whether or not anything is listening at the origin, so a missing
backend is not a tunnel failure. Access sits in front, so the failure only shows
up after authenticating: expect Cloudflare error 1033 when no tunnel is running
or the hostname is misrouted, and a 502 only once the tunnel is up but the origin
port is dead. Start Desktop (or the headless backend) first and confirm with
lsof as in Step 3. The headless entrypoint is
npx --yes t3@$T3_CHANNEL serve --host 127.0.0.1. Test that command on its own before
relying on it.
In Cloudflare Zero Trust: configure an IdP with MFA, then create a self-hosted
Access application for $T3_HOSTNAME with exactly one policy:
Action: Allow
Include: Emails -> $ACCESS_EMAIL
Session duration: 1 hour
Independent MFA: required, 1 hour
No Include: Everyone. No Bypass policy. No service tokens. Email OTP alone
allows any valid address - if used, still pin the policy to the exact email.
Test with the intended email and a different email before continuing.
Turn on 2FA for the Cloudflare account. With the built-in Cloudflare
identity provider as the org's only IdP, account 2FA is the MFA layer - there is
no separate "Independent MFA" toggle doing it for you. The AMR-based MFA policy
rule only supports Okta/Entra/OIDC/SAML, not the Cloudflare IdP, Google, or OTP,
and free-tier Independent MFA availability is unconfirmed. The app pins
allowed_idps to that one IdP with auto_redirect_to_identity: true, so there
is no IdP chooser to leak other login methods. From off-network/VPN, a
Google-account login is refused since the policy pins the exact email, and only
the pinned Cloudflare account with 2FA passes.
The Cloudflare-IdP consent screen ("... wants to connect to Cloudflare") took its
label from the IdP name, which was empty and showed as "Unknown app". Setting
the IdP name to T3 Code (PUT .../access/identity_providers/<id>, preserving
type: cloudflare and config.restrict_to_account_members: true) is the lever.
The Access app name and org name do not drive it. Undocumented, so re-verify
after Cloudflare changes.
cloudflared tunnel login
cloudflared tunnel create "$TUNNEL_NAME"
cloudflared tunnel list # note the UUIDWrite ~/.cloudflared/config.yml (outside the repo, credentials JSON stays
local and is never committed):
tunnel: <TUNNEL_UUID>
credentials-file: /Users/<username>/.cloudflared/<TUNNEL_UUID>.json
ingress:
- hostname: code.example.com # $T3_HOSTNAME
service: http://127.0.0.1:3773 # $T3_PORT
- service: http_status:404The catch-all http_status:404 is required - without it the tunnel refuses to
start, and it stops any other hostname from reaching the origin.
cloudflared tunnel ingress validate
cloudflared tunnel ingress rule "https://$T3_HOSTNAME"
cloudflared tunnel route dns "$TUNNEL_NAME" "$T3_HOSTNAME"Confirm the CNAME is proxied and points at <TUNNEL_UUID>.cfargotunnel.com.
Start the proxy and T3 first, then the tunnel:
cloudflared tunnel run "$TUNNEL_NAME"Do not run brew services start cloudflared. That starts it at login, which
conflicts with the on-demand orchestration this setup uses and would leave
ingress up whenever the Mac is awake.
Verify in this order, from a private window:
https://$T3_HOSTNAMEis blocked by Access until the exact email plus MFA.- A different email is refused.
- After Access passes, a browser with no T3 device session still cannot use the backend. This is the layer that matters if Access is ever misconfigured.
- A live agent response streams end to end (WebSocket through the tunnel).
lsof -nP -iTCP -sTCP:LISTENshows T3 on loopback only. With the defaultT3_BIND=loopbacka0.0.0.0listener means something is wrong; underT3_BIND=allthe wildcard bind is expected andt3-pair.shasserts it (see 4E).
Revoke the test session afterward with t3 auth session.
Passing Cloudflare Access is not enough: T3 requires its own one-time pairing
credential before a browser can drive the backend, so a remote browser lands on
"Pair with this environment". This is a second, independent layer - even a
misconfigured Access policy leaves the backend unusable without a T3 session.
Leave Desktop's Network access on "Limited to this machine." The tunnel never
needs it (it connects over loopback), and CodeLaunch cannot guard that toggle -
it belongs to the app, not to the backend the scripts manage. When you do want
LAN or VPN reach, use T3_BIND=all (4E) instead: it applies to the headless
backend, and t3-pair.sh verifies the resulting bind on every run.
The desktop .app is only a GUI - the backend is the same t3 server it spawns,
runnable headless with t3 serve. Pairing tokens are issued by the CLI against
the shared ~/.t3 auth store (default T3CODE_HOME), so they validate whichever
single backend is listening. ./t3-pair.sh reuses or starts that backend, verifies
its bind matches T3_BIND, and presents the code, expiration, and numbered URLs.
The first URL is Tunnel. With T3_BIND=all, active VPN and Wi-Fi interfaces
add direct URLs; inactive interfaces are omitted. jq is required for the
structured output; install it with brew install jq. qrencode is optional
(brew install qrencode). Newly started headless backends write to the private
$HOME/.codelaunch/run/t3-serve.log file; an already-running process keeps its
current log until it is restarted.
./t3-pair.sh # 15m token; interactive pairing helper
./t3-pair.sh 5m # custom TTL
./t3-pair.sh --detached # print code and all URLs, then exit
./t3-pair.sh -d 5m # TTL and detached mode may appear in either orderIn normal mode the helper stays open. Press c, enter a URL number, and it
prints the code and renders that URL's QR; no QR is rendered before c. Press
q to exit only the helper; services remain running. Invalid input redraws the
menu without minting another token. -d/--detached prints the code and all
URLs without rendering a QR or reading input. Non-TTY execution automatically
uses detached behavior. The one-time code and URLs are passwords.
The helper and QR use the full explicit http://<address>:<port>/pair#token=...
URL. For manual Add Environment, enter only the origin shown at the front of
that URL, such as http://10.0.0.7:3773, plus the printed code. Direct
connections bypass Cloudflare Access, so use them only on a trusted network.
t3 auth pairing list|revoke and t3 auth session list|revoke manage outstanding
tokens and sessions. The headless-start path has not been tested while the
desktop app owns the port, so test it with the app closed.
Custom mode only, in both access modes. The mobile app cannot complete
Cloudflare Access, so under custom/full it must connect directly over a trusted
LAN or VPN; under custom/direct that is the only way in. (In connect mode the app
signs in to your T3 account instead and none of this applies.) In .env:
T3_BIND=all
T3_CHANNEL=latestT3_BIND=all starts the backend on 0.0.0.0. The Cloudflare tunnel still uses
loopback. The pairing helper adds direct URLs only for active VPN (utun*) and
Wi-Fi interfaces; Ethernet, inactive interfaces, and link-local addresses are
not presented - but the bind itself is wider than what is printed.
In the app, choose Add Environment and enter only the origin at the front
of the printed full pair URL, with its explicit http:// scheme; for example,
enter http://10.0.0.7:3773 plus the printed code, not /pair#token=.... Do not
omit the scheme: a bare host may be rewritten to https://.
Direct connections bypass Cloudflare Access. This is not VPN-only exposure:
0.0.0.0 is reachable from anything that can route to this machine, which on a
home or office network means every other device on the same Wi-Fi, whether or
not its address was printed. The pairing code is the only gate, so enable this
only on a network you trust. t3-start.sh warns about it on every start, and
t3-pair.sh verifies the live bind matches T3_BIND and asks you to restart a
backend started with the other mode.
Custom mode only, optional, off by default, and independent of everything above. T3 Connect can send push notifications and Live Activities to your mobile clients without a managed tunnel - the mode the app describes as "Works without a T3 Connect tunnel." That is the right mode when the transport is already yours, which is why it applies to custom/direct and custom/full alike.
With T3_MODE=connect, T3_PUBLISH_ACTIVITY is ignored and this whole section
is moot: the full Connect link already publishes agent activity. t3-publish.sh
says so rather than creating a second, publish-only link, and t3-start.sh
replaces a publish-only link with a full one if it finds this machine on the
wrong side of that switch. Only --disable still applies in connect mode.
Understand the trade first. Push does not travel over your tunnel. This machine
POSTs to relay.t3.codes, which fans out to APNs. On every meaningful thread event it
sends the project title, thread title, phase, a short headline, up to 160 characters of
detail, the model name, and environment/thread IDs, tied to your T3 cloud account. Link
provisioning also registers this machine's local endpoint (0.0.0.0:$T3_PORT) with the
relay as providerKind: "manual". No code or diffs are sent. It is metadata, it is
continuous, and it is the one part of this stack that leaves infrastructure you control.
./t3-publish.shIt prints the same summary, asks for confirmation, then runs connect login --headless
and connect link --publish-only --headless. Headless is deliberate: it prints a URL to
open on any device and reads back a code, so this works over SSH with no browser on the
Mac. --publish-only skips the managed-tunnel path entirely - the cloudflared
install/confirm block is gated behind it - so it will not touch or compete with your
$TUNNEL_NAME tunnel.
Then set T3_PUBLISH_ACTIVITY=1 in .env and restart T3. The link is only
provisioned when the backend starts, and a live backend is reused, so enabling
this against a running stack does nothing until ./t3-stop.sh && ./t3-start.sh.
Fan-out is account-scoped, not pairing-scoped: the relay resolves the environment to the
cloud accounts linked to it and pushes to those accounts' registered devices. The phone
does not re-pair - it needs to be signed into the same T3 account, which registers its
APNs token automatically. Notification deep links are relative (/threads/<env>/<thread>),
so no tunnel hostname is embedded and taps resolve against the environment the app
already knows.
Setup state is not .env config. It is a set of mode-0600 files under
~/.t3/userdata/secrets (cloud-publish-agent-activity.bin, cloud-cli-oauth-token.bin,
cloud-relay-*.bin, cloud-linked-user-id.bin, cloud-cli-desired-link.bin). They
survive stop.sh, stop.sh --all, and reboots, so there is nothing to re-run per boot.
Only ./t3-publish.sh --disable (stops publishing, keeps the sign-in), connect unlink,
or connect logout undo it.
Why t3-start.sh checks this twice. connect status only reads those local files - it
never contacts the relay - so Authorization: stored credential means a token file
exists, not that it still refreshes. Meanwhile the backend re-mints the environment
credential from the relay on every start. If that fails - revoked grant, logout from
another machine, relay unreachable - the previously persisted secrets stay on disk and
connect status keeps reporting provisioned while nothing is being delivered. So
--check-only catches "never set up" or "flag drifted," and --verify-only reads
t3-serve.log for the one line that proves the credential worked on this boot. The
reconcile is forked behind a retry, so it lands seconds after the server reports ready;
--verify-only waits up to 45s (T3_PUBLISH_VERIFY_TIMEOUT to change it) and treats a
timeout as unknown rather than broken. It is skipped when the desktop app owns the port,
since t3-serve.log then describes a process that is no longer listening. Connect
mode does the same read inline, since it depends on the same reconcile.
start.sh owns the top-level flags and the Claudex half. t3-start.sh and
t3-stop.sh own the T3 half and run standalone, without CodeLaunch driving them.
./start.sh [--detached] [ttl] is idempotent, so a live stack short-circuits to
reuse. -d and the TTL may appear in either order and are passed through to
t3-start.sh, which uses them only in custom modes. Normal mode leaves the
pairing helper open; detached mode prints the code and all URLs, then exits.
Non-TTY execution uses detached behavior automatically.
- flags -
.envis parsed byscripts/env.sh, which accepts only the expected variables and never executes it as shell code.CLAUDEX_ENABLED,T3_ENABLED, andCODEX_WEB_GPT_MANAGEDare validated here; with both halves off,start.shsays so and exits 0. The ChatGPT/Codex app-server preflight runs either way: with Claudex on it followsCODEX_WEB_GPT_MANAGED, and with Claudex off it takes themanaged=0side, since T3 must own the app-server. - caffeinate - always, regardless of the flags. Warns (not fails) off AC
power. It reuses a valid CodeLaunch-owned
caffeinate -dims; otherwise it reuses an exact pre-existing unowned assertion without claiming it, or starts and records its own process. It cannot enable Wake for network access - that stays a manualpmset/System Settings step (pmset -g | grep wompmust read1). - Claudex path - the whole block is skipped unless
CLAUDEX_ENABLED=1, and its prerequisites (docker,claude,claudex,jq) are only required then:- Docker - reuse if
docker infosucceeds, else rundocker desktop start(native Desktop CLI, with existence probed viadocker desktop --helpsincestatuscan exit non-zero merely because Desktop is stopped) with anopen -g -j -a Dockerfallback, then polldocker infoup to 120s. - Codex token - uses the newest
cliproxy/auth/codex-*.json, valid while now is before the ISO-8601expiredfield (compared tz-aware, offset preserved). If expired or missing and stdin is a TTY, it runs./cliproxy/login.sh(browser, callback127.0.0.1:1455) and re-checks. On a non-TTY it fails and tells you to run login on the host. - cliproxy -
./cliproxy/start.sh, then an authenticatedcurl /v1/modelswith the key grepped fromconfig.yaml. A rejected key fails loudly. - Codex Web GPT - a further no-op unless
CODEX_WEB_GPT_MANAGED=1. Requires the ChatGPT desktop app to be running, then connects the reversible route, starts the Web GPT service headlessly if needed, and waits for its loopback daemon to report healthy. This is a ChatGPT desktop integration; Web GPT models are not usable inside T3 Code/CodeLaunch and cannot make tool calls there. Launcher and route failures are nonfatal warnings. See Step 6.
- Docker - reuse if
- T3 path -
./t3-start.sh [--detached] [ttl]unlessT3_ENABLED=0.
Requires npx and jq. It reads only the mode-selection variables first, so
connect mode never loads or exports the custom networking configuration. An
already-running CodeLaunch-owned server is reused as-is; its log still holds this
boot's reconcile result and must not be truncated by a second start. A server
started in a different mode is refused rather than adopted, and a headless server
CodeLaunch did not start is reused rather than duplicated.
In connect mode: the T3_CHANNEL guard, then the T3 Connect reconciliation
described in Modes, then the detached server, then the link-health
read, then the sign-in summary. The link mode is not exposed by
connect status, so the publish-only check reads the one secret that records it
(~/.t3/userdata/secrets/cloud-cli-desired-link.bin). A missing or unreadable
file is never treated as publish-only - a failed read can never cause an unlink.
In custom modes: ./t3-pair.sh --check-only for the channel guard,
./t3-publish.sh --check-only for declared-vs-persisted publishing intent, the
detached backend on $BIND_HOST:$T3_PORT, ./t3-pair.sh --ensure-only to verify
the live bind matches T3_BIND, the $TUNNEL_NAME tunnel in full,
./t3-publish.sh --verify-only, and finally the one-time pairing token. The
publish check only warns, since a notification setting should not block the stack.
The tunnel is reused if it is already up, else launched to
$HOME/.codelaunch/run/cloudflared-t3.log and polled for Registered tunnel connection (up to 30s, printing tail -20 on timeout).
Detachment. The server is started under nohup with stdin, stdout, and
stderr detached from the invoking terminal, so it survives the SSH session
closing. The T3 CLI's only background lifecycle is t3 service install, which
registers a launchd agent that also starts at login - more persistent state than
a start/stop pair should own - so CodeLaunch supervises the process itself.
npx stays alive as the supervisor and the real server is its node child, so
startup is not called successful until that child exists and the log has printed
T3 Code server is ready.; a start that never gets there is torn down and
reported with the tail of its log. Logs go to
$HOME/.codelaunch/run/t3-serve.log, mode 0600.
Ownership. $HOME/.codelaunch/run/t3-serve.pid records the supervisor PID,
its start time, its exact command line, and the mode it was started in.
t3-tunnel records the tunnel name in custom/full. A record that is provably
dead or recycled is dropped so it cannot wedge a later start; a malformed one
stops both scripts rather than being guessed at.
Idempotent, and driven entirely by those records rather than by .env - a stack
started in one mode still tears down correctly after the configuration moved on.
It SIGTERMs the recorded supervisor and the server child it resolved before
signalling (the child is reparented once the supervisor exits, so it has to be
found first), escalates to SIGKILL after 20s, then clears the record. The
recorded tunnel gets the same 35s grace and second signal as before.
A T3 Desktop app, and any headless server CodeLaunch did not start, are reported and left running. T3 account authentication and the T3 Connect link are never touched: stopping the server is not a reason to unlink the environment.
./stop.sh calls ./t3-stop.sh first, then reverses the Claudex side. It leaves
Docker Desktop, the Docker daemon, native Claude Code, and caffeinate -dims
running on purpose, and prints a reminder to use ./stop.sh --all for the
optional full shutdown:
- T3 server and tunnel -
./t3-stop.sh, above. - claudex sessions - matched by the literal
CLAUDE_CONFIG_DIR=$HOME/.claudexin the process environment (ps ewwappends env vars). Native Claude Code lacks that marker, so it is never touched. SIGTERM only. - Codex Web GPT - a no-op unless
CODEX_WEB_GPT_MANAGED=1. Disconnects the Codex route and restores the prior native one, then quits the launcher only if CodeLaunch started it and it still matches on PID, executable path, and start time. A launcher it did not start is left running. It never force-kills and never uninstalls, with or without--all. See Step 6. - cliproxy -
./cliproxy/stop.sh(docker compose down), skipped with a note if docker is unreachable. - caffeinate - normal stop leaves it running so a remote stop does not release
the sleep assertion before SSH can start the stack again. A pre-existing exact
caffeinate -dimsis reused without being claimed and is never stopped.
The Claudex teardown is not gated on CLAUDEX_ENABLED, so flipping the flag off
does not strand services that are still up; each step is already an ownership
check or a no-op.
./stop.sh --all performs the normal teardown, then stops Docker Desktop and,
last, only the exact caffeinate -dims process recorded as CodeLaunch-owned. It
rechecks ownership before signaling and leaves invalid or changed records alone.
It never stops native Claude Code, T3 Connect or unrelated tunnels, unrelated
Docker containers, or unowned caffeinate processes.
Every kill is guarded with || true so re-runs are clean no-ops.
After re-authenticating Claude Code or a Codex CLI account, restart only T3 so it reloads the account without stopping CLIProxyAPI:
./t3-restart.shThis runs ./stop.sh t3 && ./t3-start.sh. The t3 stop mode is t3-stop.sh
plus CodeLaunch-managed Claudex sessions, and leaves the proxy, Docker, and
native CLI sessions running.
Claudex path only - CODEX_WEB_GPT_MANAGED is ignored while CLAUDEX_ENABLED=0.
Optional, off by default, and independent of the tunnel and the proxy. Codex Web GPT is a separate macOS launcher that runs a loopback Responses daemon for the ChatGPT desktop integration. CodeLaunch can start that service headlessly, but the ChatGPT desktop app is required for the Web GPT models to work. They are not a usable provider inside T3 Code/CodeLaunch and cannot make tool calls there. CodeLaunch does not install or configure the integration - the launcher owns its own setup, its Bun runtime, its browser helper, and its tunnel. CodeLaunch only flips the reversible route on start and back off on stop, and quits a launcher it started itself.
The same script installs and updates it:
curl -fsSL https://github.com/miuuyy/codex-chatgpt-web/releases/latest/download/install-launcher.sh | shIt resolves the latest release, verifies the asset against checksums.txt with
SHA-256, installs Codex Web GPT.app into /Applications (falling back to
~/Applications if that is not writable), and opens it. On an update it moves the
old bundle aside and rolls back if the copy fails. It refuses to run while the
launcher is open, so quit the app - or run ./stop.sh if CodeLaunch owns it -
before updating.
Then complete setup in the GUI. CodeLaunch never runs setup or uninstall on
your behalf.
- Disable "Launch at login" if CodeLaunch should own app startup. Left on,
macOS starts the launcher before
start.shruns, so CodeLaunch reuses it as an unowned process andstop.shleaves it running - which is correct, just not what you asked for. - "Keep server running when window closes" may stay enabled. CodeLaunch quits the application, not the window, so that setting does not block shutdown.
Verify the integration exists before enabling anything:
codex-chatgpt-web route status{ "installed": true, "active": true, "routeUrl": "http://127.0.0.1:17841/v1", "errors": [] }installed means the reversible journal is written and the prior Codex route was
recorded, so it can be restored. active means Codex is pointed at the loopback
daemon right now. errors must be empty.
There is nothing to add to your PATH. The macOS installer places only the app
bundle, and the CLI is not inside it - the only copy lives in the private runtime
directory the launcher provisions per release. codelaunch_codex_web_gpt_cli
prefers a codex-chatgpt-web on PATH if you happen to have one, and otherwise
reads releaseVersion from ~/.codex-chatgpt-web/config.json and resolves
versions/<version>-darwin-<arch>/bin/codex-chatgpt-web from it. Keying off the
live release matters because updates leave the older version directories on disk,
and a hard-coded or symlinked path would keep running a stale CLI against new
config. Set CODEX_CHATGPT_WEB_HOME if you moved that directory.
CODEX_WEB_GPT_MANAGED=1Accepted values are 0 and 1; anything else fails start.sh and stop.sh
immediately. At 0 neither script looks at Codex Web GPT at all, and the same is
true at 1 while CLAUDEX_ENABLED=0 - start.sh notes that the setting is being
ignored rather than acting on it.
Before starting any services, start.sh requires the ChatGPT desktop app to be
running for this mode, and only checks for it while CLAUDEX_ENABLED=1. It shows both a terminal warning and a macOS alert, then
stops without starting or quitting ChatGPT if the app is absent. After that
preflight passes, the Web GPT setup runs after the proxy and before the T3
backend. Failures in the setup steps below are nonfatal warnings:
- Resolve the app and the CLI. The app is looked for in
$CODEX_WEB_GPT_APPLICATIONS_DIR,/Applications, and~/Applications, then via Spotlight, and every candidate must carry thedev.codexwebgpt.launcherbundle identifier. The CLI comes from PATH or the live release inconfig.json. Missing either one warns and returns. - Read
route status. It requiresinstalled: true, noerrors, and a loopbackrouteUrlof the exact formhttp://127.0.0.1:<port>/v1. A non-loopback or unparseable route is refused rather than guessed at. The port comes from that value;17841is not hard-coded. - Find the launcher by exact executable path. Already running means reuse it
without recording ownership, so
stop.shwill leave it alone. - If the route is inactive,
route connect, then re-readroute statusand require the same route back. This runs before the launcher is opened: the launcher starts its supervised runtime at process startup only when the route is already active, so connecting afterwards would never bring a stopped runtime up. - If no launcher is running,
open -g -jthe app hidden, resolve its PID, and verify PID, executable path, and process start time before writing the ownership record to~/.codelaunch/run/codex-web-gpt.pid. - Poll
http://127.0.0.1:<port>/healthzfor up to 60s and requireservice: "codex-chatgpt-web",status: "ok", andaccepting_turns: true. Thendoctor --jsonmust reportok: true, androute statusmust still agree.
If any step after the connect fails and CodeLaunch is the one that connected the
route or opened the launcher, it runs route disconnect to put native Codex
back rather than leaving it pointed at a daemon that is not answering.
stop.sh runs after the claudex sessions are stopped and before the proxy, and
behaves identically with and without --all:
- Read
route status, thenroute disconnectif it is active, then verify that the journal is stillinstalled, the route isactive: false, the samerouteUrlis recorded, anderrorsis empty. - A failed disconnect leaves the launcher running. Quitting it while Codex still points at its loopback daemon would break native Codex too, so it reports and moves on to the rest of the teardown.
- Only after a verified disconnect does it look at ownership. A launcher recorded
as CodeLaunch-owned, still alive, and still matching on PID, executable path,
and start time gets a normal application quit:
osascript -e 'tell application id "dev.codexwebgpt.launcher" to quit'. A launcher it did not start is left running and reported. A malformed or symlinked record fails closed - the app is left running and nothing is signalled. A stale or mismatched record never causes a signal either; it is only cleared once the launcher and its runtime are confirmed gone. - It waits up to 60s for the app to drain and exit, rechecking identity each
second. There is no escalation: no
kill, nokill -9, nopkill, no Bun signal, no touching the browser helper or tunnel. A launcher still up after 60s is reported as busy and its ownership record is kept for a later retry. - Ownership is cleared only after the exact process is gone and
/healthzhas stopped answering.
Neither script ever runs codex-chatgpt-web uninstall, including under --all.
Removing the integration stays a manual choice, made in the launcher.
Enable the native codex provider in T3 (.t3/example.settings.json ships it
enabled with binaryPath: "codex") and leave CODEX_WEB_GPT_MANAGED=0. In this
mode, quit ChatGPT before starting so T3 owns the Codex app-server processes.
When CODEX_WEB_GPT_MANAGED=1, CodeLaunch may start the Web GPT service
headlessly, but that mode is for the ChatGPT desktop integration. The ChatGPT
desktop app must be running, and Web GPT is not supported as a provider inside
T3 Code/CodeLaunch; its models cannot make tool calls there. Do not select the
chatgpt-web/* rows in T3 expecting them to work.
~/.codex-chatgpt-web holds authentication, runtime, and integration state:
config.json (which contains a plaintext controlToken for the daemon's admin
endpoints), secrets/ (the tunnel runtime key), the saved ChatGPT browser
session, the tunnel profile, and the reversible route journal. It is outside this
repo and must never be committed, copied into .t3/, or pasted into an issue.
Back it up the way you would an SSH key, or not at all.