diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index b74bb7439f87..dd3aaac15b4d 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -39,6 +39,7 @@ const clientSettings: ClientSettings = {
// T3-CUSTOM(expbkt3): plan mode is available by default in the fork.
planModeAvailable: true,
nativePlanReviewEnabled: true,
+ agentUiSurfacesEnabled: true,
showSkillsInSlashMenu: false,
providerModelPreferences: {},
providerRateLimitsEnabled: true,
diff --git a/apps/server/src/agentui/AgentUiService.test.ts b/apps/server/src/agentui/AgentUiService.test.ts
new file mode 100644
index 000000000000..bdef5421c796
--- /dev/null
+++ b/apps/server/src/agentui/AgentUiService.test.ts
@@ -0,0 +1,138 @@
+/**
+ * T3-CUSTOM(expbkt3): coverage for agent-rendered UI surfaces.
+ *
+ * The validation here is the security boundary an agent's input crosses, so the
+ * tests pin the rejections as tightly as the happy path: an agent that can
+ * choose the framed URL scheme, or push an unbounded document into the database,
+ * is a different feature from the one we shipped.
+ */
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { AGENT_UI_MAX_HTML_CHARS, ThreadId } from "@t3tools/contracts";
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+
+import { MigrationsLive } from "../persistence/Migrations.ts";
+import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
+import * as AgentUiRenders from "../persistence/AgentUiRenders.ts";
+import * as AgentUiServiceModule from "./AgentUiService.ts";
+import { AgentUiService } from "./AgentUiService.ts";
+
+const threadId = ThreadId.make("thread-agent-ui");
+const otherThreadId = ThreadId.make("thread-agent-ui-other");
+
+const layer = AgentUiServiceModule.layer.pipe(
+ Layer.provide(AgentUiRenders.layer),
+ Layer.provide(MigrationsLive),
+ Layer.provide(SqlitePersistenceMemory),
+ Layer.provide(NodeServices.layer),
+);
+
+const withService = (
+ body: (service: AgentUiService["Service"]) => Effect.Effect,
+) =>
+ Effect.gen(function* () {
+ const service = yield* AgentUiService;
+ return yield* body(service);
+ }).pipe(Effect.provide(layer));
+
+describe("AgentUiService", () => {
+ it.effect("stores an inline document and reads it back by handle", () =>
+ withService((service) =>
+ Effect.gen(function* () {
+ const handle = yield* service.show({
+ threadId,
+ title: " Latency ",
+ html: "hello
",
+ });
+ expect(handle.kind).toBe("html");
+ expect(handle.renderId.startsWith("aui_")).toBe(true);
+
+ const render = yield* service.getRender({ threadId, renderId: handle.renderId });
+ expect(render?.html).toBe("hello
");
+ expect(render?.title).toBe("Latency");
+ expect(render?.url).toBeNull();
+ }),
+ ),
+ );
+
+ it.effect("scopes a render to its own thread", () =>
+ withService((service) =>
+ Effect.gen(function* () {
+ const handle = yield* service.show({ threadId, title: "Chart", html: "x" });
+ const leaked = yield* service.getRender({
+ threadId: otherThreadId,
+ renderId: handle.renderId,
+ });
+ expect(leaked).toBeNull();
+ }),
+ ),
+ );
+
+ it.effect("clamps the height into the embeddable range", () =>
+ withService((service) =>
+ Effect.gen(function* () {
+ const tall = yield* service.show({ threadId, title: "t", html: "", height: 5_000 });
+ const short = yield* service.show({ threadId, title: "t", html: "", height: 1 });
+ const absent = yield* service.show({ threadId, title: "t", html: "" });
+ expect(tall.height).toBe(900);
+ expect(short.height).toBe(120);
+ expect(absent.height).toBe(360);
+ }),
+ ),
+ );
+
+ it.effect("accepts an https URL and rejects every other scheme", () =>
+ withService((service) =>
+ Effect.gen(function* () {
+ const ok = yield* service.show({ threadId, title: "Docs", url: "https://example.com/a" });
+ expect(ok.kind).toBe("url");
+
+ for (const url of [
+ "http://example.com",
+ "javascript:alert(1)",
+ "file:///etc/passwd",
+ "data:text/html,",
+ "not a url",
+ ]) {
+ const failure = yield* service.show({ threadId, title: "x", url }).pipe(Effect.flip);
+ expect(failure.message).toContain("https://");
+ }
+ }),
+ ),
+ );
+
+ it.effect("requires exactly one of html or url", () =>
+ withService((service) =>
+ Effect.gen(function* () {
+ const neither = yield* service.show({ threadId, title: "x" }).pipe(Effect.flip);
+ expect(neither.message).toContain("Provide either");
+
+ const both = yield* service
+ .show({ threadId, title: "x", html: "", url: "https://example.com" })
+ .pipe(Effect.flip);
+ expect(both.message).toContain("only one");
+ }),
+ ),
+ );
+
+ it.effect("refuses a document past the size cap", () =>
+ withService((service) =>
+ Effect.gen(function* () {
+ const failure = yield* service
+ .show({ threadId, title: "x", html: "a".repeat(AGENT_UI_MAX_HTML_CHARS + 1) })
+ .pipe(Effect.flip);
+ expect(failure.message).toContain("the limit is");
+ }),
+ ),
+ );
+
+ it.effect("returns null for a render that was never stored", () =>
+ withService((service) =>
+ Effect.gen(function* () {
+ const missing = yield* service.getRender({ threadId, renderId: "aui_nope" });
+ expect(missing).toBeNull();
+ }),
+ ),
+ );
+});
diff --git a/apps/server/src/agentui/AgentUiService.ts b/apps/server/src/agentui/AgentUiService.ts
new file mode 100644
index 000000000000..e203fd5f4334
--- /dev/null
+++ b/apps/server/src/agentui/AgentUiService.ts
@@ -0,0 +1,153 @@
+/**
+ * T3-CUSTOM(expbkt3): agent-rendered UI surfaces shown inline in the chat.
+ *
+ * The `t3_show_ui` MCP tool records a render here and gets back a short handle;
+ * the chat later fetches the body by that handle. Keeping the two halves in one
+ * fork-owned service means the validation an agent's input goes through — size
+ * caps, height clamping, URL scheme — is the same validation the reader relies
+ * on, and none of it lives in an upstream file.
+ */
+import {
+ AGENT_UI_DEFAULT_HEIGHT,
+ AGENT_UI_MAX_HEIGHT,
+ AGENT_UI_MAX_HTML_CHARS,
+ AGENT_UI_MIN_HEIGHT,
+ AgentUiError,
+ type AgentUiRenderHandle,
+ type AgentUiRenderRecord,
+ type ThreadId,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Crypto from "effect/Crypto";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+
+import { AgentUiRepository } from "../persistence/AgentUiRenders.ts";
+
+export interface ShowUiInput {
+ readonly threadId: ThreadId;
+ readonly title: string;
+ readonly html?: string | undefined;
+ readonly url?: string | undefined;
+ readonly height?: number | undefined;
+}
+
+export interface AgentUiServiceShape {
+ readonly show: (input: ShowUiInput) => Effect.Effect;
+ readonly getRender: (input: {
+ readonly threadId: ThreadId;
+ readonly renderId: string;
+ }) => Effect.Effect;
+}
+
+export class AgentUiService extends Context.Service()(
+ "t3/agentui/AgentUiService",
+) {}
+
+function clampHeight(height: number | undefined): number {
+ if (height === undefined || !Number.isFinite(height)) return AGENT_UI_DEFAULT_HEIGHT;
+ return Math.max(AGENT_UI_MIN_HEIGHT, Math.min(Math.round(height), AGENT_UI_MAX_HEIGHT));
+}
+
+/**
+ * Only https is embeddable. http would be blocked as mixed content on every
+ * hosted deployment, and the non-network schemes are how a framed document
+ * would reach back at the app.
+ */
+function normalizeUrl(raw: string): string | null {
+ let parsed: URL;
+ try {
+ parsed = new URL(raw);
+ } catch {
+ return null;
+ }
+ return parsed.protocol === "https:" ? parsed.toString() : null;
+}
+
+export const make = Effect.gen(function* () {
+ const repository = yield* AgentUiRepository;
+ const crypto = yield* Crypto.Crypto;
+
+ // A failing CSPRNG is a defect, not something a caller can recover from.
+ const uuid = crypto.randomUUIDv4.pipe(Effect.orDie);
+
+ const show: AgentUiServiceShape["show"] = (input) =>
+ Effect.gen(function* () {
+ const title = input.title.trim() || "Agent view";
+ const html = input.html?.trim() ?? "";
+ const rawUrl = input.url?.trim() ?? "";
+
+ if (html.length === 0 && rawUrl.length === 0) {
+ return yield* new AgentUiError({
+ operation: "show",
+ message: "Provide either `html` or `url`.",
+ });
+ }
+ if (html.length > 0 && rawUrl.length > 0) {
+ return yield* new AgentUiError({
+ operation: "show",
+ message: "Provide only one of `html` or `url`, not both.",
+ });
+ }
+ if (html.length > AGENT_UI_MAX_HTML_CHARS) {
+ return yield* new AgentUiError({
+ operation: "show",
+ message: `The document is ${html.length} characters; the limit is ${AGENT_UI_MAX_HTML_CHARS}. Render a smaller view, or link to a URL.`,
+ });
+ }
+
+ const url = rawUrl.length > 0 ? normalizeUrl(rawUrl) : null;
+ if (rawUrl.length > 0 && url === null) {
+ return yield* new AgentUiError({
+ operation: "show",
+ message: "`url` must be an absolute https:// URL.",
+ });
+ }
+
+ const kind = url === null ? ("html" as const) : ("url" as const);
+ const height = clampHeight(input.height);
+ const renderId = `aui_${(yield* uuid).replaceAll("-", "").slice(0, 20)}`;
+ const createdAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
+
+ yield* repository
+ .insertRender({
+ renderId,
+ threadId: input.threadId,
+ title,
+ kind,
+ html: kind === "html" ? html : null,
+ url,
+ height,
+ createdAt,
+ })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new AgentUiError({
+ operation: "show",
+ message: `Could not store the view: ${String(cause)}`,
+ }),
+ ),
+ );
+
+ return { renderId, kind, height, title } satisfies AgentUiRenderHandle;
+ });
+
+ const getRender: AgentUiServiceShape["getRender"] = (input) =>
+ repository.getRender(input).pipe(
+ Effect.map(Option.getOrNull),
+ Effect.mapError(
+ (cause) =>
+ new AgentUiError({
+ operation: "get-render",
+ message: `Could not read the view: ${String(cause)}`,
+ }),
+ ),
+ );
+
+ return AgentUiService.of({ show, getRender });
+});
+
+export const layer = Layer.effect(AgentUiService, make);
diff --git a/apps/server/src/auth/rpcForkScopes.ts b/apps/server/src/auth/rpcForkScopes.ts
index 42f4fc587f1f..5862e2256e58 100644
--- a/apps/server/src/auth/rpcForkScopes.ts
+++ b/apps/server/src/auth/rpcForkScopes.ts
@@ -52,4 +52,7 @@ export const FORK_RPC_REQUIRED_SCOPES = {
[WS_FORK_METHODS.sessionArchiveBackfill]: AuthOrchestrationOperateScope,
// T3-CUSTOM(expbkt3): context handoff renders a string and writes nothing.
[WS_FORK_METHODS.threadContextExport]: AuthOrchestrationReadScope,
+ // T3-CUSTOM(expbkt3): agent-rendered UI surfaces. A read of thread content,
+ // and the handler additionally gates on per-thread access.
+ [WS_FORK_METHODS.agentUiGetRender]: AuthOrchestrationReadScope,
} as const;
diff --git a/apps/server/src/mcp/toolkits/control/handlers.ts b/apps/server/src/mcp/toolkits/control/handlers.ts
index 7e16a017d37e..245f08050c80 100644
--- a/apps/server/src/mcp/toolkits/control/handlers.ts
+++ b/apps/server/src/mcp/toolkits/control/handlers.ts
@@ -33,6 +33,8 @@ import { ProjectionSnapshotQuery } from "../../../orchestration/Services/Project
// T3-CUSTOM(expbkt3): bounded catch-up detail for t3_list_sessions.
import type { ProjectionSessionListDetail } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { PlannotatorManager } from "../../../plannotator/PlannotatorManager.ts";
+// T3-CUSTOM(expbkt3): agent-rendered UI surfaces in chat.
+import { AgentUiService } from "../../../agentui/AgentUiService.ts";
import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts";
import { redactServerSettingsForClient, ServerSettingsService } from "../../../serverSettings.ts";
import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts";
@@ -1363,6 +1365,35 @@ const handlers = {
},
),
+ // T3-CUSTOM(expbkt3): BEGIN — agent-rendered UI surfaces in chat.
+ //
+ // Always renders into the caller's own session: the box appears where the tool
+ // call appears, so there is no cross-session target to authorize.
+ t3_show_ui: Effect.fn("T3ControlToolkit.showUi")(function* (input) {
+ const operation = "show-ui";
+ const scope = yield* requireCapability(operation, "t3.read");
+ const agentUi = yield* AgentUiService;
+ const handle = yield* agentUi
+ .show({
+ threadId: scope.threadId,
+ title: input.title,
+ html: input.html,
+ url: input.url,
+ height: input.height,
+ })
+ .pipe(mapControlError(operation));
+ // Small and single-line on purpose: activity projection summarizes an MCP
+ // result to one short line, and this handle has to survive that trip to
+ // reach the chat client.
+ return {
+ t3UiRender: true,
+ renderId: handle.renderId,
+ kind: handle.kind,
+ height: handle.height,
+ };
+ }),
+ // T3-CUSTOM(expbkt3): END
+
t3_dispatch_command: Effect.fn("T3ControlToolkit.dispatchCommand")(function* (input) {
const operation = "dispatch-command";
yield* requireExternalOperator(operation);
diff --git a/apps/server/src/mcp/toolkits/control/tools.ts b/apps/server/src/mcp/toolkits/control/tools.ts
index 64c314658b8c..fffda16430ab 100644
--- a/apps/server/src/mcp/toolkits/control/tools.ts
+++ b/apps/server/src/mcp/toolkits/control/tools.ts
@@ -30,6 +30,8 @@ import {
PlannotatorManager,
PlannotatorPlanFormat,
} from "../../../plannotator/PlannotatorManager.ts";
+// T3-CUSTOM(expbkt3): agent-rendered UI surfaces in chat.
+import { AgentUiService } from "../../../agentui/AgentUiService.ts";
import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts";
import { ServerSettingsService } from "../../../serverSettings.ts";
import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts";
@@ -52,6 +54,7 @@ const dependencies = [
Crypto.Crypto,
];
const plannotatorDependencies = [...dependencies, PlannotatorManager];
+const agentUiDependencies = [...dependencies, AgentUiService];
const configurationDependencies = [...dependencies, ProviderRegistry, ServerSettingsService];
const ownershipDependencies = [ClerkDirectory, ServerConfig];
const projectDependencies = [
@@ -612,6 +615,43 @@ export const T3UnlinkSessionTool = mutatingTool(
);
// T3-CUSTOM(expbkt3): END
+// T3-CUSTOM(expbkt3): BEGIN — agent-rendered UI surfaces in chat.
+//
+// Named to stay clear of the provider adapters' substring classifier: anything
+// containing "create", "file", "agent" or "command" would be tagged as a file
+// change or subagent row instead of an MCP tool call.
+export const T3ShowUiTool = readonlyTool(
+ Tool.make("t3_show_ui", {
+ description:
+ "Render an interactive view inside the chat transcript: a sandboxed box the user sees inline, right where the tool call happened. Pass a self-contained HTML document in `html` (inline ",
+ "",
+ html,
+ "