diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts
index 2f7c24536caa..2da8015fce1e 100644
--- a/apps/server/src/binCli.ts
+++ b/apps/server/src/binCli.ts
@@ -21,6 +21,7 @@ import { projectCommand } from "./cli/project.ts";
import { threadCommand } from "./cli/thread.ts";
import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts";
import { updateCommand } from "./cli/update.ts";
+import { usageCommand } from "./cli/usage.ts";
import { uninstallCommand } from "./cli/uninstall.ts";
import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
import { claudeHistoryCommand } from "./cli/claudeHistory.ts";
@@ -78,6 +79,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
browserCommand,
projectCommand,
threadCommand,
+ usageCommand,
serviceCommand,
updateCommand,
uninstallCommand,
diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts
index 989028afccb9..28f5ce36fbfb 100644
--- a/apps/server/src/cli/thread.ts
+++ b/apps/server/src/cli/thread.ts
@@ -129,7 +129,7 @@ const isThreadCliError = Schema.is(ThreadCliError);
* These errors are expected outcomes for a script, so they print as one line
* on stderr instead of the runtime's logged stack trace.
*/
-const reportThreadErrors = (effect: Effect.Effect) =>
+export const reportThreadErrors = (effect: Effect.Effect) =>
effect.pipe(
Effect.tapError((error) =>
isThreadCliError(error) ? Console.error(error.message) : Effect.void,
@@ -138,7 +138,7 @@ const reportThreadErrors = (effect: Effect.Effect) =>
const decodeFailureText = Schema.decodeUnknownOption(Schema.fromJsonString(OrchestratorMcpFailure));
const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
-const printJson = (value: unknown) => Console.log(encodeJson(value));
+export const printJson = (value: unknown) => Console.log(encodeJson(value));
const ToolResult = Schema.Struct({
isError: Schema.optional(Schema.Boolean),
@@ -202,9 +202,13 @@ export type ThreadTools = ReturnType;
* Signs in as a short-lived MCP client of the running server, the same way an
* outside agent does, and revokes that session when the command ends.
*/
-const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* (
+export const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* (
flags: { readonly baseDir: Option.Option },
run: (tools: ThreadTools) => Effect.Effect,
+ client: { readonly label: string; readonly name: string } = {
+ label: "t3 thread cli",
+ name: "t3-thread-cli",
+ },
) {
const logLevel = yield* GlobalFlag.LogLevel;
const config = yield* resolveCliAuthConfig(flags, logLevel);
@@ -217,7 +221,7 @@ const withThreadTools = Effect.fn("cli.thread.withThreadTools")(function* new ThreadServerUnreachableError({ origin, cause })));
return yield* run(makeThreadTools(session, origin));
}).pipe(
diff --git a/apps/server/src/cli/usage.ts b/apps/server/src/cli/usage.ts
new file mode 100644
index 000000000000..6e38a97d092f
--- /dev/null
+++ b/apps/server/src/cli/usage.ts
@@ -0,0 +1,181 @@
+import {
+ OrchestratorMcpUsageLimitsResult,
+ OrchestratorMcpUsageSpendResult,
+ type OrchestratorMcpUsageWindow,
+} from "@t3tools/contracts";
+import { formatCount, formatPercent, formatTokens, formatUsd } from "@t3tools/shared/usageFormat";
+import { formatDuration } from "@t3tools/shared/usageLimits";
+import * as Console from "effect/Console";
+import * as Effect from "effect/Effect";
+import { Command, Flag } from "effect/cli";
+
+import { projectLocationFlags } from "./config.ts";
+import { printJson, reportThreadErrors, withThreadTools } from "./thread.ts";
+
+type Reading = Pick<
+ OrchestratorMcpUsageLimitsResult["providers"][number],
+ "ageMins" | "windows" | "unavailable"
+>;
+
+const MINUTE = 60_000;
+
+const formatWindow = (window: OrchestratorMcpUsageWindow) => {
+ const reset =
+ window.resetsInMins === null
+ ? ""
+ : window.resetsInMins === 0
+ ? " · resets now"
+ : ` · resets in ${formatDuration(window.resetsInMins * MINUTE)}`;
+ return ` ${window.label.padEnd(16)} ${String(window.remainingPercent).padStart(3)}% left${reset}`;
+};
+
+const formatReading = (title: string, reading: Reading) => [
+ reading.ageMins === null
+ ? title
+ : `${title} · read ${formatDuration(reading.ageMins * MINUTE)} ago`,
+ ...(reading.unavailable === null
+ ? reading.windows.map(formatWindow)
+ : [` ${reading.unavailable}`]),
+];
+
+const formatNotices = (notices: ReadonlyArray) =>
+ notices.length === 0 ? [] : ["Notices", ...notices.map((notice) => ` ${notice}`)];
+
+const formatLimits = (limits: OrchestratorMcpUsageLimitsResult) => {
+ const lines = [
+ ...limits.providers.flatMap((provider) =>
+ formatReading(
+ String(provider.providerInstanceId) === String(provider.driverKind)
+ ? provider.providerInstanceId
+ : `${provider.providerInstanceId} [${provider.driverKind}]`,
+ provider,
+ ),
+ ),
+ ...limits.pooledAccounts.flatMap((account) =>
+ formatReading(`${account.source} · ${account.accountId} [${account.driverKind}]`, account),
+ ),
+ ];
+ return [
+ ...(lines.length === 0 ? ["No providers are enabled."] : lines),
+ ...formatNotices(limits.notices),
+ ].join("\n");
+};
+
+const amount = (costUsd: number | null, totalTokens: number) =>
+ `${(costUsd === null ? "unpriced" : formatUsd(costUsd)).padStart(11)} ${formatTokens(totalTokens).padStart(6)} tokens`;
+
+const formatSpend = (spend: OrchestratorMcpUsageSpendResult) => {
+ const { window } = spend;
+ const range =
+ window.days === 1
+ ? "past 24 hours"
+ : `${window.days} days, ${window.sinceDay} to ${window.untilDay}`;
+ const width = Math.max(
+ 0,
+ ...[
+ ...spend.providers.map((provider) => provider.provider),
+ ...spend.models.map((model) => model.model),
+ ...spend.periods.map((period) => period.start),
+ ].map((label) => label.length),
+ );
+ return [
+ `Spend · ${range} (${window.timeZone}) · API estimate`,
+ ` ${formatUsd(spend.costUsd)} · ${formatTokens(spend.totalTokens)} tokens · ${formatCount(spend.sessions)} sessions${
+ spend.unpricedShare > 0 ? ` · ${formatPercent(spend.unpricedShare)} unpriced` : ""
+ }`,
+ " By provider",
+ ...spend.providers.map(
+ (provider) =>
+ ` ${provider.provider.padEnd(width)} ${amount(provider.costUsd, provider.totalTokens)} ${formatCount(provider.sessions)} sessions`,
+ ),
+ " By model",
+ ...spend.models.map(
+ (model) => ` ${model.model.padEnd(width)} ${amount(model.costUsd, model.totalTokens)}`,
+ ),
+ window.days === 1 ? " By hour" : " By day",
+ ...spend.periods.map(
+ (period) => ` ${period.start.padEnd(width)} ${formatUsd(period.costUsd).padStart(11)}`,
+ ),
+ ...formatNotices(spend.notices),
+ ].join("\n");
+};
+
+const jsonFlag = Flag.Boolean("json").pipe(
+ Flag.withDescription("Print JSON instead of text."),
+ Flag.withDefault(false),
+);
+
+const usageClient = { label: "t3 usage cli", name: "t3-usage-cli" };
+
+const usageLimitsCommand = Command.make("limits", {
+ ...projectLocationFlags,
+ refresh: Flag.Boolean("refresh").pipe(
+ Flag.withDescription("Re-read every provider before printing. Slower."),
+ Flag.withDefault(false),
+ ),
+ json: jsonFlag,
+}).pipe(
+ Command.withDescription(
+ "Show each provider's remaining subscription limits and when they reset.",
+ ),
+ Command.withHandler((flags) =>
+ withThreadTools(
+ flags,
+ Effect.fn("cli.usage.limits")(function* (tools) {
+ const limits = yield* tools.call(
+ "t3_usage_limits",
+ { refresh: flags.refresh },
+ OrchestratorMcpUsageLimitsResult,
+ );
+ if (flags.json) {
+ return yield* printJson(limits);
+ }
+ yield* Console.log(formatLimits(limits));
+ }),
+ usageClient,
+ ).pipe(reportThreadErrors),
+ ),
+);
+
+const usageSpendCommand = Command.make("spend", {
+ ...projectLocationFlags,
+ days: Flag.ChoiceWithValue("days", [
+ ["1", 1],
+ ["7", 7],
+ ["30", 30],
+ ["90", 90],
+ ] as const).pipe(
+ Flag.withDescription("Window in days, as on the Usage tab. 1 shows the past 24 hours."),
+ Flag.withDefault(30),
+ ),
+ refresh: Flag.Boolean("refresh").pipe(
+ Flag.withDescription("Wait for sources that are still loading. Slower."),
+ Flag.withDefault(false),
+ ),
+ json: jsonFlag,
+}).pipe(
+ Command.withDescription(
+ "Show the Usage tab's token and dollar spend for this environment, with days in the server's time zone.",
+ ),
+ Command.withHandler((flags) =>
+ withThreadTools(
+ flags,
+ Effect.fn("cli.usage.spend")(function* (tools) {
+ const spend = yield* tools.call(
+ "t3_usage_spend",
+ { days: flags.days, refresh: flags.refresh },
+ OrchestratorMcpUsageSpendResult,
+ );
+ yield* flags.json ? printJson(spend) : Console.log(formatSpend(spend));
+ }),
+ usageClient,
+ ).pipe(reportThreadErrors),
+ ),
+);
+
+export const usageCommand = Command.make("usage").pipe(
+ Command.withDescription(
+ "Show what the Usage tab shows, from the running server: limits or spend.",
+ ),
+ Command.withSubcommands([usageLimitsCommand, usageSpendCommand]),
+);
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index 5c6c4fece44b..862409f1c530 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -62,6 +62,7 @@ import {
import * as WorktreeHandlers from "./toolkits/worktree/handlers.ts";
import { WorktreeToolkit } from "./toolkits/worktree/tools.ts";
import * as WorktreeMcpService from "./WorktreeMcpService.ts";
+import * as UsageReport from "../usage/UsageReport.ts";
import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts";
import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts";
import * as DeviceHandlers from "./toolkits/device/handlers.ts";
@@ -1011,7 +1012,11 @@ export const layerPreviewToolkit = Layer.mergeAll(
export const layerOrchestratorToolkit = toolkitRegistration(
OrchestratorToolkit,
OrchestratorHandlers.layer,
-).pipe(Layer.provide(OrchestratorMcpService.layer), Layer.provide(ThreadMetadataMcpService.layer));
+).pipe(
+ Layer.provide(OrchestratorMcpService.layer),
+ Layer.provide(ThreadMetadataMcpService.layer),
+ Layer.provide(UsageReport.layer),
+);
export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer);
diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
index 09b4bd4f424d..997d01467689 100644
--- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
+++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
@@ -44,12 +44,18 @@ import * as ProjectService from "../project/ProjectService.ts";
import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
+import * as UsageLimitSources from "../usage/UsageLimitSources.ts";
+import * as UsageService from "../usage/UsageService.ts";
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as McpHttpServer from "./McpHttpServer.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
+ Layer.provide(Layer.mock(UsageLimitSources.UsageLimitSources)({})),
+ Layer.provide(Layer.mock(UsageService.UsageService)({})),
+ Layer.provide(Layer.mock(ServerEnvironment.ServerEnvironment)({})),
);
// Effect returns a declared tool failure as `isError` with its encoded payload
diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts
index 02216cec576d..f58c7cb80db5 100644
--- a/apps/server/src/mcp/toolkits/core.test.ts
+++ b/apps/server/src/mcp/toolkits/core.test.ts
@@ -51,10 +51,16 @@ import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts";
import { dispatchFailure } from "../threadAccess.ts";
import * as EnvironmentHandlers from "./environment/handlers.ts";
import * as AttachmentHandlers from "./attachment/handlers.ts";
+import * as UsageLimitSources from "../../usage/UsageLimitSources.ts";
+import * as UsageService from "../../usage/UsageService.ts";
+import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";
const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe(
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
+ Layer.provide(Layer.mock(UsageLimitSources.UsageLimitSources)({})),
+ Layer.provide(Layer.mock(UsageService.UsageService)({})),
+ Layer.provide(Layer.mock(ServerEnvironment.ServerEnvironment)({})),
);
// Effect returns a declared tool failure as `isError` with its encoded payload
diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts
index 0df604a85011..703eedbe81a9 100644
--- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts
+++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts
@@ -7,6 +7,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as McpToolAccess from "../../McpToolAccess.ts";
import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";
+import * as UsageReport from "../../../usage/UsageReport.ts";
const handlers = {
orchestrator_capabilities: McpToolAccess.reads((input) =>
@@ -16,6 +17,28 @@ const handlers = {
return yield* service.capabilities(scope, input);
}),
),
+ t3_usage_limits: McpToolAccess.reads((input) =>
+ Effect.gen(function* () {
+ const report = yield* UsageReport.UsageReport;
+ return yield* report.readLimits({ refresh: input.refresh === true });
+ }),
+ ),
+ t3_usage_spend: McpToolAccess.reads((input) =>
+ Effect.gen(function* () {
+ const report = yield* UsageReport.UsageReport;
+ return yield* report
+ .readSpend({
+ days: input.days ?? 30,
+ refresh: input.refresh === true,
+ })
+ .pipe(
+ Effect.mapError(
+ (error) =>
+ new OrchestratorMcpFailure({ code: "orchestration_error", message: error.detail }),
+ ),
+ );
+ }),
+ ),
delegate_task: McpToolAccess.actsAsCaller((input) =>
Effect.gen(function* () {
const scope = yield* McpInvocationContext.McpInvocationContext;
diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts
index 111870be9bbd..03f79eeebecb 100644
--- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts
+++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts
@@ -18,6 +18,10 @@ import {
OrchestratorMcpTaskCancelInput,
OrchestratorMcpTaskCancelResult,
OrchestratorMcpUpdateScheduledTaskInput,
+ OrchestratorMcpUsageLimitsInput,
+ OrchestratorMcpUsageLimitsResult,
+ OrchestratorMcpUsageSpendInput,
+ OrchestratorMcpUsageSpendResult,
OrchestratorMcpTaskStatusInput,
OrchestratorMcpThreadInterruptInput,
OrchestratorMcpThreadInterruptResult,
@@ -36,6 +40,7 @@ import { Tool, Toolkit } from "effect/ai";
import * as Schema from "effect/Schema";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as UsageReport from "../../../usage/UsageReport.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts";
@@ -65,6 +70,34 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, true);
+const UsageLimitsTool = Tool.make("t3_usage_limits", {
+ description:
+ "Read each enabled provider instance's subscription limits, such as Codex and Claude, to compare headroom before choosing where to start or delegate work. Each window gives remainingPercent and resetsInMins; a label such as 'Weekly · Fable' marks a window that applies to one model. unavailable explains an empty windows list: no subscription limits (API key or Bedrock), a failed read, or a provider that is not installed, signed in, or healthy. Limits can be old while no T3 client is open; ageMins says how old. pooledAccounts lists accounts from usage limit sources such as a CLI Proxy hub. For token and dollar spend, use t3_usage_spend.",
+ parameters: OrchestratorMcpUsageLimitsInput,
+ success: OrchestratorMcpUsageLimitsResult,
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies: [UsageReport.UsageReport],
+})
+ .annotate(Tool.Title, "Get usage limits")
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false)
+ .annotate(Tool.Idempotent, true);
+
+const UsageSpendTool = Tool.make("t3_usage_spend", {
+ description:
+ "Read the token and API-priced USD spend the Usage tab calculates from this environment's provider transcripts: totals, then cost and tokens by provider and model, and cost by day (or hour for days=1) in the server's time zone. notices say when a source is missing or still loading. For remaining quota and reset times, use t3_usage_limits.",
+ parameters: OrchestratorMcpUsageSpendInput,
+ success: OrchestratorMcpUsageSpendResult,
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies: [UsageReport.UsageReport],
+})
+ .annotate(Tool.Title, "Get usage spend")
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false)
+ .annotate(Tool.Idempotent, true);
+
const DelegateTaskTool = Tool.make("delegate_task", {
description:
"Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model and no model options are requested or inherited. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Set isolation to worktree for a separate checkout based on the parent local commit, with project setup completed before the child starts. Uncommitted edits are not copied; omitted isolation shares the parent checkout. Use delegate_task when model options are requested or the child keeps the parent's provider instance and model with nonempty inheritedOptions from orchestrator_capabilities, even for native-supported models. Native subagents cannot reliably apply T3 model options. Set target.options using advertised IDs: Claude {effort: 'max', fastMode: true}; Codex {reasoningEffort: 'max', serviceTier: 'priority'}. Check providerInstanceId, model, and options in the result. Provider/model and modes inherit unless overridden. Options inherit only for the same instance and model; explicit entries override individual values and [] or {} clears them. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. Leave runtimeMode unset unless the user asks for a supervised child: approval-required and auto-accept-edits usually make the child wait for a person at each action that needs approval. Prefer mode='async' for long work; mode='wait' blocks until completion, a blocking question, a pending approval, or timeout. If hasPendingQuestion, pass threadId=childThreadId to t3_pending_request_list, t3_pending_request_read (with requestId) and t3_pending_request_respond, or ask the user; the task's completion still wakes this thread. If pendingApproval is set, read the fresh full request in childThreadId before responding: the record can outlive its provider callback. It grants no approval authority; decisions remain with the child's existing approval holder, and pending request tools answer questions only. The child keeps running and its completion still wakes this thread. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.",
@@ -275,6 +308,8 @@ const ThreadInterruptTool = Tool.make("t3_thread_interrupt", {
export const OrchestratorToolkit = Toolkit.make(
OrchestratorCapabilitiesTool,
+ UsageLimitsTool,
+ UsageSpendTool,
DelegateTaskTool,
TaskStatusTool,
TaskCancelTool,
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index 97ebde00ac27..175993e37e4b 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -1438,6 +1438,8 @@ export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*";
// ClaudeAdapterV2.test.ts cross-checks this list against the toolkit.
export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [
"mcp__t3-code__orchestrator_capabilities",
+ "mcp__t3-code__t3_usage_limits",
+ "mcp__t3-code__t3_usage_spend",
"mcp__t3-code__list_scheduled_tasks",
"mcp__t3-code__t3_thread_list",
"mcp__t3-code__t3_thread_wait",
diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts
index a278470c286d..cd82c7ce66be 100644
--- a/apps/server/src/provider/ProviderRegistry.test.ts
+++ b/apps/server/src/provider/ProviderRegistry.test.ts
@@ -1464,6 +1464,108 @@ it.layer(
}),
);
+ it.effect("a fresh refresh re-probes past a cached reading and an in-flight pass", () =>
+ Effect.gen(function* () {
+ const driver = ProviderDriverKind.make("claudeAgent");
+ const instanceId = ProviderInstanceId.make("claudeAgent");
+ const provider = (checkedAt: string) =>
+ ({
+ instanceId,
+ driver,
+ status: "ready",
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ checkedAt,
+ version: "1.0.0",
+ models: [],
+ slashCommands: [],
+ skills: [],
+ }) as const satisfies ServerProvider;
+ const probes = yield* Ref.make(0);
+ const cached = yield* Ref.make(null);
+ const firstProbeStarted = yield* Deferred.make();
+ const releaseFirstProbe = yield* Deferred.make();
+ const instances = [
+ {
+ instanceId,
+ driverKind: driver,
+ continuationIdentity: { driverKind: driver, continuationKey: "claude:instance" },
+ displayName: undefined,
+ enabled: true,
+ snapshot: {
+ resolveMaintenance: () =>
+ Effect.succeed(
+ makeManualOnlyProviderMaintenanceCapabilities({
+ provider: driver,
+ packageName: null,
+ }),
+ ),
+ getSnapshot: Effect.succeed(provider("2026-06-10T00:00:00.000Z")),
+ refresh: Effect.gen(function* () {
+ const hit = yield* Ref.get(cached);
+ if (hit !== null) return provider(hit);
+ const probe = yield* Ref.updateAndGet(probes, (count) => count + 1);
+ const checkedAt = `2026-06-10T00:0${probe}:00.000Z`;
+ yield* Ref.set(cached, checkedAt);
+ if (probe === 1) {
+ yield* Deferred.succeed(firstProbeStarted, undefined);
+ yield* Deferred.await(releaseFirstProbe);
+ }
+ return provider(checkedAt);
+ }),
+ streamChanges: Stream.empty,
+ applyUsageLimits: () => Effect.void,
+ },
+ invalidateCaches: Ref.set(cached, null),
+ orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
+ textGeneration: {} as ProviderInstance["textGeneration"],
+ },
+ ] satisfies ReadonlyArray;
+ const instanceRegistryLayer = Layer.succeed(
+ ProviderInstanceRegistry.ProviderInstanceRegistry,
+ {
+ getInstance: (requestedId) =>
+ Effect.succeed(instances.find((instance) => instance.instanceId === requestedId)),
+ listInstances: Effect.succeed(instances),
+ listUnavailable: Effect.succeed([]),
+ streamChanges: Stream.empty,
+ subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe),
+ },
+ );
+ const scope = yield* Scope.make();
+ yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
+ const runtimeServices = yield* Layer.build(
+ ProviderRegistry.layer.pipe(
+ Layer.provideMerge(instanceRegistryLayer),
+ Layer.provideMerge(
+ ServerConfig.layerTest(process.cwd(), {
+ prefix: "t3-provider-registry-fresh-refresh-",
+ }),
+ ),
+ Layer.provideMerge(NodeServices.layer),
+ ),
+ ).pipe(Scope.provide(scope));
+
+ yield* Effect.gen(function* () {
+ const registry = yield* ProviderRegistry.ProviderRegistry;
+ const checkedAt = (providers: ReadonlyArray) =>
+ providers.find((candidate) => candidate.instanceId === instanceId)?.checkedAt;
+ const inFlight = yield* registry.refresh().pipe(Effect.forkChild);
+ yield* Deferred.await(firstProbeStarted);
+ assert.strictEqual(
+ checkedAt(yield* registry.refresh(undefined, { fresh: true })),
+ "2026-06-10T00:02:00.000Z",
+ );
+ yield* Deferred.succeed(releaseFirstProbe, undefined);
+ yield* Fiber.join(inFlight);
+ assert.strictEqual(yield* Ref.get(probes), 2);
+ yield* registry.refresh();
+ assert.strictEqual(yield* Ref.get(probes), 2);
+ }).pipe(Effect.provide(runtimeServices));
+ }),
+ );
+
it.effect("persists the merged snapshot when a live update has empty models", () =>
Effect.gen(function* () {
const cursorDriver = ProviderDriverKind.make("cursor");
diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts
index 97da44aa93ed..e723be37e66e 100644
--- a/apps/server/src/provider/ProviderRegistry.ts
+++ b/apps/server/src/provider/ProviderRegistry.ts
@@ -53,6 +53,7 @@ export class ProviderRegistry extends Context.Service<
readonly refresh: (
provider?: ProviderDriverKind,
+ options?: { readonly fresh?: boolean },
) => Effect.Effect>;
/**
@@ -646,7 +647,27 @@ export const layer = Layer.effect(
return yield* Deferred.await(claimed.completion);
});
- const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) {
+ const refresh = Effect.fn("refresh")(function* (
+ provider?: ProviderDriverKind,
+ options?: { readonly fresh?: boolean },
+ ) {
+ if (options?.fresh) {
+ const instances = yield* instanceRegistry.listInstances;
+ yield* Effect.forEach(
+ instances.filter(
+ (instance) => provider === undefined || instance.driverKind === provider,
+ ),
+ (instance) => instance.invalidateCaches ?? Effect.void,
+ { discard: true },
+ );
+ }
+ if (provider === undefined && options?.fresh) {
+ yield* Effect.forEach(yield* getLiveSources, refreshOneSource, {
+ concurrency: "unbounded",
+ discard: true,
+ });
+ return yield* Ref.get(providersRef);
+ }
if (provider === undefined) {
return yield* refreshAll();
}
@@ -1010,8 +1031,8 @@ export const layer = Layer.effect(
return {
getProviders: Ref.get(providersRef),
- refresh: (provider?: ProviderDriverKind) =>
- refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
+ refresh: (provider?: ProviderDriverKind, options?: { readonly fresh?: boolean }) =>
+ refresh(provider, options).pipe(Effect.catchCause(recoverRefreshFailure)),
refreshInstance: (instanceId: ProviderInstanceId) =>
refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
refreshWorkspaceSnapshot: (input) =>
diff --git a/apps/server/src/usage/UsageReport.test.ts b/apps/server/src/usage/UsageReport.test.ts
new file mode 100644
index 000000000000..8bec2f96fe2a
--- /dev/null
+++ b/apps/server/src/usage/UsageReport.test.ts
@@ -0,0 +1,207 @@
+import { assert, it } from "@effect/vitest";
+import {
+ EnvironmentId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ USAGE_CONTRACT_VERSION,
+ UsageDay,
+ UsageLimitSourceId,
+ type ServerProvider,
+ type ServerProviderUsageLimits,
+ type UsageBucket,
+ type UsageLimitSourceSnapshot,
+ type UsageSource,
+ type UsageSummaryInput,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as TestClock from "effect/testing/TestClock";
+
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as ProviderRegistryMock from "../provider/testUtils/providerRegistryMock.ts";
+import * as UsageLimitSources from "./UsageLimitSources.ts";
+import * as UsageReport from "./UsageReport.ts";
+import * as UsageService from "./UsageService.ts";
+
+const checkedAt = "2026-10-21T10:00:00.000Z";
+
+const limits: ServerProviderUsageLimits = {
+ checkedAt,
+ windows: [
+ {
+ id: "five_hour",
+ kind: "session",
+ label: "5h",
+ usedPercent: 27.6,
+ resetsAt: "2026-10-21T12:00:00.000Z",
+ windowDurationMins: 300,
+ },
+ { id: "seven_day", kind: "weekly", label: "Weekly", usedPercent: 60 },
+ ],
+};
+
+const provider = (id: string, overrides: Partial = {}): ServerProvider => ({
+ instanceId: ProviderInstanceId.make(id),
+ driver: ProviderDriverKind.make(id),
+ enabled: true,
+ installed: true,
+ version: null,
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt,
+ models: [],
+ slashCommands: [],
+ skills: [],
+ ...overrides,
+});
+
+const bucket: UsageBucket = {
+ day: UsageDay.make("2026-10-20"),
+ provider: "codex",
+ model: "gpt-5.5",
+ totals: {
+ uncachedInputTokens: 1_000,
+ cachedInputTokens: 9_000,
+ cacheCreationTokens: 0,
+ outputTokens: 500,
+ reasoningTokens: 200,
+ },
+ costUsd: 1.005,
+ cacheSavingsUsd: 0.5,
+ costSource: "modelPriced",
+ records: 4,
+ unpricedRecords: 0,
+ sessions: 1,
+};
+
+const source = (usageProvider: UsageSource["fingerprint"]["provider"]): UsageSource => ({
+ fingerprint: {
+ hostId: "host",
+ provider: usageProvider,
+ resolvedHomePath: `/home/dev/.${usageProvider}`,
+ volumeId: "1:1",
+ },
+ status: "ok",
+ scannedFiles: 1,
+ skippedFiles: 0,
+ malformedRecords: 0,
+ distinctSessions: 2,
+ message: null,
+});
+
+const layerUsage = (requests: Array): Layer.Layer =>
+ Layer.mock(UsageService.UsageService)({
+ readSummary: (input) =>
+ Effect.sync(() => {
+ requests.push(input);
+ return {
+ contractVersion: USAGE_CONTRACT_VERSION,
+ readAt: checkedAt,
+ timeZone: input.timeZone,
+ sinceDay: input.sinceDay,
+ untilDay: input.untilDay,
+ buckets: [bucket],
+ sources: [source("codex")],
+ pricing: { status: "fresh", source: "test", fetchedAt: checkedAt, knownModels: 1 },
+ scanDurationMs: 0,
+ };
+ }),
+ });
+
+interface Dependencies {
+ readonly registry?: Layer.Layer;
+ readonly sources?: ReadonlyArray;
+ readonly usage?: Layer.Layer;
+}
+
+const layerReport = (dependencies: Dependencies) =>
+ UsageReport.layer.pipe(
+ Layer.provide(dependencies.registry ?? ProviderRegistryMock.layer([])),
+ Layer.provide(
+ Layer.mock(UsageLimitSources.UsageLimitSources)({
+ current: Effect.succeed(dependencies.sources ?? []),
+ refresh: Effect.void,
+ }),
+ ),
+ Layer.provide(dependencies.usage ?? layerUsage([])),
+ Layer.provide(
+ Layer.mock(ServerEnvironment.ServerEnvironment)({
+ getDescriptor: Effect.succeed({
+ environmentId: EnvironmentId.make("usage-report-test"),
+ label: "Test environment",
+ platform: { os: "linux", arch: "x64" },
+ serverVersion: "0.0.0-test",
+ capabilities: { repositoryIdentity: true },
+ }),
+ }),
+ ),
+ );
+
+const readLimits = (dependencies: Dependencies) =>
+ Effect.flatMap(UsageReport.UsageReport, (report) => report.readLimits({ refresh: false })).pipe(
+ Effect.provide(layerReport(dependencies)),
+ );
+
+const readSpend = (dependencies: Dependencies, days: 1 | 7 | 30 | 90) =>
+ Effect.flatMap(UsageReport.UsageReport, (report) =>
+ report.readSpend({ days, refresh: false }),
+ ).pipe(Effect.provide(layerReport(dependencies)));
+
+it.effect("reads limits from the provider registry and limit hubs", () =>
+ Effect.gen(function* () {
+ yield* TestClock.setTime(Date.parse("2026-10-21T12:00:00Z"));
+ const report = yield* readLimits({
+ registry: ProviderRegistryMock.layer([provider("claudeAgent", { usageLimits: limits })]),
+ sources: [
+ {
+ id: UsageLimitSourceId.make("hub"),
+ kind: "cliproxy",
+ label: "Team hub",
+ checkedAt,
+ accounts: [
+ {
+ id: "codex-1.json",
+ driver: ProviderDriverKind.make("codex"),
+ usageLimits: limits,
+ },
+ ],
+ },
+ ],
+ });
+
+ assert.deepStrictEqual(report.providers, [
+ {
+ providerInstanceId: ProviderInstanceId.make("claudeAgent"),
+ driverKind: ProviderDriverKind.make("claudeAgent"),
+ ageMins: 120,
+ windows: [
+ { kind: "session", label: "5h", remainingPercent: 72, resetsInMins: 0 },
+ { kind: "weekly", label: "Weekly", remainingPercent: 40, resetsInMins: null },
+ ],
+ unavailable: null,
+ },
+ ]);
+ assert.deepStrictEqual(
+ report.pooledAccounts.map((account) => [account.source, account.accountId]),
+ [["Team hub", "codex-1.json"]],
+ );
+ }),
+);
+
+it.effect("reads spend from the Usage tab's summary for the requested window", () =>
+ Effect.gen(function* () {
+ yield* TestClock.setTime(Date.parse("2026-10-21T12:00:00Z"));
+ const requests: Array = [];
+ const spend = yield* readSpend({ usage: layerUsage(requests) }, 7);
+
+ assert.deepStrictEqual(
+ [requests[0]?.sinceDay, requests[0]?.untilDay, requests[0]?.resolution],
+ ["2026-10-15", "2026-10-21", "day"],
+ );
+ assert.deepStrictEqual(
+ spend.providers.map((entry) => [entry.provider, entry.costUsd, entry.totalTokens]),
+ [["codex", 1.01, 10_500]],
+ );
+ }),
+);
diff --git a/apps/server/src/usage/UsageReport.ts b/apps/server/src/usage/UsageReport.ts
new file mode 100644
index 000000000000..ed4418022209
--- /dev/null
+++ b/apps/server/src/usage/UsageReport.ts
@@ -0,0 +1,208 @@
+import {
+ isProviderAvailable,
+ type EnvironmentId,
+ USAGE_CONTRACT_VERSION,
+ type OrchestratorMcpUsageLimitsResult,
+ type OrchestratorMcpUsageSpendResult,
+ type ServerProvider,
+ type ServerProviderUsageLimits,
+ type UsageSummary,
+ type UsageTokenTotals,
+ type UsageLimitSourceSnapshot,
+ type UsageReadError,
+} from "@t3tools/contracts";
+import { makeWindow } from "@t3tools/shared/usageFormat";
+import { limitsNotice, remainingPercent } from "@t3tools/shared/usageLimits";
+import { isModelCostUnknown, mergeUsage } from "@t3tools/shared/usageMerge";
+import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+
+import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
+import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
+import * as UsageLimitSources from "./UsageLimitSources.ts";
+import * as UsageService from "./UsageService.ts";
+
+type Reading = Pick<
+ OrchestratorMcpUsageLimitsResult["providers"][number],
+ "ageMins" | "windows" | "unavailable"
+>;
+type SpendDays = OrchestratorMcpUsageSpendResult["window"]["days"];
+
+const minutes = (milliseconds: number) => Math.max(0, Math.round(milliseconds / 60_000));
+
+const reading = (limits: ServerProviderUsageLimits, now: number): Reading => ({
+ ageMins: minutes(now - Date.parse(limits.checkedAt)),
+ windows: limits.windows.map((window) => ({
+ kind: window.kind,
+ label: window.label,
+ remainingPercent: remainingPercent(window),
+ resetsInMins: window.resetsAt === undefined ? null : minutes(Date.parse(window.resetsAt) - now),
+ })),
+ unavailable: limitsNotice(limits),
+});
+
+const providerReading = (provider: ServerProvider, now: number): Reading => {
+ const unusable = !provider.installed
+ ? "This provider is not installed."
+ : !isProviderAvailable(provider)
+ ? (provider.unavailableReason ?? provider.message ?? "This provider is unavailable.")
+ : provider.status === "error" || provider.status === "disabled"
+ ? (provider.message ?? `This provider's status is ${provider.status}.`)
+ : provider.auth.status === "unauthenticated"
+ ? "This provider is not signed in."
+ : null;
+ if (unusable !== null) {
+ return { ageMins: null, windows: [], unavailable: unusable };
+ }
+ if (provider.usageLimits === undefined) {
+ return {
+ ageMins: null,
+ windows: [],
+ unavailable: "This provider has not reported usage limits.",
+ };
+ }
+ return reading(provider.usageLimits, now);
+};
+
+const summarizeLimits = (
+ providers: ReadonlyArray,
+ sources: ReadonlyArray,
+ now: number,
+): OrchestratorMcpUsageLimitsResult => ({
+ providers: providers
+ .filter((provider) => provider.enabled)
+ .map((provider) => ({
+ providerInstanceId: provider.instanceId,
+ driverKind: provider.driver,
+ ...providerReading(provider, now),
+ })),
+ pooledAccounts: sources.flatMap((source) =>
+ source.accounts.map((account) => ({
+ source: source.label,
+ accountId: account.id,
+ driverKind: account.driver,
+ ...reading(account.usageLimits, now),
+ })),
+ ),
+ notices: sources.flatMap((source) =>
+ source.error === undefined ? [] : [`${source.label}: ${source.error}`],
+ ),
+});
+
+const CENTS = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2, useGrouping: false });
+const usd = (value: number) => Number(CENTS.format(value));
+
+const totalTokens = (totals: UsageTokenTotals) =>
+ totals.uncachedInputTokens +
+ totals.cachedInputTokens +
+ totals.cacheCreationTokens +
+ totals.outputTokens;
+
+const summarizeSpend = (
+ summary: UsageSummary,
+ environment: { readonly environmentId: EnvironmentId; readonly label: string },
+ days: SpendDays,
+): OrchestratorMcpUsageSpendResult => {
+ const merged = mergeUsage([{ ...environment, summary }], USAGE_CONTRACT_VERSION);
+ return {
+ window: {
+ days,
+ sinceDay: summary.sinceDay,
+ untilDay: summary.untilDay,
+ timeZone: summary.timeZone,
+ },
+ costUsd: usd(merged.costUsd),
+ totalTokens: totalTokens(merged),
+ sessions: merged.sessions,
+ unpricedShare: Math.round(merged.costQuality.unpricedShare * 1000) / 1000,
+ providers: merged.providers.map((provider) => ({
+ provider: provider.provider,
+ costUsd: usd(provider.costUsd),
+ totalTokens: provider.totalTokens,
+ sessions: provider.sessions,
+ })),
+ models: merged.models.map((model) => ({
+ provider: model.provider,
+ model: model.model,
+ costUsd: isModelCostUnknown(model) ? null : usd(model.costUsd),
+ totalTokens: totalTokens(model.tokens),
+ })),
+ periods:
+ days === 1
+ ? merged.hourly.map((period) => ({ start: period.hourStart, costUsd: usd(period.costUsd) }))
+ : merged.daily.map((period) => ({ start: period.day, costUsd: usd(period.costUsd) })),
+ notices: summary.sources.flatMap((source) => [
+ ...(source.message !== null &&
+ (source.status === "partial" ||
+ source.status === "failed" ||
+ source.fingerprint.provider === "cursor")
+ ? [source.message]
+ : []),
+ ...(source.refreshing
+ ? [
+ `${source.fingerprint.provider} usage is still being read, so spend leaves it out. Pass refresh: true to wait for it.`,
+ ]
+ : []),
+ ]),
+ };
+};
+
+export class UsageReport extends Context.Service<
+ UsageReport,
+ {
+ readonly readLimits: (input: {
+ readonly refresh: boolean;
+ }) => Effect.Effect;
+ readonly readSpend: (input: {
+ readonly days: SpendDays;
+ readonly refresh: boolean;
+ }) => Effect.Effect;
+ }
+>()("t3/usage/UsageReport") {}
+
+const make = Effect.gen(function* () {
+ const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
+ const usageLimitSources = yield* UsageLimitSources.UsageLimitSources;
+ const usage = yield* UsageService.UsageService;
+ const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
+
+ const readLimits = Effect.fn("UsageReport.readLimits")(function* (input: {
+ readonly refresh: boolean;
+ }) {
+ if (input.refresh) {
+ yield* Effect.all(
+ [providerRegistry.refresh(undefined, { fresh: true }), usageLimitSources.refresh],
+ { concurrency: "unbounded", discard: true },
+ );
+ }
+ const [providers, sources] = yield* Effect.all([
+ providerRegistry.getProviders,
+ usageLimitSources.current,
+ ]);
+ return summarizeLimits(providers, sources, DateTime.toEpochMillis(yield* DateTime.now));
+ });
+
+ const readSpend = Effect.fn("UsageReport.readSpend")(function* (input: {
+ readonly days: SpendDays;
+ readonly refresh: boolean;
+ }) {
+ const now = yield* DateTime.now;
+ const window = makeWindow(
+ input.days,
+ DateTime.toDateUtc(now),
+ input.days === 1 ? "hour" : "day",
+ );
+ const summary = yield* usage.readSummary({
+ ...window,
+ ...(input.refresh ? { awaitRefresh: true } : {}),
+ });
+ const environment = yield* serverEnvironment.getDescriptor;
+ return summarizeSpend(summary, environment, input.days);
+ });
+
+ return UsageReport.of({ readLimits, readSpend });
+});
+
+export const layer = Layer.effect(UsageReport, make);
diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md
index 5f312b61bd2a..ce2b78cd73b4 100644
--- a/docs/orchestration-v2/orchestrator-mcp-server.md
+++ b/docs/orchestration-v2/orchestrator-mcp-server.md
@@ -215,6 +215,33 @@ user's model order for other providers. `omittedModels` counts the rest, and the
`search` input finds them, legacy models included. `delegate_task` still accepts
any catalog model.
+### `t3_usage_limits` and `t3_usage_spend`
+
+The Usage tab's two halves, split so an agent that only needs headroom before
+choosing a target pays for a small limits response, not a spend report.
+
+`t3_usage_limits` returns the subscription windows the provider registry
+already holds: each driver's own probe (Codex app-server
+`account/rateLimits/read`, the Claude Agent SDK's `get_usage`), turn-time
+rate-limit events, and any configured usage limit sources. Snapshots only
+re-probe while a client is in the foreground, so `refresh: true` clears driver
+caches and re-reads every provider before answering.
+
+`t3_usage_spend` is `UsageService.readSummary` for the Usage tab's window
+(`makeWindow`), merged with the tab's own `mergeUsage`, so the totals match the
+tab when it shows only this environment, hides no providers, and runs in the
+server's time zone. The tool reports the environment that serves it, every
+provider, and days in the server's zone; the tab can merge environments, hide
+providers, and uses the viewer's zone.
+
+Both answer what an agent decides with, not everything the tab draws: reset
+and read times as minutes from now, no account email, and spend without the
+tab's token-type, category, speed, and per-period provider splits. Every agent
+pays for a tool's output schema on every turn, so there is no `detail` switch
+for the rest; the tab shows it.
+
+`t3 usage limits` and `t3 usage spend` print the same results.
+
### `delegate_task`
Creates a T3-owned child thread and immediately dispatches the supplied task
diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md
index f1503ef49b22..ce39bc96e2de 100644
--- a/docs/user/outside-agents.md
+++ b/docs/user/outside-agents.md
@@ -114,6 +114,14 @@ the message to read it from stdin.
2 when the agent waits for an answer you give in the app. Add `--json` for a
result that always includes the thread id.
+`t3 usage limits` prints each provider's remaining subscription limits and when
+they reset, so a script can pick the provider with the most headroom.
+`t3 usage spend` prints the Usage tab's token and dollar spend for the last 30
+days; `--days 1|7|30|90` picks another window. Spend counts days in the
+server's time zone and includes every provider, even ones hidden on the Usage
+tab. `--refresh` reads again first, and `--json` prints the same result agents
+get from the `t3_usage_limits` and `t3_usage_spend` tools.
+
## Remove an agent
Approved agents appear under **Settings → Connections** like other clients.
diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts
index df655c1d6c21..86473bab8af1 100644
--- a/packages/client-runtime/src/t3ToolSummary.ts
+++ b/packages/client-runtime/src/t3ToolSummary.ts
@@ -440,6 +440,12 @@ export function summarizeT3ToolCalls(
case "capabilities":
label = phrase("Checked", "check", `orchestration capabilities ${times}`);
break;
+ case "usage-limits":
+ label = phrase("Checked", "check", `usage limits ${times}`);
+ break;
+ case "usage-spend":
+ label = phrase("Checked", "check", `usage spend ${times}`);
+ break;
}
return { label, failedCount };
}
diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts
index c962c3975c17..564db2eb99f8 100644
--- a/packages/contracts/src/orchestratorMcp.ts
+++ b/packages/contracts/src/orchestratorMcp.ts
@@ -39,6 +39,7 @@ import {
} from "./orchestrationV2.ts";
import { ProviderOptionSelection, ProviderOptionSelectionValue } from "./model.ts";
import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
+import { UsageProviderKind } from "./usage.ts";
const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)).annotate({
description: "Complete task or message text for the target agent.",
@@ -582,6 +583,104 @@ export const OrchestratorMcpCapabilitiesResult = Schema.Struct({
});
export type OrchestratorMcpCapabilitiesResult = typeof OrchestratorMcpCapabilitiesResult.Type;
+export const OrchestratorMcpUsageLimitsInput = Schema.Struct({
+ refresh: Schema.optional(
+ Schema.Boolean.annotate({
+ description:
+ "Re-read every provider before answering. Slower; use when ageMins is too old to trust.",
+ }),
+ ),
+});
+export type OrchestratorMcpUsageLimitsInput = typeof OrchestratorMcpUsageLimitsInput.Type;
+
+export const OrchestratorMcpUsageWindow = Schema.Struct({
+ kind: Schema.Literals(["session", "weekly", "monthly", "other"]),
+ label: Schema.String,
+ remainingPercent: Schema.Finite,
+ resetsInMins: Schema.NullOr(Schema.Finite),
+}).annotate({ identifier: "UsageLimitWindow" });
+export type OrchestratorMcpUsageWindow = typeof OrchestratorMcpUsageWindow.Type;
+
+const OrchestratorMcpUsageReading = {
+ ageMins: Schema.NullOr(Schema.Finite).annotate({
+ description: "Minutes since these limits were read.",
+ }),
+ windows: Schema.Array(OrchestratorMcpUsageWindow),
+ unavailable: Schema.NullOr(Schema.String).annotate({ description: "Why windows is empty." }),
+};
+
+export const OrchestratorMcpUsageLimitsResult = Schema.Struct({
+ providers: Schema.Array(
+ Schema.Struct({
+ providerInstanceId: ProviderInstanceId,
+ driverKind: ProviderDriverKind,
+ ...OrchestratorMcpUsageReading,
+ }),
+ ),
+ pooledAccounts: Schema.Array(
+ Schema.Struct({
+ source: Schema.String,
+ accountId: Schema.String,
+ driverKind: ProviderDriverKind,
+ ...OrchestratorMcpUsageReading,
+ }),
+ ),
+ notices: Schema.Array(Schema.String),
+});
+export type OrchestratorMcpUsageLimitsResult = typeof OrchestratorMcpUsageLimitsResult.Type;
+
+const OrchestratorMcpUsageAmount = {
+ costUsd: Schema.Finite,
+ totalTokens: Schema.Finite,
+};
+
+export const OrchestratorMcpUsageSpendInput = Schema.Struct({
+ days: Schema.optional(
+ Schema.Literals([1, 7, 30, 90]).annotate({
+ description:
+ "As on the Usage tab: 1 is the past 24 hours by hour, otherwise calendar days ending today. Defaults to 30.",
+ }),
+ ),
+ refresh: Schema.optional(
+ Schema.Boolean.annotate({
+ description: "Wait for sources that are still loading. Slower.",
+ }),
+ ),
+});
+export type OrchestratorMcpUsageSpendInput = typeof OrchestratorMcpUsageSpendInput.Type;
+
+export const OrchestratorMcpUsageSpendResult = Schema.Struct({
+ window: Schema.Struct({
+ days: Schema.Literals([1, 7, 30, 90]),
+ sinceDay: Schema.String,
+ untilDay: Schema.String,
+ timeZone: Schema.String,
+ }),
+ ...OrchestratorMcpUsageAmount,
+ sessions: Schema.Finite,
+ unpricedShare: Schema.Finite.annotate({
+ description: "Share of usage records with no known price, so missing from costUsd.",
+ }),
+ providers: Schema.Array(
+ Schema.Struct({
+ provider: UsageProviderKind,
+ ...OrchestratorMcpUsageAmount,
+ sessions: Schema.Finite,
+ }),
+ ),
+ models: Schema.Array(
+ Schema.Struct({
+ provider: UsageProviderKind,
+ model: Schema.String,
+ costUsd: Schema.NullOr(Schema.Finite),
+ totalTokens: Schema.Finite,
+ }),
+ ),
+ periods: Schema.Array(Schema.Struct({ start: Schema.String, costUsd: Schema.Finite })),
+ notices: Schema.Array(Schema.String),
+});
+export type OrchestratorMcpUsageSpendResult = typeof OrchestratorMcpUsageSpendResult.Type;
+
export const OrchestratorMcpScheduleTaskInput = Schema.Struct({
projectId: OrchestratorMcpProjectTarget,
prompt: OrchestratorMcpPrompt.annotate({
diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts
index 21f8be3941f9..95e8cb980adc 100644
--- a/packages/shared/src/t3McpToolPresentation.ts
+++ b/packages/shared/src/t3McpToolPresentation.ts
@@ -50,6 +50,8 @@ export type T3McpToolSummaryAction =
| "project-delete"
| "project-clone"
| "environment-read"
+ | "usage-limits"
+ | "usage-spend"
| "environment-update"
| "attachment-prepare"
| "attachment-discard"
@@ -124,6 +126,8 @@ const T3_MCP_TOOLS: Readonly> = {
["Get", "Getting", "Got", "orchestration capabilities"],
"capabilities",
),
+ t3_usage_limits: tool(["Check", "Checking", "Checked", "usage limits"], "usage-limits"),
+ t3_usage_spend: tool(["Check", "Checking", "Checked", "usage spend"], "usage-spend"),
delegate_task: tool(["Delegate", "Delegating", "Delegated", "a child task"], "delegate"),
task_status: tool(["Get", "Getting", "Got", "delegated task status"], "task-status"),
task_cancel: tool(