A terminal client for T3 Code that lists and drives your coding-agent threads across every machine you run T3 Code on. Register each host once, then start turns, interrupt runaway agents, and see what's running everywhere from one prompt — without opening the desktop app.
Unofficial community client. Not affiliated with T3 Tools. See Limitations.
You need a running T3 Code server (t3 serve, or the desktop app, which runs one)
and Node 22+.
# 1. install
npm i -g @gobius/t3ctl
# 2. mint a token — run this on the machine hosting T3 Code
npx t3 auth session issue --label t3ctl --ttl 30d --token-only
# 3. register that host — t3ctl probes it and names it after the machine
t3ctl host add http://localhost:3773 eyJ2Ijox...
# 4. see everything
t3ctl lslaptop http://localhost:3773
t3ctl ●1 ◆1 ·3 ~/Code/t3ctl
api-gateway ✓2 ·1 ~/Code/api-gateway
Add -t to expand threads:
t3ctl ls -tlaptop http://localhost:3773
t3ctl ●1 ◆1 ·3 ~/Code/t3ctl
● rewrite the readme for users main claudeAgent
◆ add snapshotSequence polling poll claudeAgent
· flaky release workflow main claudeAgent
Projects are sorted most-recently-updated first, and so are the threads inside them. Thread titles are truncated at 62 characters.
Hosts and tokens are stored in ~/.config/t3ctl/hosts.json (directory 0700,
file 0600). Tokens are stored in plaintext, so treat that file like a password
file — revoke with npx t3 auth session revoke <session-id> if it leaks.
Run t3ctl with no arguments for the built-in summary.
List projects and threads across all registered hosts, in parallel. Hosts that
don't answer are reported at the end as unreachable rather than failing the run.
t3ctl ls # projects only, with a status tally per project
t3ctl ls -t # --threads: expand each project's threads
t3ctl ls -a # --all: include archived and deleted, and empty projects
t3ctl ls --json # machine-readable; always includes threadsls deliberately has no --host filter — it's the "what's happening everywhere"
view. Pipe --json through jq if you want to slice it:
t3ctl ls --json | jq -r '.projects[].threads[] | select(.status=="running") | .title'The JSON shape is {projects: [{host, id, title, workspaceRoot, threads: [{id, title, branch, status, provider, updatedAt}]}], unreachable: [{host, error}]}.
Register (or update) a host. <origin> is a scheme + host + optional port, with
any trailing slash trimmed — the scheme is required.
Before writing anything, t3ctl fetches the host's environment descriptor from
/.well-known/t3/environment. That endpoint is unauthenticated, so it confirms
you are pointed at a real T3 Code server before a token is involved: a wrong
origin fails with not a T3 Code server instead of a confusing 401 on your first
ls. The descriptor's environmentId, label and serverVersion are stored
alongside the token.
t3ctl host add https://studio.tailnet-1234.ts.net eyJ2Ijox...The host is named after the machine's own label (SPR-Gobius-D becomes
spr-gobius-d); pass --name to choose your own. Re-running host add for an
origin you already have updates that entry in place and keeps the stored token if
you don't pass a new one.
t3ctl warns you when:
- the new host's
serverVersiondiffers from your other hosts — the API is not a stable public interface, so a version split is worth knowing about - an origin you already registered now reports a different
environmentId, meaning it points at a different machine than it used to and the stored token belongs to the old one
The token is optional so you can register a host before minting one, but reads will fail until you add it.
Register a host given only its ssh login — no URL, no token, no port:
t3ctl host add agent@goobles-agentboxThat one command bootstraps the machine end to end:
- Probes it over ssh for a running T3 Code server. The server records its
port in
~/.t3/userdata/server-runtime.jsonon the remote, so the port is discovered, never assumed (it is 3773 or whatever free port the server fell back to). - Installs the server if none is running —
t3 service install, T3 Code's own per-user launchd/systemd service (no sudo; macOS and Linux). The exactt3CLI version is resolved locally via npm (latest, falling back tonightly); override with--t3-version <version-or-tag>. The remote npm output streams past — a cold cache can download for a few minutes. - Mints a token for you —
t3 auth session issueon the remote, labeledt3ctl:<name>, TTL 30d by default (--ttlchanges it). The session id is printed along with the exact revoke command for that host. - Tunnels to it — an ssh ControlMaster forwards a local port to the remote server (loopback-only by design; nothing is exposed on the remote's network). The master outlives t3ctl, and every command silently rebuilds it when it dies or the remote port changed.
Registering again for the same login is idempotent: it refreshes the tunnel and
reuses the stored token unless the login now lands on a different machine
(different environmentId), in which case a fresh token is minted.
Give it a name with --name (otherwise it is named after the remote's own
machine label, same as the origin form). Notes:
t3ctl host rm <name>tears the tunnel down with the entry.- macOS remotes need someone logged in at the console — a launchd agent starts at login, so an ssh install with nobody logged in installs fine but cannot start the server. Linux (systemd user service) has no such gap.
- If the remote lacks node for non-interactive shells, install Node there (a
version manager may need configuring for non-login shells); if a native
dependency fails to build, a C toolchain is missing (
build-essential/gcc-c++/xcode-select --install).
t3ctl host rm desktopList registered hosts, probing each one in parallel for its label, environment id
(shortened), server version and reachability. t3ctl host with no subcommand does
the same thing.
t3ctl hosts● laptop SPR-Gobius-D 9d9d9921 0.0.38-nightly.20260901.1250 http://localhost:3773
✕ desktop Desktop 11111111 0.0.31-nightly.20260801.0900 https://studio.tailnet-1234.ts.net cannot reach …
Unreachable hosts are dimmed and show the values last recorded, not live ones.
ls deliberately does not probe — it stays a single request per host.
Register an existing directory on the host as a project. The path is resolved
locally (~ expands) and must already exist — t3ctl will not create it.
t3ctl project create t3ctl ~/Code/t3ctlNote: the workspace root is interpreted on the host, so this really only makes sense for a host whose filesystem you share — i.e.
localhost. For a remote host, pass the remote absolute path and skip the~shorthand.
Create a thread. This produces an idle thread with no messages — it does not
start the agent. Use thread send for that.
t3ctl thread create t3ctl "rewrite the readme for users" --branch docs/readmeFlags:
| Flag | Default | Meaning |
|---|---|---|
--model <instance>/<model> |
claudeAgent/claude-opus-5 |
Provider instance and model |
--branch <name> |
none | Git branch for the thread |
--worktree <path> |
none | Explicit worktree path |
--runtime-mode <mode> |
full-access |
approval-required, auto-accept-edits, auto, full-access |
--interaction-mode <mode> |
default |
default or plan |
--host <name> |
the only host | Which host to act on |
--model splits on the first slash, so slashed model names work as-is:
--model opencode/github-copilot/gpt-5.4.
Send a message to a thread and run the agent. This is how you continue a
conversation — it is the same thing the app does for every message you type,
first or fiftieth. Aliased as thread start, the name it shipped under in 0.2.0. Everything after the thread reference is the
message — no quoting needed.
t3ctl thread send "rewrite the readme" move the endpoint tables into CONTRIBUTING.mdstarted rewrite the readme for users
id 0f5c1e2a-...
model claudeAgent/claude-opus-5
mode full-access / default
seq 4471
Accepts --model, --runtime-mode, --interaction-mode, and --host. Unlike
thread create, --model has no default here: the thread's existing model is
reused unless you override it.
Rename a thread. Useful when a thread was created from a script and ended up with a title like a filename.
t3ctl thread rename "probe well-known" Verify hosts with the environment descriptorAsk the server to derive a title from the thread's own content, instead of supplying one.
t3ctl thread retitle "probe well-known"This is a request, not a guarantee. The server records the intent and expects
something downstream to generate the title; in practice it sometimes clears the
request a few seconds later without producing one, and reports no error when it
does. So retitle watches the thread afterwards and tells you which happened:
asked the server to retitle Build t3ctl: a CLI for controlling T3 Code hosts (seq 677390)
no title was generated — the server cleared the request without producing one.
The title is still "Build t3ctl: a CLI for controlling T3 Code hosts". Set one directly:
t3ctl thread rename 861645a3-... <title...>
It exits non-zero when no title appears, so a script can tell. Waits 30s by
default; --timeout changes that. If you need the rename to be certain, use
thread rename, which sets the title directly.
Stop the turn that's currently running.
t3ctl thread interrupt "rewrite the readme"Thread lifecycle. Each takes a single thread reference (plus --host).
t3ctl thread settle "rewrite the readme" # mark as done, drop out of the active list
t3ctl thread archive "flaky release workflow"
t3ctl thread unarchive 0f5c1e2a-...
t3ctl thread unpin "api rate limits"
t3ctl thread delete "scratch experiment"delete is not prompted and not undoable from t3ctl — check with ls -t first.
Every prompt you typed in a window, across every registered host, with the project each one happened in. Read-only, and built for other tools to consume rather than for reading yourself — time trackers, activity logs, weeknotes.
t3ctl export prompts --since 2026-09-14 --until 2026-09-1529 prompts 2026-09-14T00:00:00.000Z -> 2026-09-15T00:00:00.000Z
agentbox 29 local state.sqlite
@clients/dsl 28
t3ctl 1
| Option | |
|---|---|
--since <date> |
Required. Start of the window, inclusive. |
--until <date> |
End of the window, exclusive. Defaults to now. |
--host <name> |
Just this host. Defaults to all of them. |
--watch <path> |
Only projects under this root. Repeatable. Defaults to ~/Code. |
--json |
The full records instead of the summary. |
A bare YYYY-MM-DD is a UTC day boundary, not local midnight — the stored
timestamps are UTC and an export should mean the same window wherever it runs.
Pass a full ISO instant (2026-09-14T09:00:00+02:00) when you want a different
edge. The window is half-open: [since, until).
--watch is also the filter. A prompt in a project outside every watched root
is not exported, because it has no marker to file it under.
With --json:
{
"messages": [
{
"host": "agentbox",
"threadId": "f54ddbd8-92e1-402b-b228-a45e948e2f08",
"messageId": "8e7ea607-39db-4572-9373-92d3ac764bc7",
"createdAt": "2026-09-14T10:32:47.421Z",
"text": "which data is being fetched from /api/data?",
"workspaceRoot": "/home/agent/Code/@clients/dsl",
"marker": "@clients/dsl"
}
],
"unreachable": []
}text is the prompt on one line: <user_query> wrappers removed, whitespace
collapsed. marker is the project path relative to the watched root it sits
under — prefixed with the host name for every host but the local one, since two
machines routinely hold the same repo at the same path.
A host that can't be read lands in unreachable and the rest still return, so
one asleep laptop doesn't cost you the export. The exit code is non-zero only
when every host failed.
Same rows either way; only the cost differs.
| Host | How | Cost |
|---|---|---|
| Origin on loopback | Reads ~/.t3/userdata/state.sqlite directly |
One query |
| Anything else | Snapshot, then one fetch per thread that could match | N+1 requests |
The snapshot is filtered by each thread's updatedAt before anything is
fetched, so a host with hundreds of idle threads still only requests the ones
active in the window. The database is in WAL mode, so reading it alongside a
running T3 Code is safe and needs no copy — which matters, because it runs to
hundreds of megabytes.
You rarely need to paste a UUID.
Projects resolve by id, then exact title, then workspace root (~ expands,
relative paths are resolved against your current directory).
Threads resolve by id, then exact title, then a unique case-insensitive substring of the title. Ambiguous substrings are listed rather than guessed:
error "readme" matches 3 threads:
0f5c1e2a-... rewrite the readme for users
7b31d004-... readme screenshots
c9e0a115-... fix readme badge
Deleted threads are never resolution candidates.
Write commands act on one host. With a single host registered, that one is
implied. With more than one, pass --host:
t3ctl thread send --host desktop "api rate limits" pick this back upOtherwise you get multiple hosts; pass --host <laptop|desktop>.
Each project line ends with a tally like ●1 ◆1 ·3 — one icon per status, with a
count. With -t, each thread line starts with its own icon.
| Icon | Status | What it means |
|---|---|---|
● green |
running |
A turn is in flight right now. The agent is working. |
✕ red |
error |
The session or its most recent turn failed. Needs you. |
◆ yellow |
needs-review |
The agent produced a plan and is waiting for you to approve it. |
☾ grey |
snoozed |
Hidden on purpose until a wake time (set in the app) passes. |
✓ grey |
settled |
You marked it done. It stays settled until new activity un-settles it. |
· grey |
idle |
Alive, nothing running, nothing waiting on you. Freshly created threads land here. |
▪ grey |
archived |
Archived. Hidden unless you pass -a. |
✗ grey |
deleted |
Deleted. Hidden unless you pass -a. |
The two worth acting on are ✕ and ◆: red means something broke, yellow means an
agent is blocked waiting for your approval. ● is just work in progress.
One status per thread, most urgent first — a thread that is both running and
settled shows as running.
t3ctl only ever stores an origin string, so any transport that gives a host a
reachable URL works. There's nothing to configure beyond host add.
- SSH login —
t3ctl host add you@boxdoes everything: server install if needed, token minting, and a managed tunnel (see its section above). Good for hosts you don't want exposed at all. - Tailscale — on the host,
npx t3 serve --tailscale-servepublishes it athttps://machine.tailnet.ts.net/. Register that URL. - LAN —
npx t3 serve --host 0.0.0.0(or a specific interface), then registerhttp://192.168.1.x:3773. Read the URLt3 serveprints; it picks another port if the default is taken. - Manual SSH port-forward —
ssh -N -L 3773:localhost:3773 you@boxin a terminal you keep open, then registerhttp://localhost:3773. Thehost add you@boxform automates exactly this and keeps the forward alive for you.
One token per host. Tokens are issued by the server they belong to, so run
npx t3 auth session issue --label t3ctl --ttl 30d --token-only on each machine
and give each host its own short name:
t3ctl host add http://localhost:3773 eyJ2Ijox...
t3ctl host add https://studio.tailnet-1234.ts.net eyJ2Ijox...
t3ctl host add http://10.0.0.42:3773 eyJ2Ijox...
t3ctl host add you@elsewhere # token minted for you
t3ctl ls -tls then fans out to all of them at once. Machines that are asleep or offline
show up as unreachable and don't block the rest.
T3 Code's own T3 Connect relay (what the mobile app uses when you're off your
tailnet) is not planned: the relay's dpop-token exchange only accepts a
Clerk session JWT carrying the relay audience, and its allowed scopes are keyed
by client_id, which is pinned to t3-mobile and t3-web. A third-party CLI has
no way to present either. The transports above are the options.
Worth knowing before you build a workflow on this:
- Unofficial. Not affiliated with or supported by T3 Tools. Written against T3 Code Nightly's HTTP API, which is not a documented public API — endpoints and payloads can change without warning and a T3 Code update may break t3ctl until it catches up.
- A host is only reachable while its T3 Code server is running. t3ctl can't
wake a machine or queue work for later. If the desktop app is closed and no
t3 serveis running, that host isunreachable— except an ssh-registered host, whose boot service keeps a server running (on macOS only while someone is logged in at that console). thread createdoesn't run anything. It leaves an idle thread with no messages — a state the desktop UI never produces. Follow it withthread start, or the thread just sits there.- Not everything the API supports is wired up. No
pin,unsettle,snooze/unsnooze, no live tailing of a running turn.export promptsreads your own prompts; nothing reads agent output.unpinexists withoutpinbecause only some of these share a payload shape — see CONTRIBUTING.md. lsfetches full snapshots. Fine interactively; too heavy to poll in a loop.- Tokens sit in plaintext in
~/.config/t3ctl/hosts.json. No keychain integration. Scope them with--ttland revoke when done.
Protocol notes, the command vocabulary, status-derivation rules, and the release process are in CONTRIBUTING.md.
MIT