T3 Connector
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@T3 Connectorlist my T3 Code projects and recent threads"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 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 exactlyorchestration:read.Write (
t3-connector-write gate|bridge): 42 thread actions and a conditional send built on them, each with a mandatoryenvironment. Nothing is dispatched without a 60-minute lease approved with a passkey on a page served athttp://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, ADR 0002, waiting and ADR 0003, writes and ADR 0004, English contract.
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 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.
sshonPATHif an environment is reached through an SSH tunnel.To expose the connector to a remote client: an MCP stdio transport such as the
tunnel-clientof OpenAI's Secure MCP Tunnel.
Related MCP server: DistributedAI
Install
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 PATHFrom 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.
default: optional and informational only (CLI banner andt3-connector environments). No read tool uses it: a call withoutenvironmentqueries every environment (ADR 0005).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) orssh: {host, remotePort}(default 3773). With SSH, the connector spawnsssh -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 exactlyorchestration: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:
<t3 server cli> auth pairing create --json --ttl 5m --label t3-connector \
| t3-connector pair --environment localFor 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
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 writesThe 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):
SMOKE_THREAD_REMOTO=<id> [SMOKE_AMBIENTE_REMOTO=<alias>] [SMOKE_THREAD_ATIVA=<id>] [SMOKE_THREAD_FORA=<id>] npm run smokeIt 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
There is no default environment.
t3_ambienteslists the configured ones. Listing and discovery tools (t3_projetos,t3_threads,t3_atencao,t3_providers,t3_buscar_threads) called withoutenvironmentquery every environment and putenvironment: {alias, environmentId, name}on each item; passenvironmentonly 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.Partial results are flagged. Every listing answers with
complete,queriedEnvironments(each withfound) andenvironmentFailures. Withcomplete: falsethe list is partial: an empty list then does not prove absence. Tell the user which environment did not answer. Withenvironmentgiven, a failure there is an error instead.Reads by ID (
t3_thread,t3_mensagens) withenvironmentread 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. Withoutenvironmentthe 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 carriesenvironmentandenvironmentDiscovery. It refuses when the ID exists in more than one environment (ask the user, then passenvironment), 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 forenvironmentor a retry, without claiming absence. To find a thread by title, callt3_buscar_threads(find threads): each result carries the environment where the thread lives. Whentotalis above 1, ask the user which one; never pick by order or recency.To follow a thread, call
t3_aguardar_thread(which requiresenvironment) withtimeoutMsbetween 1000 and 2000 for voice (max 5000).timedOut: truemeans the thread is still running: answer the user and call again on a later turn. Do not chain waits in the same turn.needs_intervention(approval, question, plan) is not an end state. The connector only reports it; answering requires T3 itself.To browse providers or models, call
t3_providersin the target environment. A write withmodelSelectiondoes not need it first: it validates the selection itself. See Provider instances.To decide what to do next across machines, call
t3_control_planeonce. Act only on what it lists; ifcompleteis false, say which environments are missing instead of reporting that nothing needs attention. See Control plane snapshot.
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). t3_thread_read_batch puts
environment on each item.
Tool | Input | Main output |
|
|
|
|
|
|
|
|
|
| exactly one of |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
a pending runtime request:
needs_intervention(pending_request);a run still active:
running(active_run);a usage limit without automatic resume, or a proposed plan:
needs_intervention(usage_limit,proposed_plan);otherwise the outcome of the latest run (
latest_run), orno_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:
| Meaning |
| The environment is not configured (or is outside the OAuth policy); |
| Missing, deleted or outside the authorized projects of that environment (one answer for all three); never looked up in another environment |
| The environment or the thread projection could not be read; same codes as |
|
|
| 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;wokeAtis 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) reacheswokeAt(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: nullmeans 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 |
| ID to answer; identical to the preserved |
| Summary fields; |
|
|
| Whether the snapshot supplied a valid, supported request body |
| Typed body below, or |
|
|
| Always |
| 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:
{
"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:
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).
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
wokefilter the server cannot decide, or aprojectIdthat environment does not authorize) go toenvironmentFailures(code:timeout,global_timeout,unavailable,environment_mismatch,http_<status>,connection_refused,refusedorfailed;reason; never token paths or transport output), andcompleteis false.totalcounts matches in the environments that answered, so an empty or short list withcomplete: falsedoes not prove absence. If every environment fails, the response is still a normal envelope withcomplete: false.With
environmentonly 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.
needsInterventionandrunningare the canonicalstate(the same rule ast3_threadsandt3_atencao).readyis work that is potentially actionable and can be derived from the shell without guessing: the latest run iscompleted,failedorcancelledand the thread is neither settled nor snoozed (readyReasons:latest_run_<state>_unsettled), or the thread is woke (woke). Threads without a run, inunknownstate, settled or still snoozed are left out of the lists but counted inqueriedEnvironments[].byState; archived threads and other projects are not counted.Items. Each item is the
t3_threadssummary plusenvironment: {alias, environmentId, name},activeRun(runId,statusor null),pendingRequest(requestId,kind,reason,since; the question or approval content and the answer path are read witht3_thread),snoozedUntilwhen snoozed, andnext, thet3_threadcall (environment,threadId) that reads it.readyitems addreadyReasons,blockersandactionableNow:blockers: ["background_work_pending"]means the shell reports background tasks (backgroundTasks).Not acceptance.
readydoes not mean the work is correct or that the thread is idle. Background work the shell has not published yet is only visible int3_thread. Read the thread before continuing or settling it.Partial failures. Environments that fail or time out are in
environmentFailures(same codes ast3_buscar_threads),completeis false andincompleteReasonsays 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" whencompleteis true.Coherence. Each environment is one shell read, a single backend snapshot (
snapshotSequencewhen 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 atlimit;totalandtruncatedsay how much was left out. There is no cursor: for more, callt3_threadswithstatein 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) andavailabilityas T3 reports them. Fields T3 omits stay absent.IDs are exact and per environment.
instanceId,driver,displayNameand modelslugs are returned as received. The sameinstanceIdcan have another display name or other models in another environment, so call it in the environment you will write to.instanceIdfilters 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}andmodels(each T3ServerProviderModelunchanged:slug,name,isCustom,capabilities, …). This is a projection, not the wholeServerProvider: 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: truethe response grows to tens of kB per environment, so ask for models of the choseninstanceIdonly.Freshness. T3 serves its last provider check (
checkedAt); the connector does not ask it to probe again, which would requireorchestration: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:
t3_providers {environment}and pick the instance byinstanceIdordisplayName(ask the user if more than one fits).t3_providers {environment, instanceId, includeModels: true}and pickmodelfrommodels[].slug.Optional
optionsare{id, value}withidfrommodels[].capabilities.optionDescriptors[].idandvalueone of that descriptor'soptions[].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 |
|
|
|
|
| an option |
| a boolean option got a non-boolean, or a select option a value outside its choices (lists them) |
| T3 did not declare the models or option descriptors needed to check, or uses a descriptor type the connector does not interpret |
| 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):
{
"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 witht3-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.
stateDirstores the registered passkey, the idempotency journal, the relay capability and logs, with 600/700 permissions.passkey.rpNameandpasskey.userNameonly label the registration of a new passkey. TherpIDis alwayslocalhost; changing the labels does not invalidate existing passkeys.
How a client should write
t3_pedir_aprovacao(request approval) returns the active lease or an approval link.environmentslists the environments the lease covers (projectCount,actionCount);unavailableEnvironments, those left out because they did not respond (reason).Every
t3_escrever_*(write) tool, the leased reads andt3_reconciliar_escrita(reconcile) requireenvironment. There is no default.operationIdis the idempotency key: repeating the same operation in the same environment never resends it. Forthread.send,clientRequestIdmust equaloperationId.reconciliation_requiredmeans the result is uncertain: do not retry; callt3_reconciliar_escritawith the sameenvironmentandoperationId.modelSelectionis validated by the write against the environment's provider configuration;t3_providersis optional, for browsing. See Using it before a write.
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
| Effect | Valid example |
| Steers the current run without restarting it. | "Use the existing API instead of building another backend." |
| 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." |
| Interrupts the identified run and starts another. | "Stop this approach and start over with the corrected requirement." |
| Follow-up work after the current run. Requires | "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.sendstart_immediately); each can be reconciled witht3_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_pendinganduncertainare 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 asuncertain(step_changed_after_result).state:precondition_pending(R still active),precondition_failed(reasonother_run_active,run_superseded,run_unknown; terminal, nothing sent),failed(failedSteprefused before sending, proven by the journal; earlier steps stay applied and are listed),uncertain(failedOperationIdmay have been or may still be sent:step_in_progresswhile another call holds it,reconciliation_requiredwithout an acknowledgement,step_changed_after_result; never resend under another id, reconcile or retry the sameclientRequestId) orcompleted. The result keeps every observation and step;deliveryreports 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.
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(orT3_CONNECTOR_OPS_CONFIG), or the same JSON inT3_CONNECTOR_OPS_ENVIRONMENTS:{"environments": {"mac": {"url": "https://…", "tokenFile": "…", "aliases": ["laptop"], "environmentId": "…"}}}. Each entry hasurlorssh, atokenFile(mode 600, private directory) whose token hasorchestration:read(enough for list, thread, read, timeline, projects, providers and query) and, for send, create, settle, snooze and act,orchestration:operate; optional extraaliasesand an optionalenvironmentIdthat the server descriptor must match.urlis HTTPS or loopback HTTP;"insecureHttp": trueallows plain HTTP to another host, only for a network that encrypts by itself (for example a tailnet).Idempotency:
senduses--message-idas commandId and messageId;createderives the thread id from--client-request-idand returns the existing thread (created: false) on a repeat; settle and snooze derive their commandId from the thread (and date). Importers can pass anamespacefor 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'seffortorreasoningEffortoption) and any--optionare checked against the environment'sserver.getConfigbefore anything is sent, with the same codes as the write tools. Default runtime modefull-access, workspaceroot.Confirmation: every mutation is read back. Settle and snooze refuse a thread that is running or has a pending runtime request;
sendreportsdeliveredorqueued(behind an active run).Composed operations, decided as the native MCP tools decide them on the server:
configure(t3_thread_configure: builds the selection withbuildModelSelection, 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.seton the thread's instance,provider.switchon 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'}) andorganize(t3_thread_organize actions, sent as the native tool sends them and confirmed by reading the thread, archived included;mark_unreadis confirmed by the receipt only). Each takes anoperationId.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.sendwith 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.seton the same instance,provider.switchon another, as the native tool decides),thread.pull-request.watch(watching: true|false; needs a T3 server with that command) andt3_preview_close. Input is validated by the same zod schemas (same error codes, e.g.target_run_id_required), and anymodelSelectionagainstserver.getConfigbefore sending. Output:{action, operationId, commandId, ids?, result};resultis 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 ofthread.send, the threadId ofthread.launchand the targetThreadId ofthread.fork, derive from (namespace, environmentId, action, operationId), so repeating an operation makes T3 replay its receipt instead of applying it again. Message ids (thread.sendmessageId/commandId, thethread.launchbrief) are<namespace>:<hash>likesendandcreate; other ids are UUIDs.thread.sendtakesclientRequestIdas the operationId. Native writes that T3 accepts without a commandId (idempotent: falseinactions: clone, preferences, scheduled task update/delete/run, project create from a title, preview close) repeat their effect. A typed T3 answer ist3_refusedwithdetails.native(for a sent command it does not prove that nothing happened); a lost transport after the send isuncertain: 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,timeoutMsup to 300000 ms here, 5000 in MCP) andread_batch(items need onlythreadId); plus the native-tool reads by tool name (t3_thread_configuration,t3_queue_list,t3_worktree_status,list_scheduled_tasks,t3_preview_list, ...).List:
--settledlists the settled threads instead (as the nativet3_thread_list),--archivedthe archived ones (from the archived shell snapshot); both list either. Every summary carriesmodelSelectionandeffort(reasoningEffort, elseeffort, ast3_thread).Not offered, with the reason in
OPS_OMITTED(src/ops.mjs): preview automation and devices (host-bound), attachments (signed upload), the nativet3_thread_readtimeline,list_thread_pull_requests,t3_worktree_handoff,delegate_task/task_status/task_cancelas composed tools (their building blocks are actions) andthread.conditional-send(needs the write journal).Transport: with
"insecureHttp": truethe WebSocket follows the plainhttp:base (ws:to that host); otherwisews:is accepted only on127.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.
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 for configuration.
Development
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 publishingReleases are GitHub Releases built from a vX.Y.Z tag; see
docs/releasing.md.
reference/ holds a copy of the T3 Code contract used only by tests; see
reference/README.md for its origin and license. Source comments
and some CLI diagnostics are still in Portuguese.
Security
See SECURITY.md. Never commit tokens, config.json, write.json, the state
directory or tunnel keys.
License
MIT. reference/ keeps T3 Code's MIT license
(reference/LICENSE.t3code).
This server cannot be deployed
Maintenance
Related MCP Connectors
Read-only gateway for durable agent identity, consent, recognized work, and signed receipts.
Tenant-scoped control layer for agent-to-agent systems: governed routing, approvals, evidence.
Operate secure JoinLayer data pipelines through delegated OAuth.
Governed MCP gateway: one endpoint for your tools, with credential custody and audit log.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables clients to access multiple backend MCP servers through a single endpoint, with OAuth 2.1 authorization, namespaced tools, and secure credential management.1MIT
- AlicenseNot gradedqualityBmaintenanceEnables multiple MCP clients to collaborate through a shared store with identity, project-scoped access, versioned memory proposals, review queues, and leased jobs with fencing tokens.2AGPL 3.0
- AlicenseNot gradedqualityBmaintenanceEnables running authorized file operations, persistent processes, and parallel batches from an MCP client, with durable receipts and artifact verification, and allows reconnecting to collect workers without restarting them.MIT
- AlicenseNot gradedqualityCmaintenanceEnables managed access to workspace file operations, background tasks, Skills, and upstream MCP services through a unified web console with workspace-bound keys and explicit tool permissions.MIT