npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gobius/t3ctl

v0.14.0

Published

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

Readme

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'

Each thread in --json carries pullRequests: the PRs linked to it in T3 Code, as { url, repository, number }, or [] when there are none. model is the model it runs on ({ instanceId, model, options? }) and runtimeMode its runtime mode, each null when the server doesn't say.

The JSON shape is {projects: [{host, id, title, workspaceRoot, threads: [{id, title, branch, status, provider, model, runtimeMode, updatedAt, pullRequests}]}], 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

Two servers on one machine

Nothing stops two T3 Code servers from running against the same ~/.t3 — for example the boot service on 3773 and a desktop-launched server on 3774. They share one database but each keeps its own in-memory view, so a thread created through one is invisible to the other, and the app then fails with Thread '…' does not exist for command '…'. Both report the same environmentId, so the descriptor can't tell them apart.

Before every write, t3ctl compares the host with the live server recorded in ~/.t3/userdata/server-runtime.json (the one that started last). If that is a different port serving the same environment, the write is refused and nothing is sent; ls and hosts print the same warning. Stop the stale server, or re-point the host — its token carries over, since both ports use one auth database:

t3ctl host add http://127.0.0.1:3774 --name agentbox

The check runs for loopback hosts, and for ssh hosts on writes (one extra ssh round-trip to read the remote runtime file).

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 running — t3 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 you — t3 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. When the running server is the desktop app's, the token comes from the app's own bundled CLI (its executable in node mode, ELECTRON_RUN_AS_NODE=1), so nothing is installed from npm and the CLI is exactly the running server's version.
  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 models [--host <name>] [--json]

What a new thread can run on, per host: every model of the providers T3 Code's own picker offers (installed, checked and ready), as the picker lists them (hidden models left out, custom ones from settings, in your order), favourites marked ★, with the options each takes (effort=low|medium|high), then each project's default model, runtime mode and workspace (local or worktree), picked as T3 Code picks them for a new thread. Hosts that don't answer are reported, like ls; as a read, it only warns about a newer server beside a host.

t3ctl models
t3ctl models --json --host laptop

With --json, hosts[].providers[].models[].options are T3's option descriptors as the server sends them, and hosts[].projects[] gives each project's model (a model selection), runtimeMode, threadEnvMode and providers: the instance ids it may use, which a project can narrow or widen in its settings. threadEnvMode is worktree when T3 would start the thread in a new worktree and local when in the project's checkout: the project's setting, else the host's, else the repository's t3.json, else local (a project made from just a name stays local until it has a thread). Reading t3.json costs one more call per project, made only when no setting decides. hosts[].environment is the same for a project without settings of its own. hosts[].favorites lists the models starred in T3 Code ({ instanceId, model }, from the server's settings), only those still offered, in the order of T3's Favourites tab: by provider, then in each provider's model order. hosts[].providers (and the providers of projects and environment) come in the order of T3's provider rail: grouped by driver, each driver's default instance first. Each provider's models come in its picker's order, with favourites left where they are rather than grouped first. Pass the picks on with thread create --model --option --runtime-mode, and --new-worktree for worktree.

t3ctl project create <title> <workspace-root>

Register an existing directory on the host as a project. For a local host the path is resolved here (~ expands) and must already exist — t3ctl will not create it.

t3ctl project create t3ctl ~/Code/t3ctl

For a remote host (another machine's origin, or one reached over ssh) the path is that machine's: pass it as an absolute path, not ~/…, which would name this machine's home. t3ctl sends it as given and the host's server checks it.

t3ctl project create t3ctl /Users/me/Code/t3ctl --host mac

t3ctl project branches <project> [--json]

The project's git branches on its host, as T3 Code's branch pickers list them: local branches, then remote-tracking ones, leaving out each origin/<x> that has a local <x>. Within each, the checked-out and default branch come first, then the most recently committed. * marks the checked-out branch.

t3ctl project branches t3ctl
* fix/flaky-test  /Users/you/.t3/worktrees/t3ctl/fix-flaky-test
  main            default
  old-idea
  origin/release  remote

With --json:

{
  "project": { "id": "…", "title": "t3ctl" },
  "current": "fix/flaky-test",
  "default": "main",
  "branches": [
    { "name": "fix/flaky-test", "current": true, "default": false, "remote": false, "worktreePath": "/Users/you/.t3/worktrees/t3ctl/fix-flaky-test" },
    { "name": "main", "current": false, "default": true, "remote": false, "worktreePath": null }
  ]
}

current and default are null when there is none (default comes from origin/HEAD, and can be a remote one such as origin/main). Any name works as thread create --new-worktree --base <name>, as it does in the app.

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, or --new-worktree to create the thread in a fresh worktree and start it in one go.

t3ctl thread create t3ctl "rewrite the readme for users" --branch docs/readme

Flags:

| Flag | Default | Meaning | |---|---|---| | --model <instance>/<model> | the project's default model | Provider instance and model. Without it, the thread gets the model T3 Code would give it: the project's default, else the environment's, else claudeAgent/claude-opus-5-5 | | --option <id>=<value> | none | Model option, e.g. effort=high; repeatable | | --branch <name> | none | Git branch for the thread. With --new-worktree, the branch to create (default: a temporary t3code/<8 hex>) | | --worktree <path> | none | Existing worktree path | | --new-worktree | off | Have the server create a worktree and start the thread in it — see below. Needs --message | | --base <branch> | the repo's default branch | With --new-worktree: the branch to start from | | --message <text> | none | With --new-worktree: the first message, which starts the agent. Quote it — see below | | --runtime-mode <mode> | full-access | approval-required, auto-accept-edits, auto, full-access — see Runtime modes for the app's names | | --interaction-mode <mode> | default | default or plan | | --json | off | Print the new thread as JSON — see Scripting t3ctl | | --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.

--option sets one of the model's options, the same ones the app's model picker shows. The ids come from the provider, e.g. effort, fastMode and contextWindow for Claude. true and false are sent as booleans:

t3ctl thread create t3ctl "tidy the tests" --model claudeAgent/claude-opus-5-5 --option effort=medium --option fastMode=false

With --json, the summary is one JSON object instead. id is the new thread's id, which is what thread send takes next:

t3ctl thread create t3ctl "tidy the tests" --json
{
  "id": "0f5c1e2a-…",
  "title": "tidy the tests",
  "projectId": "8b1d0c44-…",
  "project": "t3ctl",
  "host": "laptop",
  "model": "claudeAgent/claude-opus-5-5",
  "sequence": 4471
}

model is the --model spelling, without --options. sequence is null for --new-worktree on a server speaking orchestration protocol 2, which answers the launch with the thread rather than a sequence.

Starting a thread in a new worktree

--new-worktree does what the app does when you send the first message of a new thread set to a new worktree. No need to run git worktree add first:

t3ctl thread create t3ctl "fix the flaky test" --new-worktree --message "find out why test/cli.test.mjs flakes on CI"
preparing t3code/3f9a1c07 from main in a new worktree on laptop — this can take a few minutes
started fix the flaky test in t3ctl on laptop
  id       0f5c1e2a-...
  branch   t3code/3f9a1c07 (from main)
  worktree /Users/you/.t3/worktrees/t3ctl/t3code-3f9a1c07
  model    claudeAgent/claude-opus-5-5
  mode     full-access / default
  seq      4471

The server fetches origin/<base>, adds the worktree under ~/.t3/worktrees/<repo>/ on the host, checks out submodules, runs the project's setup script, and then starts the agent on --message. A temporary t3code/<8 hex> branch is renamed after the conversation's first turn, just as in the app. Pass --branch to choose the name yourself.

Unlike thread send, the message is a flag, not the rest of the line, because the title already is. Quote it: --message fix the bug sends fix and makes the bug part of the title.

The base is the repo's default branch, whatever origin/HEAD points at. A repo whose remote was added by hand may not have origin/HEAD, and then t3ctl asks for --base rather than guessing (the app falls back to the checked-out branch). The base is fetched from origin first, and the local branch is used when origin has no such branch. The setup script runs. t3ctl waits until the agent has started, which includes a setup script that isn't marked async. That can take minutes, and t3ctl gives up after 15.

If the fetch or the checkout fails, the server deletes the thread it created, and t3ctl prints the server's reason and exits non-zero. A project that isn't a git repository, or a base with no commit, is an error. t3ctl will not quietly run the agent in the project checkout instead. A failing setup script is not an error: like the app, the server keeps the worktree, marks the setup failed on the thread, and starts the agent anyway. If the connection drops or t3ctl times out, the setup keeps going on the server; t3ctl ls -t shows where it got to.

t3ctl thread scratch <title>

Create a thread without a project, as the app's "No project" does. The server keeps a Scratch project (a plain folder under ~/.t3/scratch on the host, outside any repository) and gives each thread there a fresh folder of its own. The thread is idle unless --message starts it.

t3ctl thread scratch "look into the dns change" --host laptop
created look into the dns change without a project on laptop
  id     0f5c1e2a-...
  folder /Users/you/.t3/scratch/look-into-the-dns-change
  model  claudeAgent/claude-opus-5-5
  mode   full-access / default

It takes --model, --option, --message, --runtime-mode, --interaction-mode, --json and --host as thread create does. Without --model, the thread gets the Scratch project's default model, picked as for any project. With --json, project is No project and sequence is null. A host whose T3 Code data directory sits inside a git repository offers no Scratch project, and servers speaking orchestration protocol 1 have none.

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-5
  mode  full-access / default
  seq   4471

Accepts --model, --option, --runtime-mode (see Runtime modes), --interaction-mode, --host, and --json, which prints {"threadId", "host", "sequence"} instead of the summary. Unlike thread create, --model has no default here: the thread's existing model is reused unless you override it. --option changes options on top of the thread's model, or on top of --model when you pass one.

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 snooze <thread> <when> / unsnooze <thread>

Hide a thread until a wake time. Snoozed threads show as ☾ in ls and drop out of the app's active list until the time passes; unsnooze brings one back now.

t3ctl thread snooze "flaky release workflow" 3d
t3ctl thread snooze "review the api docs" monday
t3ctl thread snooze 0f5c1e2a-... 2026-10-05T07:00:00.000Z
t3ctl thread unsnooze "flaky release workflow"
snoozed flaky release workflow
  until 2026-10-05T07:00:00.000Z  (05/10/2026, 09:00:00 local)
  id    0f5c1e2a-...
  seq   4512

<when> accepts three forms, and nothing else:

| Form | Example | Means | |---|---|---| | ISO time | 2026-10-05T07:00:00.000Z | Exactly that instant. A time with no zone is read as local. | | Duration from now | 45m, 2h, 3d, 1w | Minutes, hours, days, weeks. | | Named day | tomorrow, monday…sunday, next-week | 09:00 local on that day. |

Named days are always strictly ahead: monday on a Monday means the next Monday, never this morning. next-week is the coming Monday. A wake time in the past is rejected rather than sent — the server accepts one but it does nothing.

Waking is not only manual: the thread stops counting as snoozed once the time passes, and activity in the thread wakes it server-side. unsnooze is for when you want it back before either happens.

t3ctl thread runtime-mode <thread> <mode>

Change a thread's runtime mode — what the app calls Access — without sending a message. Aliased as thread mode.

t3ctl thread runtime-mode "rewrite the readme" supervised
t3ctl thread mode 0f5c1e2a-... full
rewrite the readme
  mode full-access -> approval-required (Supervised)
  id   0f5c1e2a-...
  seq  4519

thread send --runtime-mode also changes the mode, but only as part of running a turn. This is the standalone switch, for setting a thread to supervised and leaving it there.

Runtime modes

Four values, with the names the T3 Code app shows. Both spellings are accepted everywhere a mode is — here, and in --runtime-mode on thread create and thread send:

| Wire value | App name | Also accepted as | What it allows | |---|---|---|---| | approval-required | Supervised | supervised | Asks before commands and file changes. | | auto-accept-edits | Auto-accept edits | — | Auto-approves edits, asks before other actions. | | auto | Auto | — | Supported providers approve routine actions; others still ask. | | full-access | Full access | full | Commands and edits without prompts. |

full-access is the default, for threads and for t3ctl.

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 database
  @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, not over ssh | Reads ~/.t3/userdata/statev2.sqlite directly (state.sqlite on a server from before Orchestrator V2) | 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 passes. Set one with thread snooze. | | ✓ 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 login — t3ctl 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.
  • LAN — npx 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-forward — ssh -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.

Scripting t3ctl

ls, models, project branches, export prompts, thread create and thread send take --json. With it, stdout carries only the JSON, and an error is one line on stderr, with the exit code still non-zero:

{"error":"no hosts registered — run: t3ctl host add <origin> <token>"}

That holds for argument errors too (missing required argument 'thread', unknown option '--bogus'). Warnings, such as two servers sharing a data dir, can still precede it on stderr, so read the last line.

Colour is only used on a terminal: output piped to a file or another program is plain text, with or without --json. Set NO_COLOR to anything to turn it off on a terminal too.

A tool that starts a thread and runs it:

id=$(t3ctl thread create t3ctl "tidy the tests" --json | jq -r .id)
t3ctl thread send "$id" make the snooze tests independent of the clock --json

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. t3ctl speaks both orchestration protocols T3 Code has used (Orchestrator V2 and the one before it), and asks each server which it runs.
  • 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, no unsettle, no live tailing of a running turn, and --interaction-mode can only be set while starting a turn, unlike the runtime mode. 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