# feat\(server\): agents use linked environments through any T3 tool · PR \#16684 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/16684)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: open

Source repository: pingdotgg/t3code

Source branch: t3code/peer/forwarding

Target branch: t3code/peer/links

Head commit: 641ee467aa5ceb9126f7c52e2b8b002bf258e0a4

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/16684)

[Commits](https://git.cafe/pingdotgg/t3code/pull/16684/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/16684/diffs?format=markdown)

## Description

---

Part of cross-environment orchestration. With a link from #16655, an agent can now work on a linked environment's threads through the same T3 MCP tools.

**What changes**
- **Every tool that names a thread, project, queue or request takes an optional `environmentId`**, about 48 in all. That covers threads (launch, send, wait, read, interrupt, update, configure, fork, merge back, organize, transfers, search), queues, pending requests, projects, scheduled tasks, pull requests, worktree refs and environment preferences. A new `t3_environment_links` lists the links, with status and access.
- **Forwarding lives in one place.** `McpToolAccess.toLayer` routes any tool whose parameters take `OrchestratorMcpEnvironmentTarget`, so a tool forwards by having the field and needs no forwarding code of its own. The local handler never sees `environmentId`, so it can't leak into a command. Tools that act as the calling thread itself (`task_status`, `request_secret`, `html_*`, `t3_worktree_handoff`, the preview and device tools) can't take one, and the layer refuses to build if one does. Tools whose `environmentId` means something else, such as the environment to link, don't use the target field and stay here.
- **Access is still checked here first.** Each tool's declaration checks the caller here before the call leaves, so a read-only caller, one whose turn ended, or one asking for broader modes than it has is refused locally. The thread or project the call names is checked where it lives.
- **The linked environment holds the call to the caller's modes.** Every forwarded call carries them in `T3-Mode-Limit` (#16653). The other side applies the narrower of those and the link's access, both in its declarations and under its thread lock. A plan-mode agent therefore can't steer an `auto` thread through an `auto` link.
- **Retries stay safe.** Every caller here reaches the other side as the link's one session, so `clientRequestId` is hashed with the caller's namespace before it leaves. Retrying gets the first result back, and another agent's identical key does not collide. Remote launches therefore stay idempotent through #16654.
- **Waits are chunked.** A forwarded `t3_thread_wait` goes out in pieces of at most 50 s, pinned to the run the first piece found. The far `/mcp` sends nothing while a wait is open, and T3 Connect's edge drops idle requests after about 100 s.
- **A remote launch refuses attachments for now**, because a pending upload lives on this side.
- **Small moves:**
  - `MODE_LIMIT_HEADER` moves to `McpInvocationContext`, so `peer/` doesn't import `McpHttpServer`.
  - The tool catalog and work-log summary know `t3_environment_links`.
  - The orchestration instructions mention linked environments.
  - `docs/internals/remote.md` gains a "Peer links" section on the trust model.

**Verification**
- `peer/PeerForwarding.test.ts` runs two environments in one process. B is the real orchestrator and project toolkits on its real `/mcp`, behind its real MCP OAuth, on a real socket, with its thread store mocked. A is the real toolkits plus `PeerForwarding`, linked to B with a pairing code. The four tests:
  - **Links, list and send.** `t3_environment_links` lists Box as reachable. `t3_thread_list` returns B's threads. A full-access agent sends to B's `auto` thread. A plan-mode agent is refused by B with `interaction_mode_escalation_denied`, and B records one send, not two.
  - **Chunked wait and retry keys.** A 10-minute wait goes out as three 50 s pieces, the later two pinned to the first piece's run. A retried send gets the same command id on B. Another agent's identical key gets a different one, and the raw key never reaches B.
  - **Remote launch.** A plan-mode caller's launch runs on B in `full-access/plan` (its own modes, under a full-access link). A retry asks B for the same thread. An escalation is refused on A. A launch with an upload is refused without B receiving a bearer.
  - **Unknown environment.** An unknown `environmentId` is `invalid_request`, and B receives no bearer.
- **Generic routing**, in `McpToolAccess.test.ts`, with a probe toolkit for each declaration kind:
  - a call naming another environment goes there;
  - a call naming this one, or none, runs here without `environmentId`;
  - read-only, ended and escalating callers are refused before anything goes;
  - a thread id naming a thread there is not checked against threads here.

  A tool that acts as its calling thread fails to build with the target field. `PeerForwarding.test.ts` also reads and archives B's thread with the thread toolkit's `t3_thread_configuration` and `t3_thread_organize`, which had no forwarding of their own.
- Mutation-checked: each of these fails its test when removed:
  - the mode header on forwarded calls;
  - the retry-key scoping;
  - wait chunking;
  - pinning later pieces to the run;
  - routing on `environmentId`;
  - the remote-launch attachment refusal;
  - stripping `environmentId` before the local handler;
  - the caller check before a call leaves.

  One mutation, re-sending the checked modes on launch, survived. That was redundant code (B already inherits the caller's modes from the header), so I removed it.
- Also ran all of `apps/server/src/mcp` (26 files, 274 tests), `T3OrchestrationInstructions`, contracts `orchestratorMcp`, `packages/shared`, and client-runtime `t3ToolSummary`. All passed. Server, contracts, shared and client-runtime typecheck clean.

**Review fixes** (two adversarial reviews)
- Naming this environment's own id runs the tool here with all its checks, including the ones on the target thread. Only a really remote target is left to the other side. Tests in `McpToolAccess.test.ts` and `linkOrigin.test.ts` (#16695).
- `t3_thread_interrupt` takes `stop: true`: it stops the thread as its Stop button does (`thread.stop`, which holds the queue and ends pull request watches, then the thread's own delegated tasks), with command ids from the request key and the same access checks. A peer without the option decodes it away and interrupts the run, as before. #16719 uses it to stop a remote task fully. Tested in `PeerForwarding.test.ts`: a forwarded stop reaches the box as `thread.stop` plus a delegated-task stop.

Opus 5.5 via Claude Code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/pingdotgg/t3code/pull/16684" target="_blank"><picture><source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4" alt="Devin Review"></picture></a>
<!-- devin-review-badge-end -->

