Skip to content

[RFC]: Local app integration SDK and custom project surfaces MVP #6419

Description

@abcdmku

T3 Code SDK

Building a T3 Code SDK for the HTTP endpoints and WebSocket streams the server already exposes, so a web app can read threads, dispatch commands, and follow events live reusing the existing auth and reconnect logic.

Then make a configurable surface reusing the browser surface. Point it at your web app's URL and the app renders inside T3 Code. Adding a custom surface is also the moment to tell someone that the app may act on their behalf. Web apps can optionally expose MCP servers for the agents in the T3 threads to respond to.

  1. @t3tools/sdk, a typed client for the endpoints that already exist.
  2. An Add plugin button in T3's surface picker, so a URL becomes a panel that can talk back to T3.
  3. @t3tools/ui, the components T3 renders, packaged for local apps. Nice to have.

A plugin is a page your own app serves. T3 opens it in the browser surface it already ships, hands it a one-time code, and the page then calls the T3 server directly.

Your app sits outside T3 Code, which holds the T3 client and the T3 server. T3 loads your URL with a thread id and a one-time code, and your page renders inside the client's browser surface. That page calls the T3 server directly to read, dispatch, and subscribe. Agent sessions can optionally call your app's MCP endpoint.

What a plugin can and cannot do

Can Cannot
Read projects, threads, and turn state Bring a thread into view in the T3 client
Create threads, start turns, answer approvals Open, close, or navigate T3's own panels
Follow events live, resuming after a drop Run code in the T3 renderer or server
Render as a panel, one instance per thread Put UI anywhere but its own panel
Offer tools to agent sessions over MCP React in place when you switch threads
Ask for scopes past orchestration:read Store secrets in T3, or be shared through the repo

The first cannot row is deliberate. Asking a focused T3 client to reveal a thread needs a server-side broker, host registration, and desktop IPC, which is more machinery than the rest of this issue combined. It belongs in its own proposal.

Part 1, the SDK

A thin Promise-first client over what the server already exposes: the public descriptor for discovery, bearer or code-exchange auth, shell and thread snapshots, typed command dispatch, and subscriptions that resume with afterSequence after a dropped socket. It would need effect and a narrow public entry into @t3tools/contracts, leaving the internal RPC, settings, provider, and IPC contracts private.

This runs in the plugin page itself. No backend, no proxy. The server sends access-control-allow-origin: * with authorization in its allowed headers (apps/server/src/httpCors.ts:11), so a page on any origin can call /api/* with a bearer token today, and WS /ws sits outside CORS entirely.

import { createT3Client } from "@t3tools/sdk/promise";

// T3 opened this page with #t3=<base url + one-time code>
const handoff = readT3Handoff(location.hash);

const client = createT3Client({
  baseUrl: handoff.serverUrl,
  auth: { type: "code", code: handoff.code },
});

const threadId = new URLSearchParams(location.search).get("thread");
const thread = await client.thread(threadId);
console.log(thread.title);

for await (const item of client.subscribeThread(threadId)) {
  if (item.kind === "event") render(item.event);
}

Writes are the same client. thread.create and thread.turn.start are the wire shapes T3's own clients send, and they carry boilerplate an integrator should never hand-roll (commandId, messageId, createdAt, the message envelope, runtimeMode, interactionMode), so the SDK should mint all of it.

const threadId = crypto.randomUUID();
await client.createThread({
  threadId,
  projectId,
  title: "Launched from my plugin",
  modelSelection,
  runtimeMode: "approval-required",
  interactionMode: "default",
  branch: null,
  worktreePath: null,
});
await client.startTurn({ threadId, message: { text: "Summarize the open TODOs." } });

Failures should reject with typed classes, so a caller can tell an expired grant from a rejected command.

Part 2, adding a plugin

Add one tile to the surface picker: Add plugin. Paste an http(s) URL, T3 fetches that page once for its title, favicon, and description, and the entry is saved per project in server settings (settings.json, already a validated and watched JSON store at apps/server/src/config.ts:118). Entry names stay within [a-z0-9-], because the name becomes a tool prefix later.

Opening an entry substitutes {threadId} and {projectId}, and appends a fragment carrying the environment base URL and a one-time code. An unresolved {threadId} is left alone, so the same template also works for a project-level open. Right-panel surfaces are per thread already (rightPanelStore.ts:52), so each thread opens its own instance with its own thread id.

The page exchanges that code at POST /oauth/token, the RFC 8693 exchange the server already serves for pairing, and gets a short-lived token scoped to orchestration:read. A plugin can ask for more, and what it asked for appears on the consent screen in plain words.

T3 fetches the URL for its title, favicon, and description. A consent screen keyed to the origin covers the page grant and the MCP grant, and the entry is saved in settings.json. On open, T3 passes a thread id and a one-time code, the page exchanges the code at POST /oauth/token for a short-lived orchestration:read token, and then calls /api/* and WS /ws with it. Agent sessions can optionally call the plugin's MCP endpoint.

The consent screen

Adding a plugin means handing a third party a token that acts as you, so T3 says so before it happens. One screen, two separate grants, both keyed to the origin:

  • This page may call T3 as you, listing the scopes it asked for.
  • Agents may use this plugin's tools, shown only when the entry declares an MCP URL.

The origin is the identity, since a page's title and favicon are self-reported and can change at any time. Those are shown as decoration and labelled as coming from the page. A grant is remembered until it is revoked, appears in t3 auth session list, and dies with t3 auth session revoke. Changing the title cannot inherit another origin's grant.

The optional MCP URL

An entry can declare an MCP URL, which T3 merges into an agent session's mcpServers beside its own t3-code entry. That wiring already exists for one server (ClaudeAdapter.ts:3551, type: "http" plus an auth header) and is resolved per thread at session start, so a registered plugin's tools are available in every thread of that project. HTTP only, and no headers, which keeps T3 out of the business of storing your plugin's secrets. A plugin that already serves a page can serve /mcp from the same process, so this asks for no extra bridge.

Tools show up to the agent as mcp__<entry-name>__<tool>, the same shape as T3's own mcp__t3-code__* tools.

Part 3, the UI components (nice to have)

Move the UI primitives T3 already has, such as button, dialog, command palette, tooltip, and the form controls, into @t3tools/ui with the theme stylesheet beside them. A plugin page then renders the same components as the app around it. T3 keeps rendering the same code, and nothing changes visually.

import { Button } from "@t3tools/ui/button";

Parts 1 and 2 stand on their own, so this one can wait or be dropped.

Compatibility and safety

  • Optional pieces sit behind a capability flag on the public descriptor. Absent means unsupported, and clients stay quiet.
  • The fragment carries a one-time code and never a long-lived token. Fragments are not sent to the plugin's own web server and stay out of its logs.
  • Plugin URLs resolve to http(s) only, and grants are per origin, per environment, revocable, and visible in t3 auth session list.
  • Nothing is injected into the page. No preload, no bridge, no postMessage API.

Questions for maintainers

Package names and which entry points are public and stable. Whether the one-time code should ride the URL fragment or wait for a real handoff channel. Whether @t3tools/ui should be shared with external apps at all, or stay internal with a documented copy path.

Related

#377 asked for a server SDK. #1582 proposed an extension system with a marketplace. #5020 proposed a Pi-style extension API. #6158 is an open local plugin implementation. #3993 was an earlier plugin attempt that closed after the orchestration foundation changed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions