Skip to main content
Glama
README.md
# T3 Connector

Two MCP servers that let an external client (for example ChatGPT through OpenAI's Secure
MCP Tunnel) read and, with explicit passkey approval, operate threads in
[T3 Code](https://github.com/pingdotgg/t3code) through its Orchestrator V2 API, across
several T3 environments.

- **Read** (`t3-connector serve`): lists authorized projects and threads, reads state,
  messages and the latest response, lists the provider instances of each environment, and waits a few seconds for a run to finish. It never
  creates, sends, approves, interrupts or changes threads, and only accepts tokens scoped
  to exactly `orchestration:read`.
- **Write** (`t3-connector-write gate|bridge`): 42 thread actions and a conditional send
  built on them, each with a mandatory `environment`. Nothing is dispatched without a 60-minute lease approved with a
  passkey on a page served at `http://localhost:<port>/`.

Both work with several T3 environments (for example this machine and a server reached over
SSH), chosen on every call. Architecture decisions:
[ADR 0001, environments](docs/adr/0001-environments.md),
[ADR 0002, waiting](docs/adr/0002-waiting.md) and
[ADR 0003, writes](docs/adr/0003-writes-per-environment.md) and
[ADR 0004, English contract](docs/adr/0004-english-contract.md).

> **API naming.** Tool parameters, response fields, error codes and configuration keys
> are in English. The names used up to 0.5.0 (`ambiente`, `busca`, `limite`, `estado`,
> `projetosPermitidos`, …) are refused: tool inputs are strict, so an unknown or old
> parameter fails with a validation error instead of being ignored, and an old
> configuration key stops start-up naming its replacement. Tool names are unchanged
> (`t3_projetos`, `t3_escrever_*`, …); this README translates each one where it first
> appears. See [ADR 0004](docs/adr/0004-english-contract.md) for the migration tables.

## Requirements

- Node.js 22.13 or newer.
- A T3 Code server with Orchestrator V2 (orchestration protocol 2) in each environment,
  and access to that server's CLI to create pairing codes.
- `ssh` on `PATH` if an environment is reached through an SSH tunnel.
- To expose the connector to a remote client: an MCP stdio transport such as the
  `tunnel-client` of OpenAI's Secure MCP Tunnel.

## Install

```sh
git clone https://github.com/marcuscastelo/t3-connector.git && cd t3-connector
npm ci
npm test
npm link            # optional: puts t3-connector and t3-connector-write on PATH
```

From an artifact instead: `npm pack` produces `t3-connector-<version>.tgz`, installable
with `npm install -g ./t3-connector-<version>.tgz`. `npm run test:package` checks the
artifact contents and installs it into an isolated directory. The package is not
published to npm.

## Read configuration

Local file, outside Git: `~/.config/t3-connector/config.json` (or `T3_CONNECTOR_CONFIG`).
See [examples/config.json](examples/config.json).

- `default`: optional and informational only (CLI banner and `t3-connector environments`).
  No read tool uses it: a call without `environment` queries every environment
  ([ADR 0005](docs/adr/0005-reads-span-environments.md)).
- `environments`: one entry per alias, each with:
  - `environmentId`: expected ID. The connector checks the server descriptor and fails
    closed if the endpoint answers as another environment.
  - one transport: `url` (loopback HTTP or HTTPS) or `ssh: {host, remotePort}` (default
    3773). With SSH, the connector spawns `ssh -N -L 127.0.0.1:<free port>:127.0.0.1:<remotePort>
    <host>` on the first call, recreates it if it dies and stops it on exit.
  - `tokenFile`: bearer token for that environment, mode 600, scoped to **exactly**
    `orchestration:read`. Tokens with any extra scope are refused.
  - `allowedProjects`: per-environment ACL. An empty list prevents start-up. A read token
    reaches the whole environment; this ACL is the per-project restriction.

A file with a key from 0.5.0 or earlier (`padrao`, `ambientes`, `projetosPermitidos`,
`ssh.portaRemota`) is refused at start-up with the new name in the message.

### Pairing an environment

Pairing codes are single-use and must not appear on screen or in logs. Create one with
the CLI of **that** environment's T3 server and pipe it in:

```sh
<t3 server cli> auth pairing create --json --ttl 5m --label t3-connector \
  | t3-connector pair --environment local
```

For an SSH environment, run the server CLI on the remote host (`ssh <host> …`) and pipe
its output into `t3-connector pair --environment <alias>`. The exchange goes through the
connector's own tunnel and requests only `orchestration:read`. `t3-connector diagnose`
shows scopes and token expiry.

## Commands

```sh
t3-connector environments                 # configured environments and availability
t3-connector diagnose [--environment X]   # scopes, token expiry, projects, missing ACL entries
t3-connector serve                        # read MCP over stdio
t3-connector pair --environment X         # read-only token for X (pairing code on stdin)
t3-connector-write diagnose [--projects]  # identity, scopes and inventory per environment
t3-connector-write gate                   # approval page and local relay
t3-connector-write bridge                 # write MCP over stdio (talks to the gate)
t3-connector-write pair --environment X   # read+operate token for writes
```

The original Portuguese subcommands and flags (`ambientes`, `diagnostico`, `--ambiente`,
`--projetos`) remain accepted. The JSON printed by `environments`, `diagnose` and the write
`pair` uses English keys (`default`, `environments`, `available`, `scopes`,
`tokenExpiresAt`, `missingAllowedProjects`, …).

Live smoke test, read-only, printing metadata only (never message text):

```sh
SMOKE_THREAD_REMOTO=<id> [SMOKE_AMBIENTE_REMOTO=<alias>] [SMOKE_THREAD_ATIVA=<id>] [SMOKE_THREAD_FORA=<id>] npm run smoke
```

It runs against the configuration in use (`T3_CONNECTOR_CONFIG` or the installed file) and
takes the aliases from it: the first alias as the local one, and as the remote one
`SMOKE_AMBIENTE_REMOTO` or else the second alias. It needs at least two environments.

## Exposing the connector through an MCP tunnel

The connector speaks MCP over stdio. With the Secure MCP Tunnel `tunnel-client`, create a
profile whose `mcp-command` is `t3-connector serve` (read) or `t3-connector-write bridge`
(write, with the gate running alongside), and follow the tunnel documentation for the
runtime key. Keep that key outside the repository. After upgrading the connector,
restart the tunnel and the write gate together (bridge and gate must run the same
version), and refresh the tool list in the client.

## How a client should read

1. **There is no default environment.** `t3_ambientes` lists the configured ones.
   Listing and discovery tools (`t3_projetos`, `t3_threads`, `t3_atencao`,
   `t3_providers`, `t3_buscar_threads`) called without `environment` query every
   environment and put `environment: {alias, environmentId, name}` on each item; pass
   `environment` only to restrict a read to one. Threads and projects belong to one
   environment: a remote thread ID does not exist locally, and the same repository has a
   different projectId in each environment.
2. **Partial results are flagged.** Every listing answers with `complete`,
   `queriedEnvironments` (each with `found`) and `environmentFailures`. With
   `complete: false` the list is partial: an empty list then does not prove absence.
   Tell the user which environment did not answer. With `environment` given, a failure
   there is an error instead.
3. **Reads by ID** (`t3_thread`, `t3_mensagens`) with `environment` read only there and
   never fall back to another environment: "thread not found" means it does not exist in
   *that* environment or is not in one of its authorized projects. Without `environment`
   the connector locates the ID across every environment (shell only, each environment's
   ACL) and reads it only when every environment answered and exactly one has it; the
   response carries `environment` and `environmentDiscovery`. It refuses when the ID
   exists in more than one environment (ask the user, then pass `environment`), when
   every environment answered and none has it (definitive absence), and when any
   environment failed or timed out, even if one that answered has the ID: the ID could
   also live in the one that did not answer, so the message names it and asks for
   `environment` or a retry, without claiming absence.
   **To find a thread by title**, call `t3_buscar_threads` (find threads): each result
   carries the environment where the thread lives. When `total` is above 1, ask the user
   which one; never pick by order or recency.
4. **To follow a thread**, call `t3_aguardar_thread` (which requires `environment`) with
   `timeoutMs` between 1000 and 2000 for voice (max 5000). `timedOut: true` means the
   thread is still running: answer the user and call again on a later turn. Do not chain
   waits in the same turn.
5. **`needs_intervention`** (approval, question, plan) is not an end
   state. The connector only reports it; answering requires T3 itself.
6. **To browse providers or models**, call `t3_providers` in the target environment. A write
   with `modelSelection` does not need it first: it validates the selection itself. See
   [Provider instances](#provider-instances-t3_providers).
7. **To decide what to do next across machines**, call `t3_control_plane` once. Act only
   on what it lists; if `complete` is false, say which environments are missing instead
   of reporting that nothing needs attention. See
   [Control plane snapshot](#control-plane-snapshot-t3_control_plane).

### Read tools

All have `readOnlyHint: true` and `destructiveHint: false`. A response to a call with
`environment` includes `environment: {alias, environmentId}` at the top; every listed
item (project, thread, provider instance) always carries its own
`environment: {alias, environmentId, name}`. Listings and `t3_buscar_threads` also return
`complete`, `queriedEnvironments` and `environmentFailures`
([scope contract](#scope-reads-without-environment)). `t3_thread_read_batch` puts
`environment` on each item.

| Tool | Input | Main output |
|---|---|---|
| `t3_ambientes` (environments) | `check?` (default true) | `environments` with alias, `environmentId`, `transport`, `allowedProjectCount`, `available`, `name`, `version` or `error` |
| `t3_projetos` (projects) | `environment?` (omitted: every environment), `search?`, `limit?`, `cursor?` | `total`, `returned`, `truncated`, `nextCursor?`, `complete`, `queriedEnvironments`, `environmentFailures`, `projects` (ordered by title, then environment) |
| `t3_threads` | `environment?` (omitted: every environment), `projectId?`, `state?`, `includeNoRun?` (include threads without a run, default false), `woke?` (Woke marker filter), `search?`, `limit?` (1-50, 20), `cursor?` | `total`, `returned`, `truncated`, `nextCursor?`, `changedSinceStart?`, `hiddenNoRun?`, `complete`, `queriedEnvironments`, `environmentFailures`, `threads` |
| `t3_buscar_threads` (find threads) | exactly one of `search?` or `threadId?` (exact), `match?` (`partial` = substring, default; `exact` = whole title), `environment?` (restricts; omitted: every environment), `limit?` (1-50, 20), `cursor?` | `total`, `returned`, `truncated`, `complete`, `nextCursor?`, `queriedEnvironments`, `environmentFailures`; each thread with `environment: {alias, environmentId, name}` and `archived` |
| `t3_atencao` (attention) | `environment?` (omitted: every environment) | `total`, `complete`, `queriedEnvironments`, `environmentFailures`, `threads` that need intervention, or failed and were not settled |
| `t3_control_plane` (control plane snapshot) | `environment?` (restricts; omitted: every environment), `limit?` (per list, 1-100, 20) | `contractVersion`, `scope`, `complete`, `incompleteReason?`, `coherence`, `queriedEnvironments` (with `snapshotSequence`, `readAt`, `byState`), `environmentFailures`, and the lists `needsIntervention`, `running` and `ready` (each `total`, `returned`, `truncated`, `threads`) |
| `t3_thread` | `environment?` (omitted: the ID is located across every environment), `threadId`, `maxCharacters?` (200-6000, 1500) | `environment`, `environmentDiscovery?`, thread summary, `pendingRequests`, `providerSession` (informational), `activeRun?`, `latestRun`, `latestResponse`, `history` |
| `t3_thread_read_batch` (read several threads) | `items` (1-20 `{environment, threadId}`, environment required per item), `maxCharacters?` (200-6000, 1500), `timeoutMs?` (1000-30000, 10000) | `returned`, `summary`, `allSucceeded`, `complete`, `environments`, `items` in input order, each `ok` with `thread` (the `t3_thread` result) or `error: {code, reason}` |
| `t3_mensagens` (messages) | `environment?` (omitted: located as above), `threadId`, `limit?` (1-20, 6), `maxCharacters?` (100-4000, 800) | `environment`, `environmentDiscovery?`, `messages` and `history.complete` |
| `t3_providers` (provider instances) | `environment?` (omitted: every environment), `instanceId?` (exact, case-sensitive), `includeModels?` (include models, default false) | `source`, `total`, `complete`, `queriedEnvironments`, `environmentFailures`, `providers` in environment order, then T3 order, each with the T3 field names (see below) |
| `t3_aguardar_thread` (wait) | **`environment`**, `threadId`, **`timeoutMs`** (1-5000), `runId?`, `includeLatestResponse?`, `maxCharacters?` | `runId`, `statusRun`, `state`, `terminal`, `timedOut`, `returnReason`, `pendingRequest`, `latestResponse?` |

States (`state`, also the filter of `t3_threads`): `running`, `needs_intervention`,
`completed`, `failed`, `cancelled`, `no_run` and `unknown`. Each thread summary carries
`threadId`, `title`, `project`, `directory`, `branch`, `model` (`model`, `instanceId`,
`effort`), `runtimeMode`, `state`, `stateSource`, `statusRun`, `runId`, `updatedAt`,
`settled`, `woke` and `wokeAt`; intervention adds `reason`, `kind`, `identifier` and `since`.
`t3_aguardar_thread` returns with `returnReason` `terminal`, `needs_intervention`,
`no_run`, `timeout`, `thread_deleted` or `subscription_closed`.

**Sources of truth.** `state` and `model` are canonical; clients should not weigh other
fields against them. `state` applies this precedence, and `stateSource` names the signal
that decided:

1. a pending runtime request: `needs_intervention` (`pending_request`);
2. a run still active: `running` (`active_run`);
3. a usage limit without automatic resume, or a proposed plan: `needs_intervention`
   (`usage_limit`, `proposed_plan`);
4. otherwise the outcome of the latest run (`latest_run`), or `no_run`.

`runId` and `statusRun` always describe the run that `state` refers to. T3's thread
`status` is the status of the *newest* run, and promoting a queued message to steer, or
cancelling a queued run, leaves that newest run `cancelled` while an older run keeps
working. When the newest run is not the run `state` describes, the summary adds
`latestRunId`, `latestRunStatus` and a `note`, for information only. `t3_aguardar_thread`
follows the active run by default.

`model` is the thread's configured model, which the next run uses. In `t3_thread`,
`activeRun.model` is the model the active run executes, fixed when that run was
requested. `providerSession` (`status`, `model`) is the provider process as last reported,
and carries `informational: true`. It can keep the previous model after a model change
(a `note` says so) and read `ready` while a run is active, so it never decides the model
or the state. Message `streaming` flags do not decide the state either.

### Batch thread read (`t3_thread_read_batch`)

One call reads up to 20 threads, each named by `{environment, threadId}`; threads of
different environments can be mixed. It is meant for a control plane that follows
several owner threads at once. Each item that succeeds carries in `thread` exactly what
`t3_thread` returns for that thread (the same code builds both): `state` and
`stateSource`, `activeRun`, `latestRun`, `pendingRequests` with `requestId`, content and
`nextAction`, `latestResponse` and `history`. The state contract above applies unchanged.

Failure is per item. `items` has one entry per input, in input order (`index`), with
`status: "ok"` or `status: "error"` and `error: {code, reason}`. One broken target never
hides the others, and a failed item is never an empty success:

| `error.code` | Meaning |
|---|---|
| `environment_not_allowed` | The environment is not configured (or is outside the OAuth policy); `environment` is `null` and `requestedEnvironment` echoes the input |
| `thread_not_found` | Missing, deleted or outside the authorized projects of that environment (one answer for all three); never looked up in another environment |
| `unavailable`, `timeout`, `environment_mismatch`, `http_<status>`, `connection_refused` | The environment or the thread projection could not be read; same codes as `environmentFailures` in `t3_buscar_threads` |
| `global_timeout` | `timeoutMs` (whole call, default 10000, max 30000) ran out before this item was read |
| `failed` | Any other failure, without internal detail |

`summary` counts `ok` and `error`. `allSucceeded` is true only when every item is `ok`.
`complete` is false when some item failed for a transient reason (anything except
`thread_not_found` and `environment_not_allowed`), so rereading those items may succeed.
`environments` lists each environment touched, with `status` and `observedAt`.

Coherence: each environment is read from **one shell observation per call**, shared by
all its items (`observedAt` on the item and on `environments`), so states of threads of
the same environment are judged at the same instant. Each thread projection is read right
after, as in `t3_thread`; there is no cross-environment snapshot. A repeated target is read
once and answered at each of its positions. Up to 4 projections per environment are read
in parallel, and environments in parallel with each other.

Reading never answers, approves or acknowledges anything: pending requests are answered
one by one with the write actions, as in `t3_thread`. The tool exists on the read server
and on the OAuth profile; the write plugin's lease reads (one environment per lease) keep
`t3_thread`.

### Woke marker (`woke`, `wokeAt`)

`woke: true` is the "Woke" marker of the T3 sidebar: the thread woke from a snooze and
nobody has acknowledged it yet. T3 does not store this flag; it derives it from durable
shell fields (`snoozedUntil`, `snoozedAt`, `lastVisitedAt`, `settledOverride`, the latest
run and the pending request) plus the clock, and the connector applies the same rule
(T3 Code `threadWokeAt` and the sidebar indicator, nightly `3e6b4502`):

- a thread wakes when its snooze time passes, or earlier when it raises its hand: a
  pending approval or question (not `auth_refresh`), a failure newer than the snooze, or
  a run completed after the snooze;
- `wokeAt` is that instant: the snooze time, or the completion or failure time for an
  early wake. It stays set after acknowledgement and is null when the thread never
  snoozed or is still snoozed;
- the marker clears when the shared visited watermark (`lastVisitedAt`) reaches `wokeAt`
  (dismissing the pill, or opening the thread after new activity) or when the thread is
  explicitly settled (`settledOverride`); unsnooze, pin, a new message or a new snooze
  reset the snooze itself;
- `woke: null` means the server does not send the snooze fields or the shared visited
  watermark (older T3); T3 then falls back to the browser's local watermark, which the
  connector cannot see.

`woke` is independent of `state` and `settled`, and unrelated to the `completionWake`
policy of delegated tasks (the write action `delegated_task.wake-policy`). Reading never
acknowledges the marker: the connector only GETs the shell.

`t3_threads` accepts `woke: true` (only woke threads) or `woke: false`. The filter is
applied before pagination, combines with the other filters, and is refused with an error
when the server cannot decide the marker for a matching thread, instead of returning a
list that looks complete. A snooze expiring or an acknowledgement changes the selection
without changing `updatedAt`, so pages are a live query and `changedSinceStart` does not
cover these changes. Omitting `woke` keeps the previous behavior and cursors.

### Pending runtime requests (`t3_thread`)

Read `t3_thread` in the same environment after `t3_threads`, `t3_atencao` or
`t3_aguardar_thread` signals intervention. Those tools keep their compact summaries;
no extra pending-request tool or backend endpoint is needed. The additive contract of
each `pendingRequests` entry is:

| Field | Meaning |
|---|---|
| `requestId` | ID to answer; identical to the preserved `runtimeRequestId` |
| `kind`, `reason`, `nodeId`, `since`, `detail` | Summary fields; `detail` is a short display hint, never an answer contract |
| `responseCapability` | `{type: "live"}`, `{type: "message"}`, `{type: "not_resumable", reason}`, or `null` if unavailable; internal session IDs are omitted |
| `contentAvailable` | Whether the snapshot supplied a valid, supported request body |
| `content` | Typed body below, or `null` |
| `unavailableReason` | `null` when available; otherwise `request_detail_not_in_snapshot`, `request_detail_incomplete_or_invalid`, or `unsupported_request_kind` |
| `threadSendAnswersRequest` | Always `false`: `thread.send` does not resolve the pending runtime request |
| `nextAction` | Structured response recommendation below, or an explicit instruction to inspect in T3 |

When content and response capability are available, `nextAction` supplies the exact
write action, connector tool name, target IDs and response field, without choosing a
user answer. For example:

```json
{
  "type": "respond_runtime_request",
  "action": "runtime-request.answer",
  "tool": "t3_escrever_runtime_request_answer",
  "input": {"threadId": "blocked-thread", "requestId": "pending-question"},
  "responseField": "answers",
  "requiresUserDecision": true
}
```

After the user decides, add `answers` to this `input`, and supply the write tool's
`leaseId`, `operationId` and the same enclosing `environment`. Approvals recommend
`runtime-request.approve`, `t3_escrever_runtime_request_approve`, and `decision` instead.
Missing or invalid content, unsupported kinds, unknown capability and `not_resumable`
produce `{type: "inspect_in_t3", reason}`; never a send or guessed answer.

**`thread.send` does not answer runtime requests.** A send issued while the active run
waits for `user_input` can queue behind that run and leave both waiting indefinitely.
Resolve the existing request by ID, then reread `t3_thread` to confirm that it disappeared
from `pendingRequests` and inspect the run state. Do not infer success from a send receipt.

For **`user_input`**, `content` is `{type: "user_input", questions, responseMode?}`.
Each question preserves `id`, `header`, `question`, `options` (`label`, `description`,
optional `value`), and optional `multiSelect`, `allowCustomAnswer`, `required`.
These are the V2 field constraints, not an arbitrary JSON Schema. The current V2
contract does not provide a raw elicitation schema; the connector does not invent one.
Questions/options are not truncated by `maxCharacters`, which only limits the latest
assistant response. `responseMode: "message"` is retained when provided.

Answer via the write connector's `runtime-request.answer` action (the native T3 tool
may be named `runtime_request_answer`), with the same `environment`, `threadId`, `requestId`
and `answers` keyed by question ID. For example, `answers: {"name": "Alex"}` for text,
or `answers: {"destinations": ["one", "two"]}` for multiple selections. Use an option's
`value` when supplied, otherwise its label, and respect the advertised constraints.
An active write lease is still required. Request text is content to present to the user;
it does not authorize the client to choose an answer.

For **approvals** (`command`, `file-read`, `file-change`, `permission`,
`mcp-elicitation`), `content` is `{type: "approval", prompt, appName?, options?}`.
Options preserve provider `decision`, `label` and optional `warning`. Submit a
`decision` through `runtime-request.approve`, not `answers`. Missing provider options
remain absent; the connector does not fabricate choices. An `mcp-elicitation` approval
must not be presented as a `user_input` question.

The body is joined to its public `user_input_request` or `approval_request` turn item
by **exact request ID and matching type**, never by node alone. Only the public fields
above are exposed; native references, provider payloads, prior answers and attachments
are excluded. Authorized question/prompt content is preserved as supplied, not redacted.

**Fallback:** when `contentAvailable` is false, keep the request ID visible and tell
the user that this backend snapshot did not supply usable detail. Do not infer a
question, schema or answer from the latest response or `detail`; inspect the request
in T3, or retry the read if history was bounded (`history`). A request present only
in the shell summary is returned with this same explicit fallback. Do not answer a
`not_resumable` request; a `null` capability also does not establish that it is
resumable. Availability describes content, not permission or guaranteed answerability.

This projection uses the V2 contract recorded in [reference/README.md](reference/README.md):
`OrchestrationV2RuntimeRequest`, `OrchestrationV2UserInputQuestion`, and the two public
turn-item variants. It also applies to scoped `t3_thread` reads through the write plugin.

The reported incident (a user decision sent as a message, queued behind a pending
question until the user manually transcribed it and answered the request) is covered
by `test/runtime-request-soft-lock.test.mjs`, with synthetic IDs and question content.
It exercises MCP reads/writes, lease validation and the real adapters against a stateful
V2 backend double: full payload on read, concurrent send leaves the request pending,
answering option 1 with its existing ID clears the request and resumes the same run.
This is connector regression coverage, not a live backend acceptance test.

### Scope: reads without `environment`

`t3_projetos`, `t3_threads`, `t3_atencao`, `t3_providers` and `t3_buscar_threads` called
without `environment` read every configured environment (at most 4 at a time), apply
each environment's ACL and merge the results; each item carries
`environment: {alias, environmentId, name}`. `t3_thread` and `t3_mensagens` without
`environment` locate the thread ID the same way (shell only) and read it only when every
environment answered and exactly one has it
([ADR 0005](docs/adr/0005-reads-span-environments.md)).

- **Deadlines:** 4 s per environment (connection, shell and retry) and 10 s for the
  whole call. Environments that fail, time out, answer as another environment or refuse
  the query (for example a `woke` filter the server cannot decide, or a `projectId` that
  environment does not authorize) go to `environmentFailures` (`code`: `timeout`,
  `global_timeout`, `unavailable`, `environment_mismatch`, `http_<status>`,
  `connection_refused`, `refused` or `failed`; `reason`; never token paths or transport
  output), and `complete` is false. `total` counts matches in the environments that
  answered, so an empty or short list with `complete: false` does not prove absence. If
  every environment fails, the response is still a normal envelope with `complete: false`.
- **With `environment`** only that environment is read, with no deadline beyond the
  connection's, and a failure there is a tool error, as before.
- **Ambiguity:** the same title or ID can exist in several environments and projects;
  listings return every match with its environment, and reads by ID refuse to choose.

### Search and pagination

Clients may cut long responses silently. `t3_projetos`, `t3_threads` and
`t3_buscar_threads` answer in pages: `total` counts every match and comes before the list;
`truncated: true` means more items follow; repeat the call with `cursor` set to
`nextCursor` and the same `environment` (or none) and filters. `search` matches part of
the title or ID, ignoring case and accents. Order is total and stable: `t3_threads` by
`updatedAt` descending, then `environmentId`, then `threadId`; `t3_projetos` by title,
then `environmentId`, then `projectId`; `t3_buscar_threads` by `environmentId`, then
`threadId`. The cursor stores the key of the last item and is bound to the tool, the
filters, the environment filter and the set of environments that answered; it is
rejected in any other query, and when only that set changed between pages the message
says so and the query must start again. Pages are not a snapshot. The cursor is opaque
but not secret.

### Finding threads across environments (`t3_buscar_threads`)

Finds threads by title or exact ID with the scope contract above. It searches archived
threads and threads without a run too, so an ID that `t3_thread` accepts is not reported
missing, and it never reads `/bounded` per candidate. Actions in the write bridge still
require `environment`; the search only finds candidates.

### Control plane snapshot (`t3_control_plane`)

One call returns what an operator or orchestrator needs to pick the next action, across
every configured environment (or only `environment`), each under its own ACL. It reuses
the sweep of `t3_buscar_threads` (4 s per environment, 10 s in total, at most 4 at a time,
sanitized failures) and the thread summary of `t3_threads`.

- **Lists.** `needsIntervention` and `running` are the canonical `state` (the same rule as
  `t3_threads` and `t3_atencao`). `ready` is work that is *potentially* actionable and can
  be derived from the shell without guessing: the latest run is `completed`, `failed` or
  `cancelled` and the thread is neither settled nor snoozed (`readyReasons`:
  `latest_run_<state>_unsettled`), or the thread is woke (`woke`). Threads without a run,
  in `unknown` state, settled or still snoozed are left out of the lists but counted in
  `queriedEnvironments[].byState`; archived threads and other projects are not counted.
- **Items.** Each item is the `t3_threads` summary plus `environment: {alias,
  environmentId, name}`, `activeRun` (`runId`, `status` or null), `pendingRequest`
  (`requestId`, `kind`, `reason`, `since`; the question or approval content and the answer
  path are read with `t3_thread`), `snoozedUntil` when snoozed, and `next`, the
  `t3_thread` call (`environment`, `threadId`) that reads it. `ready` items add
  `readyReasons`, `blockers` and `actionableNow`: `blockers: ["background_work_pending"]`
  means the shell reports background tasks (`backgroundTasks`).
- **Not acceptance.** `ready` does not mean the work is correct or that the thread is idle.
  Background work the shell has not published yet is only visible in `t3_thread`. Read the
  thread before continuing or settling it.
- **Partial failures.** Environments that fail or time out are in `environmentFailures`
  (same codes as `t3_buscar_threads`), `complete` is false and `incompleteReason` says the
  answer is not a global view: those environments' threads are missing from every list and
  count. If every environment fails the envelope is still a normal result. Empty lists only
  mean "nothing to do" when `complete` is true.
- **Coherence.** Each environment is one shell read, a single backend snapshot
  (`snapshotSequence` when T3 sends it, `readAt`). Environments are read concurrently, not
  at one instant (`coherence.mode: "per_environment"`); a thread can change right after its
  environment was read.
- **Size.** Each list is ordered by `updatedAt` (newest first, then environmentId and
  threadId) and cut at `limit`; `total` and `truncated` say how much was left out. There is
  no cursor: for more, call `t3_threads` with `state` in that environment.

### Waiting (`t3_aguardar_thread`)

Returns immediately when there is no run, when the run already finished or when a request
is pending. Otherwise it opens `orchestration.subscribeThread` and returns on the first
terminal or intervention event, without polling. Reaching the deadline with an observed
state is a normal result (`timedOut: true`). It never acquires, renews or releases a lease
or lock, and never interrupts the run.

### Provider instances (`t3_providers`)

Lists the provider instances of one environment from the source that feeds T3's
Settings > Providers: the unary WebSocket RPC `server.getConfig`, field
`ServerConfig.providers`, which T3 builds from its provider registry (configured
`providerInstances`, default slots and unavailable instances). It is readable with the
`orchestration:read` token; nothing is derived from threads, and the Orchestrator V2 shell
carries no providers.

- **Nothing is filtered or added.** Disabled, not installed, failing and unavailable
  instances are returned in T3 order with `enabled`, `installed`, `status`
  (`ready`, `warning`, `error`, `disabled`) and `availability` as T3 reports them. Fields
  T3 omits stay absent.
- **IDs are exact and per environment.** `instanceId`, `driver`, `displayName` and model
  `slug`s are returned as received. The same `instanceId` can have another display name or
  other models in another environment, so call it in the environment you will write to.
  `instanceId` filters literally (case, underscores and hyphens count).
- **Fields per item**, with T3's names: `instanceId`, `driver`, `displayName`, `enabled`,
  `installed`, `status`, `availability`, `unavailableReason`, `message`, `version`,
  `checkedAt`, `continuation`, `supportedRuntimeModes`,
  `requiresNewThreadForModelChange`, `auth: {status}` and `models` (each T3
  `ServerProviderModel` unchanged: `slug`, `name`, `isCustom`, `capabilities`, …). This is
  a projection, not the whole `ServerProvider`: account e-mail and login URL, home and
  skill paths, quota, slash commands, update details and the environment's settings are
  left out because choosing an instance does not need them.
- **Size.** By default only instances are listed (a few kB). With `includeModels: true`
  the response grows to tens of kB per environment, so ask for models of the chosen
  `instanceId` only.
- **Freshness.** T3 serves its last provider check (`checkedAt`); the connector does not
  ask it to probe again, which would require `orchestration:operate`.

**Using it before a write.** `thread.launch`, `thread.model-selection.set`,
`provider.switch` and `delegated_task.request` take
`modelSelection: {instanceId, model, options?}`. A client that already knows the exact IDs
(for example `{instanceId: "claudeAgent_custom", model: "claude-opus-5-5", options: [{id:
"fastMode", value: true}]}`) can write directly; otherwise:

1. `t3_providers {environment}` and pick the instance by `instanceId`
   or `displayName` (ask the user if more than one fits).
2. `t3_providers {environment, instanceId, includeModels: true}` and pick `model` from `models[].slug`.
3. Optional `options` are `{id, value}` with `id` from
   `models[].capabilities.optionDescriptors[].id` and `value` one of that descriptor's
   `options[].id` (select) or a boolean.

Copy the IDs exactly. Before sending, the write reads the same `server.getConfig` of the
environment (fresh on every write, so a capability change applies to the next write) and
refuses, with nothing sent, a selection the environment does not offer. Each refusal names
the problem and the offered values:

| Code | Meaning |
|---|---|
| `provider_instance_unavailable` | `instanceId` is not configured in this environment (lists the configured ones) |
| `provider_model_unavailable` | `model` is not a `models[].slug` of that instance (lists the offered ones) |
| `model_option_unsupported` | an option `id` is not offered by that model, or is repeated (lists the offered options and types) |
| `model_option_value_unsupported` | a boolean option got a non-boolean, or a select option a value outside its choices (lists them) |
| `model_capabilities_unknown` | T3 did not declare the models or option descriptors needed to check, or uses a descriptor type the connector does not interpret |
| `model_capabilities_unavailable` | the provider configuration could not be read |

The selection is sent exactly as given: no option is added, removed or defaulted (omitted
options keep T3's defaults). `status`/`enabled` here are information, not a gate: whether a
valid instance can run is still decided by T3 when the write arrives.

## Writes

The write connector is a separate MCP server with its own process, tokens and tunnel.
Local configuration in `~/.config/t3-connector/write.json` (or
`T3_CONNECTOR_WRITE_CONFIG`):

```jsonc
{
  "port": 7433,                                      // approval page port
  "stateDir": "~/.local/state/t3-connector-write",   // state directory
  "channel": { "organization": "my-org", "tunnelId": "tunnel_<your tunnel id>" },
  "passkey": { "rpName": "T3 Connector", "userName": "t3-connector" },   // optional
  "environments": {
    "local":  { "environmentId": "…", "url": "http://127.0.0.1:3773", "tokenFile": "~/.config/t3-connector/write/local.token" },
    "remoto": { "environmentId": "…", "ssh": { "host": "my-server", "remotePort": 3773 }, "tokenFile": "~/.config/t3-connector/write/remoto.token" }
  }
}
```

Keys from 0.5.0 or earlier (`porta`, `estado`, `canal`, `ambientes`, `ssh.portaRemota`)
are refused at start-up with the new name in the message.

- One token per environment, scoped to exactly `orchestration:read` +
  `orchestration:operate`, paired with `t3-connector-write pair --environment <alias>`.
  Never reuse the read-only token.
- There is no allowlist: every approval request carries the full inventory of each
  environment and every action, and the passkey holder decides on the scope shown on the
  page.
- `stateDir` stores the registered passkey, the idempotency journal, the relay capability
  and logs, with 600/700 permissions.
- `passkey.rpName` and `passkey.userName` only label the registration of a new passkey.
  The `rpID` is always `localhost`; changing the labels does not invalidate existing
  passkeys.

### How a client should write

1. `t3_pedir_aprovacao` (request approval) returns the active lease or an approval link.
   `environments` lists the environments the lease covers (`projectCount`, `actionCount`);
   `unavailableEnvironments`, those left out because they did not respond (`reason`).
2. Every `t3_escrever_*` (write) tool, the leased reads and `t3_reconciliar_escrita`
   (reconcile) require `environment`. There is no default.
3. `operationId` is the idempotency key: repeating the same operation in the same
   environment never resends it. For `thread.send`, `clientRequestId` must equal
   `operationId`.
4. `reconciliation_required` means the result is uncertain: do not retry; call
   `t3_reconciliar_escrita` with the same `environment` and `operationId`.
5. `modelSelection` is validated by the write against the environment's provider
   configuration; `t3_providers` is optional, for browsing. See
   [Using it before a write](#provider-instances-t3_providers).

Write results carry `environment: {alias, environmentId}`. Routing errors:
`environment_required`, `environment_unknown`, `environment_not_in_lease`,
`environment_unavailable` (nothing was sent), `gate_unavailable` and `thread_not_found`.
A missing, invalid or unknown parameter (including a name from 0.5.0) is refused by the
MCP SDK with error `-32602` before anything reaches the gate.

### `thread.send` timing contract

| `delivery` | Effect | Valid example |
| --- | --- | --- |
| `steer_active` | Steers the current run without restarting it. `targetRunId` required. T3 may refuse if the run changed or finished. | "Use the existing API instead of building another backend." |
| `start_immediately` | Starts a new run when there is no active run to preserve. May become a queued message if a run is active. | "The thread is idle; implement the next task." |
| `restart_active` | Interrupts the identified run and starts another. `targetRunId` required. Does not undo effects already made. | "Stop this approach and start over with the corrected requirement." |
| `queue_after_active` | Follow-up work after the current run. Requires `deferUntilActiveCompletes: true`, only when deferring was explicitly requested. | "When the implementation is done, do a separate review." |

A correction of the current work uses `steer_active`; it is never deferred until after the
work it is meant to correct. Refused steer/restart requests never fall back to a queue.

### Conditional send (`t3_escrever_thread_conditional_send`)

"When run R ends, switch the model (for example to Fast Mode), then send this instruction" in
one call, with one `clientRequestId` (equal to `operationId`). Input: `threadId`,
`afterRunId` (the active run read from `t3_thread`), optional `modelSelection`, `text` and
optional `waitMs` (0–10000) to wait for R to end inside the call.

- Precondition: R is terminal, is still the thread's latest run and no run is active. It is
  checked before the first step and again right before the send, so a run started by anyone
  in between refuses the send instead of letting T3 queue it.
- Steps are the existing writes, journaled under `<clientRequestId>:model-selection`
  (`thread.model-selection.set`) and `<clientRequestId>:send` (`thread.send`
  `start_immediately`); each can be reconciled with `t3_reconciliar_escrita`. The lease (or
  OAuth consent) must include those actions; no new action is consented.
- Retrying the same request never sends twice. A step's state is always read from the write
  journal of its operationId, never assumed: `precondition_pending` and `uncertain` are evaluated
  again on retry, and a final answer is replayed only while the journal still agrees with its
  steps. Concurrent duplicates in one process share one execution.
- "Not sent" (`sent: false`, `precondition_failed`, a refused step) is stated only for a step whose
  journal record is a refusal. When the request itself concludes it (precondition, model not
  reflected, an earlier step refused), it first reserves the step's operationId as refused with
  the journal's atomic reservation, the one the Dispatcher uses to own an operationId: a later
  write under it is refused and never sends, and if another writer already holds it, that
  writer's state is reported instead. A refusal that leaves no record (lease, scope) refuses the
  call and states nothing about the step. Replaying a negative answer recorded before this rule
  closes its steps the same way first; if another writer holds one, the answer is rebuilt as
  `uncertain` (`step_changed_after_result`).
- `state`: `precondition_pending` (R still active), `precondition_failed` (`reason`
  `other_run_active`, `run_superseded`, `run_unknown`; terminal, nothing sent), `failed`
  (`failedStep` refused before sending, proven by the journal; earlier steps stay applied and
  are listed), `uncertain` (`failedOperationId` may have been or may still be sent:
  `step_in_progress` while another call holds it, `reconciliation_required` without an
  acknowledgement, `step_changed_after_result`; never resend under another id, reconcile or
  retry the same `clientRequestId`) or `completed`. The result keeps every
  observation and step; `delivery` reports the run T3 created for the message
  (`deliveredAs: started | queued_behind_active`, `runModelMatches`): T3 can still queue it
  if a run started after the last check, and that is reported, not hidden.

## Operator CLI (`t3-connector-ops`)

One core for thread operations across environments, for local automation on a machine that
already holds an `orchestration:operate` token: `src/ops.mjs`, exposed as the CLI
`t3-connector-ops` (JSON on stdout) and importable from scripts. It uses the operator's own
token and no passkey lease, so it is never reachable from a remote MCP client; the write
plugin keeps its lease.

```sh
t3-connector-ops environments
t3-connector-ops <env> list [--settled] [--archived]  # default: not settled, archived or deleted
t3-connector-ops <env> read <threadId> [--since P] [--last N]
t3-connector-ops <env> timeline <threadId> [--message-ids A,B] [--not-before ISO]
t3-connector-ops <env> projects | providers
t3-connector-ops <env> send <threadId> --message-id ID [--delivery queue_after_active|start_immediately] < text
t3-connector-ops <env> create --project P --title T --instance I --model M [--effort E] [--option id=value]...
                              [--runtime-mode M] [--worktree BASE[:BRANCH]] --client-request-id ID < brief
t3-connector-ops <env> settle <threadId>...
t3-connector-ops <env> snooze <threadId> <ISO datetime with offset>
t3-connector-ops <env> configure <threadId> [--instance I] [--model M] [--effort E] [--option id=value]... --operation-id ID
t3-connector-ops <env> interrupt <threadId> --operation-id ID [--run-id R] [--reason TEXT]
t3-connector-ops <env> organize <threadId> <pin|unpin|snooze|unsnooze|settle|unsettle|archive|unarchive|mark_unread> --operation-id ID [--until ISO]
t3-connector-ops <env> actions                    # every action and read below, with kind and description
t3-connector-ops <env> act <action> [--operation-id ID] [--namespace NS] [--input JSON | < JSON]
t3-connector-ops <env> query <name> [--input JSON | < JSON]
```

- **Environments**: `~/.config/t3-connector/ops.json` (or `T3_CONNECTOR_OPS_CONFIG`), or the same
  JSON in `T3_CONNECTOR_OPS_ENVIRONMENTS`: `{"environments": {"mac": {"url": "https://…",
  "tokenFile": "…", "aliases": ["laptop"], "environmentId": "…"}}}`. Each entry has `url` or
  `ssh`, a `tokenFile` (mode 600, private directory) whose token has
  `orchestration:read` (enough for list, thread, read, timeline, projects, providers and query)
  and, for send, create, settle, snooze and act, `orchestration:operate`; optional extra `aliases` and an optional
  `environmentId` that the server descriptor must match. `url` is HTTPS or loopback HTTP;
  `"insecureHttp": true` allows plain HTTP to another host, only for a network that encrypts
  by itself (for example a tailnet).
- **Idempotency**: `send` uses `--message-id` as commandId and messageId; `create` derives the
  thread id from `--client-request-id` and returns the existing thread (`created: false`) on a
  repeat; settle and snooze derive their commandId from the thread (and date). Importers can
  pass a `namespace` for the derived ids (`deriveId`).
- **Create**: the project is an id, a workspace root or a unique title (an ambiguous title is
  refused with the candidates). The instance, model, `--effort` (mapped to the model's
  `effort` or `reasoningEffort` option) and any `--option` are checked against the
  environment's `server.getConfig` before anything is sent, with the same codes as the write
  tools. Default runtime mode `full-access`, workspace `root`.
- **Confirmation**: every mutation is read back. Settle and snooze refuse a thread that is
  running or has a pending runtime request; `send` reports `delivered` or `queued` (behind an
  active run).
- **Composed operations**, decided as the native MCP tools decide them on the server:
  `configure` (t3_thread_configure: builds the selection with `buildModelSelection`, effort mapped
  to the model's option; omitted instance/model keep the current ones and, when both stay, the
  other current options are kept; `thread.model-selection.set` on the thread's instance,
  `provider.switch` on another; confirmed by reading the thread; returns `{command, before,
  after}`), `interrupt` (t3_thread_interrupt: the active run with the highest ordinal; no active
  run is `{interrupted: false, reason: 'no_active_run'}`) and `organize` (t3_thread_organize
  actions, sent as the native tool sends them and confirmed by reading the thread, archived
  included; `mark_unread` is confirmed by the receipt only). Each takes an `operationId`.
- **Every action (`act`)**: the same actions as the write tools, from the same table
  (`ALL_ACTIONS`): the T3 commands (`thread.*`, `run.interrupt`, `queued-run.*`,
  `runtime-request.*`, `provider.switch`, `thread.launch`, `thread.send` with the four
  deliveries, `thread.fork`, `delegated_task.*`, ...), `project.delete[-force]` (same guard on a
  fresh active + archived count), the native-tool writes (`t3_project_create`, `schedule_task`,
  ...) and three operator-only wrappers that no MCP catalog or consent gains:
  `t3_thread_configure` (change account/instance, model and effort of a thread:
  `thread.model-selection.set` on the same instance, `provider.switch` on another, as the native
  tool decides), `thread.pull-request.watch` (`watching: true|false`; needs a T3 server with that
  command) and `t3_preview_close`. Input is validated by the same zod schemas (same error codes,
  e.g. `target_run_id_required`), and any `modelSelection` against `server.getConfig` before
  sending. Output: `{action, operationId, commandId, ids?, result}`; `result` is the T3 receipt
  (`{sequence}`, `{threadId, resumed}`, the project) or the native tool's result.
- **Idempotency of `act`**: ops is stateless (no journal). The commandId, and the messageId of
  `thread.send`, the threadId of `thread.launch` and the targetThreadId of `thread.fork`, derive
  from (namespace, environmentId, action, operationId), so repeating an operation makes T3 replay
  its receipt instead of applying it again. Message ids (`thread.send` messageId/commandId, the
  `thread.launch` brief) are `<namespace>:<hash>` like `send` and `create`; other ids are UUIDs. `thread.send` takes `clientRequestId` as the
  operationId. Native writes that T3 accepts without a commandId (`idempotent: false` in
  `actions`: clone, preferences, scheduled task update/delete/run, project create from a title,
  preview close) repeat their effect. A typed T3 answer is `t3_refused` with `details.native`
  (for a sent command it does not prove that nothing happened); a lost transport after the send
  is `uncertain`: read before repeating, or repeat the same operationId.
- **Every read (`query`)**: the MCP reads by a stable name, built by the same code and returning
  what the MCP tool returns, always in this environment: `thread` (t3_thread: model with effort,
  runtimeMode, pendingRequests with content and nextAction, activeRun, latestRun,
  providerSession; finds archived threads), `pending_requests` (`{threadId, requestId?}`; every
  kind, approvals included), `messages`, `search`, `threads`, `projects`, `providers`,
  `attention`, `control_plane`, `wait` (event-driven, `timeoutMs` up to 300000 ms here, 5000 in
  MCP) and `read_batch` (items need only `threadId`); plus the native-tool reads by tool name
  (`t3_thread_configuration`, `t3_queue_list`, `t3_worktree_status`, `list_scheduled_tasks`,
  `t3_preview_list`, ...).
- **List**: `--settled` lists the settled threads instead (as the native `t3_thread_list`),
  `--archived` the archived ones (from the archived shell snapshot); both list either. Every
  summary carries `modelSelection` and `effort` (`reasoningEffort`, else `effort`, as `t3_thread`).
- **Not offered**, with the reason in `OPS_OMITTED` (`src/ops.mjs`): preview automation and
  devices (host-bound), attachments (signed upload), the native `t3_thread_read` timeline,
  `list_thread_pull_requests`, `t3_worktree_handoff`, `delegate_task`/`task_status`/`task_cancel`
  as composed tools (their building blocks are actions) and `thread.conditional-send` (needs the
  write journal).
- **Transport**: with `"insecureHttp": true` the WebSocket follows the plain `http:` base
  (`ws:` to that host); otherwise `ws:` is accepted only on `127.0.0.1` (SSH tunnel).

## OAuth session profile (experimental)

`t3-connector-oauth` serves the same tools over MCP HTTP behind an embedded OAuth server: the
client signs in with a passkey at the HTTPS issuer, explicitly consents, refreshes while
it keeps calling tools, and needs a new passkey after an idle window (default 1 h). It runs
alongside the stdio connectors and does not use the write lease. Design, configuration, threat
model and rehearsal: [`docs/oauth-session.md`](docs/oauth-session.md).

Set `T3_CONNECTOR_OAUTH_PROJECTS=all` for read/write access to all current and future projects
of the configured, consented environments. Each call resolves live inventory; new projects are
included automatically without another sign-in. Read/write configs must match by alias,
environment ID and logical destination. The OAuth-only loader ignores `allowedProjects` without
changing the shared file; the stdio read ACL and Ponte lease snapshot remain unchanged. The
consent page shows environments, effective scopes, one-hour idle expiry and local revoke.

HTTPS login defaults to `public` (`T3_CONNECTOR_OAUTH_LOGIN_MODE`), with no localhost navigation.
The public RP is the issuer hostname, stored separately from the original local RP under the
same canonical subject. On the loopback control page, a fresh action-bound local passkey proof
issues a browser-bound enrollment link/QR (128 bits, single use, at most 15 minutes) or removes
a selected credential and its sessions. All public enrollment routes return 404 without an
active capability. GET never approves or enrolls. `button`, `302`, `oob` and the original local
passkey remain available; HTTP localhost rehearsal defaults to `button`. Public mode requires
HTTPS. Mobile, Bitwarden sync and hosted ChatGPT E2E still require separate deployment validation.

The default `restricted` project policy preserves the sandbox read ACL and write snapshot at sign-in.
`T3_CONNECTOR_OAUTH_WRITE_PROJECTS` is available only in that mode and conflicts with `all`.
See [`examples/oauth-all-projects.env`](examples/oauth-all-projects.env) for configuration.

## Development

```sh
npm test               # unit tests (no network, no real T3)
npm run test:package   # npm pack + file allowlist + isolated install + MCP handshake
npm run release:dry-run  # release checks and packaging, without publishing
```

Releases are GitHub Releases built from a `vX.Y.Z` tag; see
[docs/releasing.md](https://github.com/marcuscastelo/t3-connector/blob/main/docs/releasing.md).

`reference/` holds a copy of the T3 Code contract used only by tests; see
[reference/README.md](reference/README.md) for its origin and license. Source comments
and some CLI diagnostics are still in Portuguese.

## Security

See [SECURITY.md](SECURITY.md). Never commit tokens, `config.json`, `write.json`, the state
directory or tunnel keys.

## License

[MIT](LICENSE). `reference/` keeps T3 Code's MIT license
([reference/LICENSE.t3code](reference/LICENSE.t3code)).

Maintenance

ActivityMaintained
ResponsivenessNo issues