Run your own modified build of T3 Code on your Mac and keep it current with upstream, without an Apple Developer account. By default your build is its own app, "T3 Code Custom", running next to the official one.
Unofficial. This is a personal kit. It is not made, endorsed or supported by T3 Tools / Ping. Don't report problems with your DIY build to the T3 Code team.
There are two ways to use it: run someone else's branch (no fork needed), or keep your own fork building every day.
Omar's fork (OmarAlex24/t3code, branch
feat/sandbox-environments) adds sandbox environments. Save a sandbox config once (Boat, Daytona,
Vercel, Upstash or Celesto), and every new thread you start on it gets its own throwaway cloud
machine that clones the repo from GitHub and runs the thread there. Background and feedback:
discussion #27949.
You don't need a fork. The kit clones Omar's branch, rebases it onto upstream main on your Mac
every day, runs the sandbox tests, and builds a "T3 Code Custom" app that sits next to your normal
T3 Code, with its own data and settings:
git clone https://github.com/OmarAlex24/t3code-diy.git
cd t3code-diy
./bin/t3code-diy install --yes --no-push \
--fork-url https://github.com/OmarAlex24/t3code.git \
--branch feat/sandbox-environments \
--test-group '^(apps/server/src/sandbox/.*\.test\.ts|packages/contracts/src/sandbox[^/]*\.test\.ts)$' \
--test-group '^apps/web/src/.*Sandbox[^/]*\.browser\.test\.tsx$'--no-push matters: you can't push to Omar's repo, and without it every run would fail at the push
step. Install clones about 700 MB of history (minutes, depending on your connection), then the
first build starts right away and takes roughly 10 to 20 minutes: tests, a Rust helper, the
Electron build and signing. Follow it with ~/.t3code-diy/kit/bin/t3code-diy status, and once a
build is in the feed:
~/.t3code-diy/kit/bin/t3code-diy install-appThen open T3 Code Custom and set up a sandbox config as described in the fork's docs/user/remote-access.md.
Fork pingdotgg/t3code, put your changes on a branch, and point the kit at it:
./bin/t3code-diy install --yes \
--fork-url git@github.com:you/t3code.git --branch my-changes \
--test-group '^apps/server/src/myfeature/.*\.test\.ts$'The kit keeps that branch rebased onto upstream and pushes it back to your fork after the tests
pass (force-with-lease, see Caveats). Leave out --yes to be asked for each setting.
install checks all of these and tells you what's missing:
- A Mac with Apple Silicon (arm64 only)
- Xcode Command Line Tools (
xcode-select --install), for git, codesign, clang and python3 - Node 22 or newer and Vite+ (
vp), which T3 Code uses for installs and tests - Rust from rustup.rs plus
rustup target add aarch64-apple-darwin(the app ships a small Rust helper, so this isn't optional) - Claude Code, logged in (
claude auth login), or the Codex CLI, logged in (codex login) with--resolver codex. It resolves rebase conflicts when upstream and the branch collide. - About 15 GB of free disk (repo, dependencies, a pristine upstream checkout for the test gate, the last 3 builds)
Every day (and when you log in) a launchd job on your Mac:
- fetches upstream
mainand the branch you follow, and rebases that branch onto upstream (withgit rerereon, so a conflict you resolved once stays resolved) - if the rebase conflicts, runs Claude Code or Codex headless to resolve it. If that doesn't settle it, the run builds your branch as it is, on the upstream it already contains, and says the build is behind upstream (see Conflict resolver)
- runs the branch's own tests (your
--test-groups) plus upstream'svp run check:changed - pushes the rebased branch back to your fork (skipped with
--no-push) - builds the macOS app, signs it with a self-signed certificate that lives only on your Mac,
and publishes it to a feed served on
127.0.0.1
Your app's own "update available" button then installs the new build, like the official app does with its releases.
If nothing changed since the last build it does nothing. If the rebase or the tests fail, nothing
gets pushed or published and your branch is put back where it was. Rebase conflicts the agent
can't resolve are the exception: by default the job then tests and builds your branch unchanged on
the older upstream it already sits on (FALLBACK_ON_CONFLICT). The push happens right after
the tests pass, so if the build or signing fails after that, the rebased branch is already on your
fork but nothing is published. Either way the app you have installed stays as it is, and you get a
macOS notification with the path to the log.
Everything runs on your machine with your Claude (or Codex) login and your signing key. There's no CI, no secrets in GitHub, and no binaries to download: you build your own.
Either command above does it. Without --yes, install asks for the repo URL, branch, whether to
push, feed port, schedule and conflict resolver.
./bin/t3code-diy install --help lists every option. Install puts everything in
~/.t3code-diy/ (set DIY_HOME to use another place): the config, a clone of the branch's repo, the
signing keychain, builds, the feed and logs. It loads two launchd jobs, dev.t3code-diy.feed and
dev.t3code-diy.update, and the first update starts right away. A cold first run takes a while
(dependencies, a Rust build, a full test pass).
Check on it:
~/.t3code-diy/kit/bin/t3code-diy statusWhen a build is in the feed, install it:
~/.t3code-diy/kit/bin/t3code-diy install-appThat puts /Applications/T3 Code Custom.app next to the official app, which stays as it is. Open
it like any app. On first launch macOS asks to let it use "t3code Safe Storage" in your keychain.
Enter your password and pick Always Allow. Updates keep the same signature, so it won't ask
again. After that the app updates itself; you only run install-app again if you change one of the
SIDE_* settings. uninstall-app removes it (its data stays).
Other commands: update runs the job by hand in your terminal (--force builds even if nothing
changed, --no-push skips the push), run starts the launchd job now and returns right away,
log [--tail N] prints the last run's log, config get/set reads or changes one setting (below),
uninstall removes the launchd jobs (--purge also deletes ~/.t3code-diy).
When git rerere can't replay a resolution, the job hands the stopped rebase to an agent:
Claude Code (claude -p) by default, or Codex (codex exec). Pick one, its model, and how hard
it thinks:
~/.t3code-diy/kit/bin/t3code-diy config set RESOLVER_PROVIDER codex RESOLVER_MODEL gpt-5.5
~/.t3code-diy/kit/bin/t3code-diy config set RESOLVER_PROVIDER claude RESOLVER_MODEL opus
~/.t3code-diy/kit/bin/t3code-diy config set RESOLVER_EFFORT high
~/.t3code-diy/kit/bin/t3code-diy config set RESOLVER_MODEL '' RESOLVER_EFFORT '' # the CLI's own defaultsRESOLVER_EFFORT is empty by default, which means whatever the CLI picks on its own. Claude takes
low, medium, high, xhigh or max (it becomes claude --effort); Codex takes those plus
none and minimal (it becomes -c model_reasoning_effort=...). Ultracode, Codex's Ultra and
Ultrathink aren't on the list: they spin up extra agents or live in the prompt, which isn't what
you want from a one-shot rebase. Switch providers and an effort the new CLI doesn't have just goes
back to empty, so RESOLVER_PROVIDER claude after Codex's minimal leaves you on Claude's default.
When the agent can't settle the conflicts (or can't start, say because its CLI is logged out), the
branch goes back to where it was and, by default, the run carries on with it as it is: your branch
already sits on an older upstream commit (its merge base with upstream/main), so the job tests it
against that commit (check:changed and the pristine-upstream comparison use it too), pushes
nothing, and builds and publishes it. The log says
rebase onto upstream/main conflicts in <files>; building <branch> on the upstream it already contains (<sha>, <date>), and the run's reason and the "build ready" notification say the build is
on upstream from that date and that the newest upstream conflicts with your branch. Upstream moves
fast, so this keeps you on a working build of your branch while you resolve the rebase by hand and
push it; the next run then rebases as usual. If the tests fail on the older upstream, the run fails
as it would otherwise, and the reason says which upstream it tested on. To fail the run instead
whenever the rebase can't be settled (the installed app stays as it is), turn it off:
~/.t3code-diy/kit/bin/t3code-diy config set FALLBACK_ON_CONFLICT 0set checks every value before it writes anything, and won't switch to a CLI that isn't installed
and logged in. Model names are what T3 Code shows (opus, claude-opus-4-6[1m], gpt-5.5); the
agent gets RESOLVER_TIMEOUT_MIN minutes (default 90). Configs from before this used
CLAUDE_MODEL; that value carries over as RESOLVER_MODEL and the next write renames it.
install --resolver codex --model gpt-5.5 --effort high does the same at install time. Older
configs have no RESOLVER_EFFORT line and keep working; the next write adds it.
config set also takes RESOLVER_TIMEOUT_MIN, PUSH, RUN_CHECK_CHANGED and
FALLBACK_ON_CONFLICT (1, the default, builds on the upstream the branch already contains when the
rebase conflicts beyond the agent; 0 fails the run). Configs without a FALLBACK_ON_CONFLICT line
get 1. config get KEY reads any plain setting. Schedule, port, labels and the SIDE_* values
still go through install, because launchd or a rebuild has to pick them up.
Each build puts T3CODE_DIY_BIN (the path to ~/.t3code-diy/kit/bin/t3code-diy) in the app's
Info.plist, next to the side-by-side keys. A fork that knows about it shows a Custom build card
in Settings, About: the last update and its result, the log, an Update now button and the
conflict resolver picker (provider, model and effort, written with config set). It talks to the kit only through the commands below, so the kit and the
app can be updated separately. Without the key (anybody else's fork, or the official app) nothing
changes. Side-by-side builds also get T3CODE_DESKTOP_DISPLAY_NAME="$SIDE_APP_NAME", which a fork
that reads it uses for the window title and the About panel.
t3code-diy status --json prints one JSON object and never asks anything. It stays off the
network and takes a fraction of a second, so it's fine to poll. schema goes up only when a field
changes meaning or goes away; new fields can show up any time, so ignore the ones you don't know.
{
"schema": 1,
"kit": { "bin": "/Users/you/.t3code-diy/kit/bin/t3code-diy", "home": "/Users/you/.t3code-diy" },
"mode": "side-by-side",
"app": {
"name": "T3 Code Custom",
"path": "/Applications/T3 Code Custom.app",
"installedVersion": "0.1.8-nightly.20261105.900004",
"forkBuild": true,
"latestBuild": "0.1.8-nightly.20261105.900005"
},
"fork": { "url": "git@github.com:you/t3code.git", "branch": "my-changes" },
"upstream": { "url": "https://github.com/pingdotgg/t3code.git", "branch": "main" },
"resolver": { "provider": "claude", "model": "opus", "effort": "", "timeoutMinutes": 90 },
"schedule": { "loaded": true, "hour": 5, "minute": 0, "atLogin": true, "nextRun": "2026-11-05T05:00:00-06:00" },
"running": false,
"lastRun": {
"result": "ok",
"reason": "built 0.1.8-nightly.20261105.900005",
"startedAt": "2026-11-04T18:25:37-06:00",
"endedAt": "2026-11-04T18:39:04-06:00",
"version": "0.1.8-nightly.20261105.900005",
"log": "/Users/you/.t3code-diy/logs/update-20261104-182537.log"
}
}app.installedVersionisnullwhen the app isn't in/Applications;latestBuildis the newest build in the feed, which the app picks up through its own updater.schedule.nextRunis the next daily run (local time, ISO 8601),nullwhen the job isn't loaded. Runs at login aren't predictable, so they aren't in it.resolver.effortis""when the CLI picks its own effort. Kits from beforeRESOLVER_EFFORTleave it out, so treat a missing one the same way.runningis true from the moment launchd starts the job until it exits.lastRunisnullbefore the first run.resultis one ofok(a new build is in the feed),up_to_date(nothing changed),running,conflict_unresolved,tests_failed,migration_collision,rebase_failed,push_failed,build_failed,interrupted(the run died without finishing: killed, or the Mac shut down) anderror(anything else).reasonis one plain sentence. A build made on the upstream the branch already contains, because the newest one conflicts, is stillok; its reason reads likebuilt <version> on upstream from <date>; the newest upstream conflicts with your branch(up_to_dateruns in that state add the same note). On every result butokthe installed app is exactly as it was.endedAtisnullwhile it runs.
Exit codes: run, config and log exit 0 on success, 1 when something is wrong (the message is
on stderr) and 2 for a bad command line.
side-by-side (default). Your build is a separate app with its own name, bundle id, data and server, so it runs at the same time as the official T3 Code:
| official | yours (defaults) | |
|---|---|---|
| app | T3 Code (Nightly).app |
T3 Code Custom.app (SIDE_APP_NAME) |
| bundle id | com.t3tools.t3code |
com.t3tools.t3code.custom (SIDE_BUNDLE_ID) |
| T3 home | ~/.t3 |
~/.t3-custom (SIDE_T3_HOME) |
| Electron profile | ~/Library/Application Support/t3code-v2 |
.../t3code-custom (SIDE_USER_DATA_NAME) |
| server port | 3773 or the next free one | 3873 (SIDE_PORT) |
| icon | upstream's Nightly icon | T3 Code Dev's blueprint (ICON) |
| updates | GitHub releases | your local feed |
Upstream's Beta and Nightly can't run together: they share the bundle id, the profile dir (and with
it the single-instance lock) and ~/.t3. Only the Dev build is separate, and only because it runs
from a dev server. So the kit separates your build itself:
- The home and port use settings T3 Code already reads (
T3CODE_HOME,T3CODE_PORT). The profile dir is hardcoded upstream, so each build applies a 3-line patch (templates/patches/userdata-name.patch) that adds aT3CODE_DESKTOP_USER_DATA_NAMEoverride. It goes in after the tests and the push, comes out right after the build, and never gets committed. If upstream changes that file so the patch no longer applies exactly, the run stops with a notification and nothing is published. The build is also checked for the override before it's published. - The app is renamed the way electron-builder would for another product name, and the three
values above go into its
Info.plist(LSEnvironment), so it starts the same way from Finder, the Dock or after an update. Its bakedapp-update.ymlpoints at your local feed only.
If you change a SIDE_* value in the config, run update --force, quit the app, then
install-app.
Icon. So you can tell the two apps apart in the Dock, side-by-side builds get T3 Code Dev's
blueprint icon (assets/dev/blueprint-macos-1024.png from the repo being built). Want your own?
Pass install --icon path/to/icon.png with a 1024px PNG that's already shaped like a macOS icon
(transparent margins, rounded square). Relative paths are inside the repo being built, so an
icon you commit to your fork works too. --icon '' keeps the stock icon. It's the ICON line in
the config; after changing it run update --force and update the app from inside it. If the
file is missing at build time, the run logs it and builds with the stock icon instead of failing.
Replace mode keeps the stock icon unless you pass --icon.
replace. Your build takes the official app's place: same name, bundle id and data. Pick it with
install --mode replace; then you use switch and rollback instead of install-app and
uninstall-app. Run those from Terminal.app, not from a terminal inside T3 Code, because they ask
you to quit the app.
switch backs up ~/.t3/userdata, the Electron data in ~/Library/Application Support and the
current /Applications/T3 Code (Nightly).app into ~/.t3code-diy/backup/<date>/, then installs your
build. rollback puts the official app and your pre-switch data back; your fork-era data isn't
deleted, it's moved to ~/.t3code-diy/backup/<date>-fork-state/.
There's one feed, so switching modes means the app from the other mode stops getting updates. Configs from before modes existed stay in replace mode.
- The build version is
<next patch>-nightly.<UTC date>.<900000+n>, e.g.0.1.8-nightly.20261104.900003. The big sequence number makes your builds sort above the official nightlies, so the app always sees yours as newer. - The local feed URL is baked into your app (
Contents/Resources/app-update.yml). The official app keeps reading GitHub and never sees your feed. T3 Code's managed policy file could also set a feed URL, but it only accepts https, so that route doesn't work for a local feed. - The feed has both
nightly-mac.ymlandlatest-mac.yml, so either update channel finds it. - The app is re-signed inside out (binaries, then nested bundles, then the app) with the hardened
runtime and the build's entitlements minus the ones that need an Apple provisioning profile.
Renaming and the
Info.plistchanges happen before signing, and the feed zip is made from the signed app. - The test gate runs your
FORK_TEST_GROUPS(must pass) andvp run check:changed upstream/main. When a fork test group fails, the files of its failing tests run once more on their own, and if that re-run passes the failures count as flaky (a loaded machine makes browser tests time out now and then); the log names them. When the output doesn't pin the failure on a test (a file that won't load, an error outside the tests such as a browser session that never connected), every file of the group runs once more, one at a time, and if they all pass the failure counts as flaky too; the log says why. A re-run that fails blocks the build, and the failure says how many tests failed and names the first, or says that no test was named even file by file. A check:changed test failure is re-run alone the same way, and one that fails again only counts as OK if the same test also fails on a pristine upstream checkout (~/.t3code-diy/upstream-check). Anything else blocks the build. - Browser tests (
*.browser.test.tsx) need Playwright's headless Chromium. The gate downloads the version the repo pins into~/.t3code-diy/playwright-browsers(once, about 100 MB), the same step upstream's CI runs. - One run at a time (lock file). Logs:
~/.t3code-diy/logs/update-*.log(last 30 kept). - Daily time, port, model and the rest live in
~/.t3code-diy/config. After editing schedule, port or labels, re-runinstallso the launchd jobs pick them up.
Do your work in your own clone of your fork, push to your branch, and the next run picks it up.
Don't work inside ~/.t3code-diy/repo; the job resets that branch on every run.
Tell the gate about your tests with --test-group (or FORK_TEST_GROUPS in the config). Each entry
is an extended regex over git ls-files; the matching files run in one vp test run. A group that
matches nothing fails the run, so renamed tests don't silently stop running.
If you write ~/.t3code-diy/fork-notes.md, it's added to the conflict agent's prompt. A few lines
on what your fork changes and where it lives help it keep your side of a conflict.
The sandbox command in Try Omar's sandbox build is a real example of test groups.
The basics:
- Unofficial. Not made, endorsed or supported by T3 Tools / Ping. Problems with a DIY build go here, not to the T3 Code team.
- macOS on Apple Silicon only. No Intel, no Linux, no Windows.
- Self-signed. The app is signed with a certificate made on your Mac, not by Apple. It isn't notarized, so it's meant for the Mac that built it, and passkeys don't work (below).
- Local feed only. Updates come from
127.0.0.1on your Mac. Nothing is downloaded from anyone else and there's nothing to share. - Following someone else's branch means running their code with your provider logins, rebased on your Mac. Your local rebase can differ from theirs, and their force-pushes are picked up on the next run. Read the diff if you care.
Side-by-side:
- Separate everything. Your app has its own projects, threads, settings and T3 Connect sign-in. Provider logins (Claude Code, Codex and so on) live in their own CLIs, so both apps share those.
- Seeing the official app's threads. Add the official app's server as an environment in your app: through T3 Connect (sign in to both with the same account) or over your LAN (turn on network access in the official app and pair).
- First launch imports old v1 storage. If you still have the pre-2.0 profile
(
~/Library/Application Support/t3code), upstream copies its browser storage into the new app's profile once (read-only, the old profile isn't changed). Saved environments from back then can show up, e.g. this Mac's official server, which is a quick way to see its threads. t3code://links (and browser sign-in callbacks) go to one app only: the one that started last, since each app makes itself the handler when it starts. Your app keeps thet3codescheme because T3 Connect's sign-in uses it as the app's origin, and another scheme may not be accepted. If a Google/GitHub sign-in in one app doesn't come back, quit and reopen that app and try again.- Two servers run while both apps are open, so expect roughly twice the memory.
- Port. If something else holds
SIDE_PORT, your app can't start its server. Pick another port. - Terminals inside your app see
T3CODE_HOME=~/.t3-custom, so at3command run there talks to your app. Don't use "Install t3 command" in your app unless you wantt3everywhere to mean your build. - The window title and the About panel say "T3 Code (Nightly)" (the name inside the app's code)
unless your fork reads
T3CODE_DESKTOP_DISPLAY_NAME, which the kit sets to your app's name. The app name, menu and Dock say yours either way. - Icon caching. Finder and the Dock cache icons, so after an update that changes the icon the
old one can stick around. Quit the app, then run
killall Dock Finder; if it's still the old one,touch "/Applications/T3 Code Custom.app"first. The small permission prompts the app shows keep the stock icon: they read the copy packed insideapp.asar, which the kit leaves alone.
Both modes:
- Passkeys don't work in a self-signed build: they need an Apple-issued entitlement. Email and OAuth sign-in work.
- T3 Connect works because the build uses the public Clerk and relay settings upstream ships in
.env.example(install copies it to.env). If upstream changes or removes those, Connect stops working in your build. - Database migrations. Upstream numbers its migrations in
apps/server/src/persistence/Migrations.ts, and the migrator skips every id up to the highest one a database recorded. A fork migration added there takes the next free id, which upstream ships sooner or later, and then the two can't both live in one database. So keep your fork's migrations in their own list with their own history table (in Omar's fork,ForkMigrations.tsandt3_fork_migrations), and leave upstream's list exactly as upstream has it. The job checks this before every rebase and stops withmigration_collisionif your fork adds anything to upstream's list; moving it out is up to you, since databases that already ran it need their history moved too. The official app never reads the fork's table, so the fork's tables in a database are just extra tables to it. Rollback still restores your pre-switch database, because the fork build may have run upstream migrations newer than the official release; anything you did in the fork build stays in the fork-era copy. - The conflict agent runs unattended. Claude Code gets permission to edit files, run git
add/commit/rebase --continue and the test commands in the managed repo, nothing else (
git push,git configandgit remoteare denied). Codex has no per-command allowlist, so it runs with its sandbox off (the rebase writes to.gitandvp iwrites outside the repo, which its sandbox refuses). Neither can push, and that part doesn't depend on the agent: for its run every remote's push URL in.git/configpoints nowhere and SSH and credential prompts are off. The real URLs are saved first and put back afterwards, even when the run fails or is killed (then the next run puts them back), and a remote the agent added is removed. After the agent exits the job checks that your branch on GitHub didn't move. The script itself pushes, and only after the tests pass. Claude Code runs with background tasks off and a 30-minute command timeout, so it waits for its test runs instead of moving them to the background and waiting for a report that never comes. When the agent runs out of time, it is stopped along with every process it started. Read the logs now and then. - Force-push. The daily job rewrites your branch (it's a rebase) and pushes it with
--force-with-lease. If you pushed something in between, the push fails and nothing is built. Don't use the branch for anything that can't handle being rebased. For pushes over SSH the job needs your key available without a prompt (e.g. in the macOS keychain viassh-add --apple-use-keychain); over https, a credential helper likegh auth setup-git. - Remote servers show "behind". Your build's version doesn't exist on GitHub, so a remote T3 server you connect to may report the client as out of date. That's expected.
- Keep the signing identity.
~/.t3code-diy/signingholds the certificate, its own keychain and the keychain password (in a 0600 file). If you delete it, the installed app stops accepting updates (different signature) and macOS asks for keychain access again.
MIT, see LICENSE. T3 Code itself is MIT licensed by T3 Tools Inc.