Skip to content

Repository files navigation

t3ctl

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.

Quick start

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 ls
laptop http://localhost:3773

  t3ctl  ●1 ◆1 ·3  ~/Code/t3ctl
  api-gateway  ✓2 ·1  ~/Code/api-gateway

Add -t to expand threads:

t3ctl ls -t
laptop 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.

Commands

Run t3ctl with no arguments for the built-in summary.

t3ctl ls

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 threads

ls 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}]}.

t3ctl host add <origin> [token] [--name <name>]

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 serverVersion differs 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.

t3ctl host add <ssh-target> [--name <name>] [--ttl <duration>]

Register a host given only its ssh login — no URL, no token, no port:

t3ctl host add agent@goobles-agentbox

That one command bootstraps the machine end to end:

  1. Probes it over ssh for a running T3 Code server. The server records its port in ~/.t3/userdata/server-runtime.json on the remote, so the port is discovered, never assumed (it is 3773 or whatever free port the server fell back to).
  2. Installs the server if none is runningt3 service install, T3 Code's own per-user launchd/systemd service (no sudo; macOS and Linux). The exact t3 CLI version is resolved locally via npm (latest, falling back to nightly); override with --t3-version <version-or-tag>. The remote npm output streams past — a cold cache can download for a few minutes.
  3. Mints a token for yout3 auth session issue on the remote, labeled t3ctl:<name>, TTL 30d by default (--ttl changes it). The session id is printed along with the exact revoke command for that host.
  4. 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 <name>

t3ctl host rm desktop

t3ctl hosts

List 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.

t3ctl project create <title> <workspace-root>

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/t3ctl

Note: 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.

t3ctl thread create <project> <title>

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/readme

Flags:

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.

t3ctl thread send <thread> <message...>

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.md
started 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.

t3ctl thread rename <thread> <new title...>

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 descriptor

t3ctl thread retitle <thread> [--timeout <seconds>]

Ask 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.

t3ctl thread interrupt <thread>

Stop the turn that's currently running.

t3ctl thread interrupt "rewrite the readme"

t3ctl thread settle|archive|unarchive|unpin|delete <thread>

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.

t3ctl export prompts --since <date> [--until <date>]

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-15
29 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.

How each host is read

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.

Referring to projects and threads

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.

Choosing a host

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 up

Otherwise you get multiple hosts; pass --host <laptop|desktop>.

Reading ls output

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.

Several machines

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 logint3ctl host add you@box does 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-serve publishes it at https://machine.tailnet.ts.net/. Register that URL.
  • LANnpx t3 serve --host 0.0.0.0 (or a specific interface), then register http://192.168.1.x:3773. Read the URL t3 serve prints; it picks another port if the default is taken.
  • Manual SSH port-forwardssh -N -L 3773:localhost:3773 you@box in a terminal you keep open, then register http://localhost:3773. The host add you@box form 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 -t

ls 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.

Limitations

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 serve is running, that host is unreachable — except an ssh-registered host, whose boot service keeps a server running (on macOS only while someone is logged in at that console).
  • thread create doesn't run anything. It leaves an idle thread with no messages — a state the desktop UI never produces. Follow it with thread 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 prompts reads your own prompts; nothing reads agent output. unpin exists without pin because only some of these share a payload shape — see CONTRIBUTING.md.
  • ls fetches 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 --ttl and revoke when done.

Contributing

Protocol notes, the command vocabulary, status-derivation rules, and the release process are in CONTRIBUTING.md.

License

MIT

About

Controller CLI for T3 Code hosts — list and drive threads across machines

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages