For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Big-bang replacement of the JSON REST API under /api/* with tRPC procedures, excluding /v1/*, /api/oauth/*, /mcp, /health//ready, /admin/*, binary/stream downloads, and /api/nodes/metadata. All five clients (web, mobile, cli, electron main, deploy) migrate to @trpc/client with end-to-end typed AppRouter.
Architecture: tRPC router composed from per-domain sub-routers in packages/websocket/src/trpc/, mounted on the existing Fastify instance at /trpc via @trpc/server/adapters/fastify. Zod input/output schemas live in @nodetool/protocol/src/api-schemas/. AppRouter type exported from @nodetool/websocket/trpc as a type-only subpath. Clients use httpBatchLink with superjson for native Date/Map/Set over the wire. Existing Fastify auth hook (req.userId) is the sole auth source; ctx.userId in tRPC reads it.
Tech Stack: tRPC v11, @trpc/server, @trpc/client, @trpc/react-query, zod v4, superjson, Fastify v5, TanStack Query v5, TypeScript strict.
Reference spec: docs/superpowers/specs/2026-04-17-trpc-migration-design.md
packages/websocket/src/trpc/
index.ts # initTRPC + errorFormatter + procedure exports
context.ts # createContextFactory → createContext
error-formatter.ts # ApiErrorCode ↔ TRPCError mapping, throwApiError helper
middleware.ts # protectedProcedure
router.ts # appRouter; export type AppRouter
routers/
costs.ts # pilot domain
assets.ts
collections.ts
files.ts
jobs.ts
mcp-config.ts
messages.ts
models.ts
nodes.ts
settings.ts
skills.ts
storage.ts
threads.ts
users.ts
workflows.ts
workspace.ts
__tests__/
<domain>.test.ts # one test file per router (createCaller-based)
__tests__/
integration.test.ts # app.inject() smoke tests for transport + auth + error formatter
packages/protocol/src/api-schemas/
index.ts # re-exports
costs.ts # pilot
assets.ts
collections.ts
files.ts
jobs.ts
mcp-config.ts
messages.ts
models.ts
nodes.ts
settings.ts
skills.ts
storage.ts
threads.ts
users.ts
workflows.ts
workspace.ts
web/src/trpc/
client.ts # createTRPCReact + createTRPCClient + httpBatchLink + superjson + auth
Provider.tsx # TRPCProvider wrapping the app (slots into existing QueryClientProvider)
mobile/src/trpc/
client.ts
Provider.tsx
packages/websocket/package.json — add @trpc/server, superjson deps; add ./trpc subpath export.packages/protocol/package.json — no new deps (zod already present); re-export api-schemas/ from src/index.ts.packages/websocket/src/server.ts — register fastifyTRPCPlugin after CORS but before the not-found handler; add /trpc to the auth-hook bypass list only for routes that should remain unauthenticated (none currently).packages/websocket/src/http-api.ts — trim to keep only functions serving REST-staying endpoints (binary/stream/metadata/oauth/openai/admin).packages/websocket/src/{collection,cost,file,models,settings,skills,storage,users,workspace}-api.ts — deleted when their endpoints are fully migrated. oauth-api.ts, openai-api.ts retained.packages/websocket/src/routes/{costs,settings,collections,skills,users,workspace,mcp-config,messages,threads,jobs,files,storage,nodes,models,assets,workflows}.ts — deleted when fully migrated, or shrunk to only the binary/stream/metadata routes that stay REST.web/package.json — add @trpc/client, @trpc/react-query, @trpc/server (peer), superjson; remove openapi-fetch, openapi-typescript; delete the openapi script.web/src/api.ts — deleted (14k generated lines).web/src/serverState/*.ts — rewritten to delegate to tRPC React Query hooks; public hook signatures preserved where practical.web/src/stores/ApiClient.ts — deleted after all consumers moved to the tRPC client.web/src/ that import from web/src/api.ts, @nodetool/websocket-style URL paths, or stores/ApiClient — swept to tRPC.mobile/ — same as web.packages/cli/src/nodetool.ts — apiGet/apiPost helpers replaced with a typed tRPC client.electron/src/api.ts — REST fetch replaced with typed tRPC client calls.packages/deploy/src/admin-client.ts, packages/deploy/src/api-user-manager.ts — REST fetch replaced with typed tRPC client.Files: none (git state)
Run: cd /Users/mg/workspace/nodetool && git checkout -b trpc-migration
Expected: Switched to a new branch 'trpc-migration'
Run: git status
Expected: nothing to commit, working tree clean
Files:
packages/websocket/package.jsonweb/package.jsonmobile/package.jsonpackages/cli/package.jsonelectron/package.jsonModify: packages/deploy/package.json
packages/websocket/package.jsonIn the dependencies block, add (alphabetical order):
"@trpc/server": "^11.0.0",
"superjson": "^2.2.2"
web/package.jsonIn dependencies:
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"superjson": "^2.2.2"
Remove from dependencies:
"openapi-fetch": "^0.14.0"
Remove from devDependencies:
"openapi-typescript": "^7.8.0"
Remove the openapi script from scripts.
mobile/package.jsonSame additions as web. Remove the same openapi entries and the openapi script.
packages/cli/package.jsonIn dependencies:
"@trpc/client": "^11.0.0",
"@trpc/server": "^11.0.0",
"@nodetool/websocket": "*",
"superjson": "^2.2.2"
Note: @nodetool/websocket is a type-only dep (the ./trpc subpath is type-erased); it’s added to dependencies because npm workspaces resolve through there. If TypeScript project-references need adjustment, see Task 1.8.
electron/package.jsonIn dependencies:
"@trpc/client": "^11.0.0",
"@trpc/server": "^11.0.0",
"@nodetool/websocket": "*",
"superjson": "^2.2.2"
packages/deploy/package.jsonIn dependencies:
"@trpc/client": "^11.0.0",
"@trpc/server": "^11.0.0",
"@nodetool/websocket": "*",
"superjson": "^2.2.2"
Run: cd /Users/mg/workspace/nodetool && npm install
Expected: completes without errors; package-lock.json updates.
git add package-lock.json packages/websocket/package.json web/package.json mobile/package.json packages/cli/package.json electron/package.json packages/deploy/package.json
git commit -m "chore: add @trpc/* and superjson deps; drop openapi-fetch/openapi-typescript"
packages/websocket/package.json exports with ./trpc subpathFiles:
Modify: packages/websocket/package.json
Step 1: Update the exports block
Replace the exports block with:
"exports": {
".": {
"nodetool-dev": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./trpc": {
"nodetool-dev": "./src/trpc/router.ts",
"types": "./src/trpc/router.ts",
"import": "./dist/trpc/router.js",
"default": "./dist/trpc/router.js"
}
}
git add packages/websocket/package.json
git commit -m "feat(websocket): add ./trpc subpath export for AppRouter type"
Files:
packages/websocket/src/types/fastify.d.ts (module augmentation for req.userId)packages/websocket/src/trpc/index.tspackages/websocket/src/trpc/error-formatter.tspackages/websocket/src/trpc/context.tspackages/websocket/src/trpc/middleware.tspackages/websocket/src/trpc/router.tsModify: packages/websocket/src/server.ts (remove inline declare module "fastify" block)
Create packages/websocket/src/types/fastify.d.ts:
import "fastify";
declare module "fastify" {
interface FastifyRequest {
userId: string | null;
}
}
Remove the inline declare module "fastify" block near the top of packages/websocket/src/server.ts (lines ~68-72). This makes the augmentation visible to all files in the package including tests.
error-formatter.ts// packages/websocket/src/trpc/error-formatter.ts
import { TRPCError } from "@trpc/server";
import type { DefaultErrorShape } from "@trpc/server";
import { ZodError } from "zod";
import { ApiErrorCode } from "../error-codes.js";
export interface TRPCErrorCause {
apiCode?: ApiErrorCode;
}
export interface ApiErrorShape extends DefaultErrorShape {
data: DefaultErrorShape["data"] & {
apiCode: ApiErrorCode | null;
zodError: Record<string, string[]> | null;
};
}
export function errorFormatter({
shape,
error
}: {
shape: DefaultErrorShape;
error: TRPCError;
}): ApiErrorShape {
const cause = error.cause as TRPCErrorCause | undefined;
const zodError =
error.cause instanceof ZodError
? (error.cause.flatten().fieldErrors as Record<string, string[]>)
: null;
return {
...shape,
data: {
...shape.data,
apiCode: cause?.apiCode ?? null,
zodError
}
};
}
interface ThrowApiErrorCause extends Error {
apiCode: ApiErrorCode;
}
const TRPC_CODE_BY_API_CODE: Partial<
Record<ApiErrorCode, TRPCError["code"]>
> = {
[ApiErrorCode.NOT_FOUND]: "NOT_FOUND",
[ApiErrorCode.WORKFLOW_NOT_FOUND]: "NOT_FOUND",
[ApiErrorCode.ASSET_NOT_FOUND]: "NOT_FOUND",
[ApiErrorCode.ALREADY_EXISTS]: "CONFLICT",
[ApiErrorCode.INVALID_INPUT]: "BAD_REQUEST",
[ApiErrorCode.MISSING_REQUIRED_FIELD]: "BAD_REQUEST",
[ApiErrorCode.UNAUTHORIZED]: "UNAUTHORIZED",
[ApiErrorCode.FORBIDDEN]: "FORBIDDEN",
[ApiErrorCode.INTERNAL_ERROR]: "INTERNAL_SERVER_ERROR",
[ApiErrorCode.SERVICE_UNAVAILABLE]: "INTERNAL_SERVER_ERROR",
[ApiErrorCode.WORKFLOW_EXECUTION_FAILED]: "INTERNAL_SERVER_ERROR",
[ApiErrorCode.ASSET_UPLOAD_FAILED]: "INTERNAL_SERVER_ERROR",
[ApiErrorCode.PYTHON_BRIDGE_UNAVAILABLE]: "INTERNAL_SERVER_ERROR"
};
export function throwApiError(
apiCode: ApiErrorCode,
message: string,
trpcCode?: TRPCError["code"]
): never {
const resolvedCode = trpcCode ?? TRPC_CODE_BY_API_CODE[apiCode] ?? "INTERNAL_SERVER_ERROR";
const cause: ThrowApiErrorCause = Object.assign(new Error(message), {
apiCode,
name: "ApiError"
});
throw new TRPCError({ code: resolvedCode, message, cause });
}
context.ts// packages/websocket/src/trpc/context.ts
import type { FastifyRequest } from "fastify";
import type { NodeRegistry } from "@nodetool/node-sdk";
import type { PythonStdioBridge } from "@nodetool/runtime";
import type { HttpApiOptions } from "../http-api.js";
export interface Context {
userId: string | null;
registry: NodeRegistry;
apiOptions: HttpApiOptions;
pythonBridge: PythonStdioBridge;
getPythonBridgeReady: () => boolean;
}
export interface ContextFactoryInput {
registry: NodeRegistry;
apiOptions: HttpApiOptions;
pythonBridge: PythonStdioBridge;
getPythonBridgeReady: () => boolean;
}
export function createContextFactory(
deps: ContextFactoryInput
): (opts: { req: FastifyRequest }) => Context {
return ({ req }) => ({
userId: req.userId,
registry: deps.registry,
apiOptions: deps.apiOptions,
pythonBridge: deps.pythonBridge,
getPythonBridgeReady: deps.getPythonBridgeReady
});
}
index.ts// packages/websocket/src/trpc/index.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { Context } from "./context.js";
import { errorFormatter } from "./error-formatter.js";
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter
});
export const router = t.router;
export const mergeRouters = t.mergeRouters;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;
export { TRPCError };
middleware.ts// packages/websocket/src/trpc/middleware.ts
import { TRPCError } from "@trpc/server";
import { publicProcedure } from "./index.js";
export const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required"
});
}
return next({ ctx: { ...ctx, userId: ctx.userId } });
});
router.ts with an empty router// packages/websocket/src/trpc/router.ts
import { router } from "./index.js";
export const appRouter = router({});
export type AppRouter = typeof appRouter;
Run: cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/websocket
Expected: passes with no errors.
git add packages/websocket/src/trpc/
git commit -m "feat(trpc): scaffold server — initTRPC, context, errorFormatter, empty router"
api-schemas/ skeleton in @nodetool/protocolFiles:
packages/protocol/src/api-schemas/index.tsSchemas are consumed via the @nodetool/protocol/api-schemas subpath (set up in Task 1.6). The main packages/protocol/src/index.ts is intentionally not modified — schemas are accessed via the subpath, not the root export, to keep the root package tree-shaking friendly.
// packages/protocol/src/api-schemas/index.ts
// Zod input/output schemas for tRPC procedures.
// One file per domain; domain-specific schema modules are re-exported as
// namespaces below as they're added (e.g. `export * as costs from "./costs.js";`).
export {};
Run: cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/protocol
Expected: passes.
git add packages/protocol/src/api-schemas/
git commit -m "feat(protocol): scaffold api-schemas directory for tRPC zod schemas"
Files:
packages/websocket/src/trpc/router.tsModify: packages/websocket/src/server.ts
healthz procedure to router.ts for smoke-testing the mountReplace the contents of packages/websocket/src/trpc/router.ts:
import { z } from "zod";
import { router, publicProcedure } from "./index.js";
export const appRouter = router({
healthz: publicProcedure.output(z.object({ ok: z.literal(true) })).query(() => ({
ok: true as const
}))
});
export type AppRouter = typeof appRouter;
server.tsNear the top with other imports, add:
import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from "@trpc/server/adapters/fastify";
import { appRouter, type AppRouter } from "./trpc/router.js";
import { createContextFactory } from "./trpc/context.js";
Placement: the tRPC plugin must be registered after the apiOptions block is constructed (currently around line ~492) and before the per-domain REST route plugins start registering (line ~569). Specifically, insert the block below right after the existing apiOptions declaration and before the first await app.register(websocketPlugin, ...):
const createContext = createContextFactory({
registry,
apiOptions,
pythonBridge,
getPythonBridgeReady: () => pythonBridgeReady
});
await app.register(fastifyTRPCPlugin, {
prefix: "/trpc",
trpcOptions: {
router: appRouter,
createContext,
onError({ path, error }) {
log.error(
`tRPC error on ${path}`,
error instanceof Error ? error : new Error(String(error))
);
}
} satisfies FastifyTRPCPluginOptions<AppRouter>["trpcOptions"]
});
This placement ensures: (1) the Fastify onRequest auth hook (registered earlier) runs first and sets req.userId; (2) CORS and the raw buffer body parser are already in place; (3) the tRPC adapter handles /trpc/* before any /api/* REST route matches.
Create packages/websocket/src/trpc/__tests__/integration.test.ts:
import { describe, it, expect } from "vitest";
import Fastify from "fastify";
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
import { appRouter } from "../router.js";
import { createContextFactory } from "../context.js";
function buildTestApp() {
const app = Fastify({ logger: false });
app.decorateRequest("userId", null);
app.addHook("onRequest", async (req) => {
req.userId = "test-user";
});
const stubBridge = { hasPython: () => false, close: () => {} } as never;
const stubRegistry = {} as never;
const stubApiOptions = { metadataRoots: [], registry: stubRegistry } as never;
const createContext = createContextFactory({
registry: stubRegistry,
apiOptions: stubApiOptions,
pythonBridge: stubBridge,
getPythonBridgeReady: () => false
});
void app.register(fastifyTRPCPlugin, {
prefix: "/trpc",
trpcOptions: { router: appRouter, createContext }
});
return app;
}
describe("tRPC Fastify mount", () => {
it("serves the healthz query at /trpc/healthz", async () => {
const app = buildTestApp();
await app.ready();
const res = await app.inject({ method: "GET", url: "/trpc/healthz" });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// tRPC v11 returns { result: { data: { json: ..., meta: ... } } } when superjson is active.
const data = body.result?.data?.json ?? body.result?.data;
expect(data).toEqual({ ok: true });
await app.close();
});
});
Run: cd /Users/mg/workspace/nodetool/packages/websocket && npm test -- src/trpc/__tests__/integration.test.ts
Expected: PASS.
Run: cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/websocket
Expected: passes.
git add packages/websocket/src/trpc/ packages/websocket/src/server.ts
git commit -m "feat(trpc): mount tRPC on Fastify at /trpc with healthz smoke test"
isTRPCErrorWithCode client helper to protocol schemas packageFiles:
packages/protocol/src/api-schemas/error-helpers.tsModify: packages/protocol/src/api-schemas/index.ts
// packages/protocol/src/api-schemas/error-helpers.ts
import { ApiErrorCode } from "./api-error-code.js";
/**
* Shape of the `error.data` object attached to TRPCClientError when the server
* uses the errorFormatter from @nodetool/websocket/src/trpc/error-formatter.
* Re-declared here so clients don't depend on server internals.
*/
export interface NodetoolTRPCErrorData {
apiCode: ApiErrorCode | null;
zodError: Record<string, string[]> | null;
code?: string;
httpStatus?: number;
path?: string;
}
export interface NodetoolTRPCClientError {
data?: NodetoolTRPCErrorData | null;
message: string;
}
export function isTRPCErrorWithCode(
err: unknown,
code: ApiErrorCode
): err is NodetoolTRPCClientError {
if (err == null || typeof err !== "object") return false;
const candidate = err as { data?: { apiCode?: unknown } };
return candidate.data?.apiCode === code;
}
ApiErrorCode into protocolBecause ApiErrorCode currently lives in packages/websocket/src/error-codes.ts and the client helper can’t depend on the server package, copy the enum into protocol:
Create packages/protocol/src/api-schemas/api-error-code.ts:
export enum ApiErrorCode {
NOT_FOUND = "NOT_FOUND",
ALREADY_EXISTS = "ALREADY_EXISTS",
INVALID_INPUT = "INVALID_INPUT",
MISSING_REQUIRED_FIELD = "MISSING_REQUIRED_FIELD",
UNAUTHORIZED = "UNAUTHORIZED",
FORBIDDEN = "FORBIDDEN",
INTERNAL_ERROR = "INTERNAL_ERROR",
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE",
WORKFLOW_NOT_FOUND = "WORKFLOW_NOT_FOUND",
WORKFLOW_EXECUTION_FAILED = "WORKFLOW_EXECUTION_FAILED",
ASSET_NOT_FOUND = "ASSET_NOT_FOUND",
ASSET_UPLOAD_FAILED = "ASSET_UPLOAD_FAILED",
PYTHON_BRIDGE_UNAVAILABLE = "PYTHON_BRIDGE_UNAVAILABLE"
}
export interface ApiErrorResponse {
code: ApiErrorCode;
detail: string;
}
export function apiError(code: ApiErrorCode, detail: string): ApiErrorResponse {
return { code, detail };
}
Replace the contents of packages/websocket/src/error-codes.ts:
export { ApiErrorCode, apiError } from "@nodetool/protocol/api-schemas/api-error-code.js";
export type { ApiErrorResponse } from "@nodetool/protocol/api-schemas/api-error-code.js";
This preserves existing imports at all call sites while moving the canonical definition to protocol.
Replace the contents of packages/protocol/src/api-schemas/index.ts:
export * from "./api-error-code.js";
export * from "./error-helpers.js";
In packages/protocol/package.json, replace the exports block with:
"exports": {
".": {
"nodetool-dev": "./src/index.ts",
"types": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./api-schemas": {
"nodetool-dev": "./src/api-schemas/index.ts",
"types": "./src/api-schemas/index.ts",
"import": "./dist/api-schemas/index.js",
"default": "./dist/api-schemas/index.js"
},
"./api-schemas/*": {
"nodetool-dev": "./src/api-schemas/*.ts",
"types": "./src/api-schemas/*.ts",
"import": "./dist/api-schemas/*.js",
"default": "./dist/api-schemas/*.js"
}
}
Run: cd /Users/mg/workspace/nodetool && npm run typecheck
Expected: passes. (If protocol isn’t built, run npm run build:packages first.)
git add packages/protocol/ packages/websocket/src/error-codes.ts
git commit -m "feat(protocol): host ApiErrorCode + client tRPC error helpers under api-schemas subpath"
Files:
web/src/trpc/client.tsCreate: web/src/trpc/Provider.tsx
client.ts// web/src/trpc/client.ts
import { createTRPCReact } from "@trpc/react-query";
import {
createTRPCClient,
httpBatchLink,
loggerLink,
type TRPCClientErrorLike
} from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@nodetool/websocket/trpc";
import { BASE_URL } from "../stores/BASE_URL";
import { supabase } from "../lib/supabaseClient";
import { isLocalhost } from "../stores/ApiClient";
export const trpc = createTRPCReact<AppRouter>();
async function authHeaders(): Promise<Record<string, string>> {
if (isLocalhost) return {};
const {
data: { session }
} = await supabase.auth.getSession();
return session ? { Authorization: `Bearer ${session.access_token}` } : {};
}
export function createTRPCHttpClient() {
return createTRPCClient<AppRouter>({
links: [
loggerLink({
enabled: (opts) =>
(typeof window !== "undefined" &&
import.meta.env.DEV &&
typeof window !== "undefined") ||
(opts.direction === "down" && opts.result instanceof Error)
}),
httpBatchLink({
url: `${BASE_URL}/trpc`,
transformer: superjson,
async headers() {
return authHeaders();
}
})
]
});
}
export type TRPCClientError = TRPCClientErrorLike<AppRouter>;
Provider.tsx// web/src/trpc/Provider.tsx
import { useState, type ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import superjson from "superjson";
import { trpc } from "./client";
import { queryClient } from "../queryClient";
import type { AppRouter } from "@nodetool/websocket/trpc";
import { BASE_URL } from "../stores/BASE_URL";
import { supabase } from "../lib/supabaseClient";
import { isLocalhost } from "../stores/ApiClient";
async function authHeaders(): Promise<Record<string, string>> {
if (isLocalhost) return {};
const {
data: { session }
} = await supabase.auth.getSession();
return session ? { Authorization: `Bearer ${session.access_token}` } : {};
}
export function TRPCProvider({ children }: { children: ReactNode }) {
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
loggerLink({
enabled: (opts) =>
(import.meta.env.DEV && typeof window !== "undefined") ||
(opts.direction === "down" && opts.result instanceof Error)
}),
httpBatchLink({
url: `${BASE_URL}/trpc`,
transformer: superjson,
async headers() {
return authHeaders();
}
})
]
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
TRPCProvider into the app rootFind where QueryClientProvider is currently mounted in web/src/index.tsx (or the closest app root). Replace the outer QueryClientProvider with TRPCProvider — TRPCProvider composes both:
// in web/src/index.tsx
import { TRPCProvider } from "./trpc/Provider";
// ... remove direct QueryClientProvider wrapper; wrap the app in <TRPCProvider> instead
<TRPCProvider>
<App />
</TRPCProvider>
(If QueryClientProvider wraps more than just the app root, keep the existing structure and add TRPCProvider as a parent — it only needs to be above any component that uses trpc.xyz.useQuery. The inner QueryClientProvider can remain; duplicates are harmless because they use the same queryClient instance.)
Run: cd /Users/mg/workspace/nodetool/web && npm run typecheck
Expected: passes.
Run: cd /Users/mg/workspace/nodetool && npm run dev
In another terminal: curl http://127.0.0.1:7777/trpc/healthz
Expected: JSON response containing {"result":{"data":{"json":{"ok":true}}}}. Kill the dev server.
git add web/src/trpc/ web/src/index.tsx
git commit -m "feat(web): scaffold tRPC client + TRPCProvider"
Files:
packages/protocol/src/api-schemas/costs.tsModify: packages/protocol/src/api-schemas/index.ts
costs.ts schemasMirror the current cost-api.ts inputs/outputs:
// packages/protocol/src/api-schemas/costs.ts
import { z } from "zod";
export const listPredictionsInput = z.object({
provider: z.string().optional(),
model: z.string().optional(),
limit: z.number().int().min(1).max(500).default(50),
startKey: z.string().optional()
});
export const aggregateInput = z.object({
provider: z.string().optional(),
model: z.string().optional()
});
export const aggregateByModelInput = z.object({
provider: z.string().optional()
});
export const predictionResponse = z.object({
id: z.string(),
user_id: z.string(),
node_id: z.string(),
provider: z.string(),
model: z.string(),
workflow_id: z.string().nullable(),
cost: z.number().nullable(),
input_tokens: z.number().nullable(),
output_tokens: z.number().nullable(),
total_tokens: z.number().nullable(),
cached_tokens: z.number().nullable(),
reasoning_tokens: z.number().nullable(),
created_at: z.union([z.string(), z.date()]),
metadata: z.record(z.string(), z.unknown()).nullable()
});
export const listPredictionsOutput = z.object({
calls: z.array(predictionResponse),
next_start_key: z.string().nullable()
});
export const aggregateOutput = z.object({
total_cost: z.number(),
total_tokens: z.number(),
count: z.number()
});
export const aggregateByProviderOutput = z.record(z.string(), aggregateOutput);
export const aggregateByModelOutput = z.record(z.string(), aggregateOutput);
export const summaryOutput = z.object({
overall: aggregateOutput,
by_provider: aggregateByProviderOutput,
by_model: aggregateByModelOutput,
recent_calls: z.array(predictionResponse)
});
export type ListPredictionsInput = z.infer<typeof listPredictionsInput>;
export type PredictionResponse = z.infer<typeof predictionResponse>;
Note: inspect the exact shape of Prediction.aggregateByUser() and friends in packages/models before finalizing aggregateOutput keys. If the live shape differs, update the schema to match the true return shape, preserving snake_case keys for wire compatibility with downstream consumers.
index.tsAppend to packages/protocol/src/api-schemas/index.ts:
export * as costs from "./costs.js";
Run: cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/protocol
Expected: passes.
git add packages/protocol/src/api-schemas/
git commit -m "feat(protocol): add costs api-schemas"
Files:
Create: packages/websocket/src/trpc/routers/__tests__/costs.test.ts
Step 1: Write the test file
// packages/websocket/src/trpc/routers/__tests__/costs.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { appRouter } from "../../router.js";
import { createCallerFactory } from "../../index.js";
import type { Context } from "../../context.js";
// Seam for mocking the Prediction model
vi.mock("@nodetool/models", async (orig) => {
const actual = await orig<typeof import("@nodetool/models")>();
return {
...actual,
Prediction: {
paginate: vi.fn(),
aggregateByUser: vi.fn(),
aggregateByProvider: vi.fn(),
aggregateByModel: vi.fn()
}
};
});
import { Prediction } from "@nodetool/models";
const createCaller = createCallerFactory(appRouter);
function makeCtx(overrides: Partial<Context> = {}): Context {
return {
userId: "user-1",
registry: {} as never,
apiOptions: { metadataRoots: [], registry: {} as never } as never,
pythonBridge: {} as never,
getPythonBridgeReady: () => false,
...overrides
};
}
describe("costs router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("list returns predictions + next_start_key", async () => {
(Prediction.paginate as ReturnType<typeof vi.fn>).mockResolvedValue([
[
{
id: "p1",
user_id: "user-1",
node_id: "n1",
provider: "openai",
model: "gpt-4",
workflow_id: null,
cost: 0.01,
input_tokens: 100,
output_tokens: 50,
total_tokens: 150,
cached_tokens: null,
reasoning_tokens: null,
created_at: "2026-04-17T00:00:00Z",
metadata: null
}
],
"next-key"
]);
const caller = createCaller(makeCtx());
const result = await caller.costs.list({ limit: 10 });
expect(result.calls).toHaveLength(1);
expect(result.calls[0].id).toBe("p1");
expect(result.next_start_key).toBe("next-key");
expect(Prediction.paginate).toHaveBeenCalledWith("user-1", {
provider: undefined,
model: undefined,
limit: 10,
startKey: undefined
});
});
it("list with filters forwards provider + model", async () => {
(Prediction.paginate as ReturnType<typeof vi.fn>).mockResolvedValue([[], null]);
const caller = createCaller(makeCtx());
await caller.costs.list({ provider: "anthropic", model: "claude", limit: 50 });
expect(Prediction.paginate).toHaveBeenCalledWith("user-1", {
provider: "anthropic",
model: "claude",
limit: 50,
startKey: undefined
});
});
it("aggregate returns aggregateByUser result", async () => {
const agg = { total_cost: 1.23, total_tokens: 1000, count: 5 };
(Prediction.aggregateByUser as ReturnType<typeof vi.fn>).mockResolvedValue(agg);
const caller = createCaller(makeCtx());
await expect(caller.costs.aggregate({})).resolves.toEqual(agg);
});
it("aggregateByProvider returns the grouped map", async () => {
const grouped = { openai: { total_cost: 1, total_tokens: 10, count: 1 } };
(Prediction.aggregateByProvider as ReturnType<typeof vi.fn>).mockResolvedValue(grouped);
const caller = createCaller(makeCtx());
await expect(caller.costs.aggregateByProvider()).resolves.toEqual(grouped);
});
it("aggregateByModel forwards provider filter", async () => {
(Prediction.aggregateByModel as ReturnType<typeof vi.fn>).mockResolvedValue({});
const caller = createCaller(makeCtx());
await caller.costs.aggregateByModel({ provider: "openai" });
expect(Prediction.aggregateByModel).toHaveBeenCalledWith("user-1", {
provider: "openai"
});
});
it("summary aggregates everything in parallel", async () => {
(Prediction.aggregateByUser as ReturnType<typeof vi.fn>).mockResolvedValue({
total_cost: 2,
total_tokens: 200,
count: 2
});
(Prediction.aggregateByProvider as ReturnType<typeof vi.fn>).mockResolvedValue({});
(Prediction.aggregateByModel as ReturnType<typeof vi.fn>).mockResolvedValue({});
(Prediction.paginate as ReturnType<typeof vi.fn>).mockResolvedValue([[], null]);
const caller = createCaller(makeCtx());
const result = await caller.costs.summary();
expect(result.overall.total_cost).toBe(2);
expect(result.recent_calls).toEqual([]);
});
it("rejects unauthenticated callers", async () => {
const caller = createCaller(makeCtx({ userId: null }));
await expect(caller.costs.list({ limit: 10 })).rejects.toMatchObject({
code: "UNAUTHORIZED"
});
});
});
Run: cd /Users/mg/workspace/nodetool/packages/websocket && npm test -- src/trpc/routers/__tests__/costs.test.ts
Expected: FAIL with caller.costs is undefined (the router doesn’t exist yet).
Files:
packages/websocket/src/trpc/routers/costs.tsModify: packages/websocket/src/trpc/router.ts
costs.ts// packages/websocket/src/trpc/routers/costs.ts
import { Prediction } from "@nodetool/models";
import { router } from "../index.js";
import { protectedProcedure } from "../middleware.js";
import {
listPredictionsInput,
listPredictionsOutput,
aggregateInput,
aggregateOutput,
aggregateByProviderOutput,
aggregateByModelInput,
aggregateByModelOutput,
summaryOutput,
type PredictionResponse
} from "@nodetool/protocol/api-schemas/costs.js";
function toPredictionResponse(pred: Prediction): PredictionResponse {
return {
id: pred.id,
user_id: pred.user_id,
node_id: pred.node_id ?? "",
provider: pred.provider,
model: pred.model,
workflow_id: pred.workflow_id ?? null,
cost: pred.cost ?? null,
input_tokens: pred.input_tokens ?? null,
output_tokens: pred.output_tokens ?? null,
total_tokens: pred.total_tokens ?? null,
cached_tokens: pred.cached_tokens ?? null,
reasoning_tokens: pred.reasoning_tokens ?? null,
created_at: pred.created_at,
metadata: pred.metadata ?? null
};
}
export const costsRouter = router({
list: protectedProcedure
.input(listPredictionsInput)
.output(listPredictionsOutput)
.query(async ({ ctx, input }) => {
const [calls, nextKey] = await Prediction.paginate(ctx.userId, {
provider: input.provider,
model: input.model,
limit: input.limit,
startKey: input.startKey
});
return {
calls: calls.map(toPredictionResponse),
next_start_key: nextKey || null
};
}),
aggregate: protectedProcedure
.input(aggregateInput)
.output(aggregateOutput)
.query(async ({ ctx, input }) => {
return Prediction.aggregateByUser(ctx.userId, {
provider: input.provider,
model: input.model
});
}),
aggregateByProvider: protectedProcedure
.output(aggregateByProviderOutput)
.query(async ({ ctx }) => {
return Prediction.aggregateByProvider(ctx.userId);
}),
aggregateByModel: protectedProcedure
.input(aggregateByModelInput)
.output(aggregateByModelOutput)
.query(async ({ ctx, input }) => {
return Prediction.aggregateByModel(ctx.userId, {
provider: input.provider
});
}),
summary: protectedProcedure.output(summaryOutput).query(async ({ ctx }) => {
const [overall, byProvider, byModel, paginated] = await Promise.all([
Prediction.aggregateByUser(ctx.userId),
Prediction.aggregateByProvider(ctx.userId),
Prediction.aggregateByModel(ctx.userId),
Prediction.paginate(ctx.userId, { limit: 10 })
]);
const [recentCalls] = paginated;
return {
overall,
by_provider: byProvider,
by_model: byModel,
recent_calls: recentCalls.map(toPredictionResponse)
};
})
});
Replace packages/websocket/src/trpc/router.ts:
import { z } from "zod";
import { router, publicProcedure } from "./index.js";
import { costsRouter } from "./routers/costs.js";
export const appRouter = router({
healthz: publicProcedure.output(z.object({ ok: z.literal(true) })).query(() => ({
ok: true as const
})),
costs: costsRouter
});
export type AppRouter = typeof appRouter;
Run: cd /Users/mg/workspace/nodetool/packages/websocket && npm test -- src/trpc/routers/__tests__/costs.test.ts
Expected: all tests PASS.
Run: cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/websocket
Expected: passes.
git add packages/websocket/src/trpc/
git commit -m "feat(trpc): implement costs router + unit tests"
Files:
Modify: packages/websocket/src/trpc/__tests__/integration.test.ts
Step 1: Add a costs integration test
Append to the existing file:
import { Prediction } from "@nodetool/models";
describe("tRPC /trpc/costs.list over Fastify", () => {
it("returns an empty list for a user with no predictions", async () => {
vi.spyOn(Prediction, "paginate").mockResolvedValue([[], null]);
const app = buildTestApp();
await app.ready();
const res = await app.inject({
method: "GET",
url: `/trpc/costs.list?input=${encodeURIComponent(
JSON.stringify({ json: { limit: 10 } })
)}`,
headers: { "content-type": "application/json" }
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
const data = body.result?.data?.json ?? body.result?.data;
expect(data.calls).toEqual([]);
expect(data.next_start_key).toBeNull();
await app.close();
});
});
Note: when superjson is enabled, tRPC wraps inputs/outputs in { json: ..., meta: ... }. The GET query-string input format is ?input=<urlencoded JSON>. Verify by consulting @trpc/server/adapters/fastify docs if the format differs in v11.
Run: cd /Users/mg/workspace/nodetool/packages/websocket && npm test -- src/trpc/__tests__/integration.test.ts
Expected: PASS.
git add packages/websocket/src/trpc/__tests__/integration.test.ts
git commit -m "test(trpc): integration smoke test for costs over Fastify"
Files:
web/src/**web/src/serverState/useCosts.ts (if not already tRPC-shaped)Modify or delete: any existing web file that fetches from /api/costs
Run: cd /Users/mg/workspace/nodetool && grep -rn "/api/costs" web/src electron/src mobile/src packages/cli packages/deploy
Record the list — these are the call sites to migrate.
Example: a hook currently using openapi-fetch:
// before
import { client } from "../stores/ApiClient";
const { data, error } = useQuery({
queryKey: ["costs"],
queryFn: async () => {
const { data, error } = await client.GET("/api/costs/summary");
if (error) throw error;
return data;
}
});
becomes:
// after
import { trpc } from "../trpc/client";
const { data, error } = trpc.costs.summary.useQuery();
For listing with filters:
const { data } = trpc.costs.list.useQuery({
limit: 50,
provider: "openai"
});
Run: cd /Users/mg/workspace/nodetool/web && npm run typecheck
Expected: passes. If the server package’s dist doesn’t exist yet, run npm run build:packages first.
Run: cd /Users/mg/workspace/nodetool/web && npm test
Expected: passes. Any tests that mocked /api/costs via MSW should be rewritten to mock the tRPC HTTP endpoint. The MSW v2 pattern for a tRPC batch GET is:
import { http, HttpResponse } from "msw";
import superjson from "superjson";
http.get("http://localhost:7777/trpc/costs.list", () =>
HttpResponse.json({
result: {
data: superjson.serialize({ calls: [], next_start_key: null })
}
})
);
For mutations (POST with JSON body): http.post("http://localhost:7777/trpc/<proc>", ...) with the same response shape. Alternatively, use msw-trpc if it’s added as a devDependency — it wraps the path construction automatically.
git add web/src/
git commit -m "feat(web): migrate costs consumers to tRPC"
Files:
packages/websocket/src/routes/costs.tspackages/websocket/src/cost-api.tsModify: packages/websocket/src/server.ts
In server.ts, delete:
import costsRoutes from "./routes/costs.js";
// ...
await app.register(costsRoutes, routeOpts);
Run:
cd /Users/mg/workspace/nodetool
rm packages/websocket/src/routes/costs.ts packages/websocket/src/cost-api.ts
Run: cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/websocket
Expected: passes (no other file imports from cost-api.ts or routes/costs.ts — confirm with grep if in doubt).
Run: cd /Users/mg/workspace/nodetool/packages/websocket && npm test
Expected: passes.
git add -A packages/websocket/
git commit -m "refactor(websocket): remove REST costs route (migrated to tRPC)"
Every remaining domain follows the same seven-step pattern. The playbook is given once; each domain task below references it and provides the domain-specific parameters.
For domain <D> (examples: settings, workflows, assets, …):
packages/protocol/src/api-schemas/<D>.ts with one zod schema per input and output shape, matching the handler signatures in packages/websocket/src/<D>-api.ts (or its equivalent source). Re-export from api-schemas/index.ts.packages/websocket/src/trpc/routers/__tests__/<D>.test.ts. For each endpoint, write a createCaller test that mocks the underlying model(s) and asserts the result shape matches the schema. Include an authentication test (userId: null should throw UNAUTHORIZED).packages/websocket/src/trpc/routers/<D>.ts with one protectedProcedure per endpoint. Body logic is lifted from the matching handler in <D>-api.ts (or http-api.ts for workflows/assets/etc.). Use throwApiError(ApiErrorCode.X, "message") for error paths instead of returning Response objects.<D>: <D>Router to packages/websocket/src/trpc/router.ts.app.inject test exercising the most-used procedure.grep -rn "/api/<D>" web/src mobile/src electron/src packages/cli/src packages/deploy/src. Replace each call site with trpc.<D>.<op>.useQuery() / useMutation() for web+mobile, or client.<D>.<op>.query() / mutate() for vanilla-client cases (cli/electron/deploy).packages/websocket/src/routes/<D>.ts, the matching functions from packages/websocket/src/<D>-api.ts (or delete the whole file if empty), and the import + app.register(<D>Routes) line in server.ts. If any routes in the file serve binary/stream/metadata responses that must stay REST, shrink the file instead of deleting.Each domain must end on a git commit and npm run typecheck + relevant npm test must pass.
Before each domain task, consult this table. Entries marked “†” have binary/stream/metadata endpoints that must remain REST — shrink routes/<D>.ts instead of deleting.
| Domain | Handler source | Routes file | Core model(s) | Endpoints | Binary/REST-retained |
|---|---|---|---|---|---|
settings |
settings-api.ts |
routes/settings.ts |
Setting, Secret |
get/list/update/delete | none |
collections |
collection-api.ts |
routes/collections.ts |
Collection (vectorstore) |
list/get/create/update/delete/index | none |
skills |
skills-api.ts |
routes/skills.ts |
in-memory skill registry | list/get/run | none |
users |
users-api.ts |
routes/users.ts |
User |
list/create/delete/reset-token | none |
workspace |
workspace-api.ts |
routes/workspace.ts |
workspace filesystem | list/read/write/mkdir/rm | † (binary read) |
mcp-config |
routes/mcp-config.ts (inline) |
routes/mcp-config.ts |
mcp config file | get/update | none |
messages |
http-api.ts (handleMessages*) |
routes/messages.ts |
Message |
list/get/create/delete | none |
threads |
http-api.ts (handleThreads*) |
routes/threads.ts |
Thread |
list/get/create/update/delete | none |
jobs |
http-api.ts (handleJobs*) |
routes/jobs.ts |
Job |
list/get/cancel/delete | none |
files |
file-api.ts |
routes/files.ts |
filesystem + File |
list/metadata/rename/delete | † (binary read/write) |
storage |
storage-api.ts |
routes/storage.ts |
Storage (local/S3) |
list/metadata/delete | † (binary PUT/GET) |
nodes |
http-api.ts (handleNodes*) + routes/nodes.ts |
routes/nodes.ts |
registry | search/list-by-namespace | † (/metadata stays REST) |
models |
models-api.ts |
routes/models.ts |
provider registry | list/get/search | none |
assets |
http-api.ts (handleAssets*) |
routes/assets.ts |
Asset |
list/get/create/update/delete/children/recursive/search/by-filename | † (thumbnails, package asset files) |
workflows |
http-api.ts (handleWorkflow*) |
routes/workflows.ts |
Workflow, WorkflowVersion |
list/get/create/update/delete/run/autosave/versions/tools/examples/public/generate-name | † (dsl-export, gradio-export) |
settings domainApply the playbook with these specifics:
settings-api.ts to enumerate endpoints. Typical inputs: key (string), value (unknown), pagination cursor. Output: settings records with { key, value, updated_at }. Write schemas and tests.Setting and Secret models (seam via vi.mock("@nodetool/models")); verify list/get/update/delete round-trip.ctx.userId; errors mapped via throwApiError(ApiErrorCode.NOT_FOUND, ...) etc.settings: settingsRouter to router.ts.app.inject.grep -rn "/api/settings" web/src mobile/src electron/src packages/cli/src packages/deploy/src; replace each.Delete REST code — remove routes/settings.ts, settings-api.ts, and the settingsRoutes import/register in server.ts.
feat(protocol): add settings api-schemas.npm test -- routers/__tests__/settings.test.ts — expect FAIL.routers/settings.ts + add to appRouter. Run tests — expect PASS. Commit: feat(trpc): implement settings router + tests.test(trpc): integration smoke for settings.feat(web): migrate settings consumers to tRPC.refactor: remove REST settings route.collections domainApply the playbook:
collection-api.ts): list collections, get by name, create, update (name/metadata), delete, index documents. Indexing may take a large body — validate carefully.Special consideration: vectorstore operations live in @nodetool/vectorstore. Router imports from there via the registry/handle pattern already in collection-api.ts.
feat(protocol): add collections api-schemas → feat(trpc): implement collections router + tests → test(trpc): integration smoke for collections → feat(web): migrate collections consumers to tRPC → refactor: remove REST collections route.skills domainskills-api.ts): list skills, get by name, run. run may stream or take non-trivial time — if it streams, keep it REST and document the exception in the PR description; otherwise migrate.Auth: same as the rest — protectedProcedure.
users domainusers-api.ts): list users, create user, delete by username, reset-token.Special: consumed by packages/deploy/src/api-user-manager.ts. Migrate deploy in the client step.
packages/deploy/src/api-user-manager.ts in Step 5’s grep and migration.workspace domainworkspace-api.ts): list entries, read (JSON metadata or small text), write (text), mkdir, remove.REST retained: binary read (files that return bytes, large files). Shrink routes/workspace.ts to keep only the binary-read route; everything else goes tRPC.
routes/workspace.ts instead of deleting.mcp-config domainroutes/mcp-config.ts, the handler is inline not a separate *-api.ts): get, update.Special: only available in non-production. Wrap the router mount with an environment check in router.ts or expose the procedures but have them throw SERVICE_UNAVAILABLE in prod — follow whichever pattern the existing code uses.
messages domainhttp-api.ts): list (with thread filter, pagination), get by id, create, delete.Handler lift: handleMessages* lives in http-api.ts — extract the logic into the router, don’t re-use the Response-returning handler.
http-api.ts (keep the file; it still has other REST-staying handlers).threads domainEndpoints (from http-api.ts): list, get, create, update (rename/metadata), delete.
Step 1–6: follow the playbook.
jobs domainhttp-api.ts): list (with workflow filter, pagination), get, cancel, delete.Special: cancelling a running job may involve cross-cutting concerns with the kernel actor runtime. Ensure the cancel procedure keeps the same semantic as the current REST handler.
files domain (JSON ops only)file-api.ts): list, metadata, rename, delete.REST retained: binary read and write. Shrink routes/files.ts to keep those; remove JSON ops.
storage domain (JSON ops only)storage-api.ts): list, metadata, delete.REST retained: binary PUT/GET. Shrink routes/storage.ts.
nodes domain (JSON ops only)http-api.ts + routes/nodes.ts): search by query, list-by-namespace, any other JSON read endpoints.REST retained: /api/nodes/metadata (public, unauth, consumed at boot). Shrink routes/nodes.ts to keep only that.
/api/nodes/metadata — it stays REST and must remain unauth (see the auth-hook bypass in server.ts).models domainEndpoints (from models-api.ts): list available models, get model details, search. registerPythonProviders is server-internal and not exposed over HTTP — don’t touch.
Step 1–6: follow the playbook.
assets domain (JSON ops only)http-api.ts): list root/by-id, create, update, delete, children, recursive, search, by-filename (metadata lookup), packages list.by-filename/:filename if it returns binary. Shrink routes/assets.ts to keep only binary routes.This is large. The asset handlers in http-api.ts are the biggest chunk of the file. Read them carefully before writing schemas.
workflows domain (JSON ops only)http-api.ts): list, get, create, update, delete, run, autosave (POST/PUT), versions list/get/restore/delete, tools, examples (list, search), public (list, get), generate-name, app metadata.dsl-export (returns .ts file as download), gradio-export (returns file). Shrink routes/workflows.ts to keep these.Largest domain. Read the current handleWorkflow* handlers carefully — versions and autosave have subtle semantics.
web/src/api.ts and purge openapi consumersFiles:
web/src/api.tsweb/src/stores/ApiClient.ts (if it’s no longer imported anywhere)Modify: call sites that still import from either
Before deleting stores/ApiClient.ts, move its env exports (isLocalhost, isDevelopment, isProduction, isElectron, setForceLocalhost) into a dedicated file so they survive the deletion.
Create web/src/lib/env.ts:
import { isElectron as browserIsElectron } from "../utils/browser";
import log from "loglevel";
const getForcedLocalhost = (): boolean | null => {
if (typeof window === "undefined") return null;
const envForce = import.meta.env.VITE_FORCE_LOCALHOST;
if (envForce === "true" || envForce === "1") return true;
if (envForce === "false" || envForce === "0") return false;
const urlParams = new URLSearchParams(window.location.search);
const queryForce = urlParams.get("forceLocalhost");
if (queryForce === "true" || queryForce === "1") return true;
if (queryForce === "false" || queryForce === "0") return false;
try {
const stored = localStorage.getItem("forceLocalhost");
if (stored === "true" || stored === "1") return true;
if (stored === "false" || stored === "0") return false;
} catch {}
return null;
};
export const isLocalhost = ((): boolean => {
const forced = getForcedLocalhost();
if (forced !== null) return forced;
return (
typeof window !== "undefined" &&
(window.location.hostname.includes("dev.") ||
window.location.hostname === "127.0.0.1" ||
window.location.hostname === "localhost")
);
})();
export const isDevelopment = isLocalhost;
export const isProduction = !isLocalhost;
export const isElectron = browserIsElectron;
export const setForceLocalhost = (force: boolean | null): void => {
if (typeof window === "undefined") return;
try {
if (force === null) localStorage.removeItem("forceLocalhost");
else localStorage.setItem("forceLocalhost", force ? "true" : "false");
window.location.reload();
} catch (error) {
log.warn("Failed to set forceLocalhost preference:", error);
}
};
if (typeof window !== "undefined") {
window.isProduction = isProduction;
window.isLocalhost = isLocalhost;
window.isElectron = isElectron;
window.setForceLocalhost = setForceLocalhost;
}
Update Task 1.7’s trpc/client.ts and trpc/Provider.tsx to import isLocalhost from ../lib/env instead of ../stores/ApiClient. Do the same sweep across the web codebase for any other import of these symbols from ApiClient.
Run: cd /Users/mg/workspace/nodetool && grep -rn "from \"../api\"\|from \"../../api\"\|stores/ApiClient\|openapi-fetch" web/src
Expected: empty (Phase 3 migrations plus Step 0 above should have removed every consumer).
Each should either move to tRPC (if it’s a migrated endpoint) or stay on fetch() (if it’s a REST-retained endpoint). For REST-retained (binary downloads, OpenAI /v1/*, OAuth), write a thin fetch-based helper in web/src/lib/rest-fetch.ts and import that instead of stores/ApiClient.
Example helper:
// web/src/lib/rest-fetch.ts
import { supabase } from "./supabaseClient";
import { isLocalhost } from "../stores/ApiClient";
import { BASE_URL } from "../stores/BASE_URL";
export async function restFetch(input: RequestInfo, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
if (!isLocalhost) {
const { data: { session } } = await supabase.auth.getSession();
if (session) headers.set("Authorization", `Bearer ${session.access_token}`);
}
const url = typeof input === "string" ? `${BASE_URL}${input}` : input;
return fetch(url, { ...init, headers });
}
rm web/src/api.ts
# Only if ApiClient is no longer imported:
git grep "stores/ApiClient" web/src || rm web/src/stores/ApiClient.ts
Run:
cd /Users/mg/workspace/nodetool/web && npm run typecheck && npm test && npm run build
Expected: all pass.
git add -A web/
git commit -m "refactor(web): remove generated api.ts and openapi-fetch client"
Files:
mobile/src/trpc/client.ts, mobile/src/trpc/Provider.tsxmobile/src/api.tsModify: every call site that imported from mobile/src/api.ts
Run: cd /Users/mg/workspace/nodetool && cd packages/protocol && npm run build (mobile’s typecheck requires protocol built — per root CLAUDE.md).
mobile/src/trpc/client.ts// mobile/src/trpc/client.ts
import {
createTRPCClient,
httpBatchLink,
type TRPCClientErrorLike
} from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@nodetool/websocket/trpc";
// Adjust to mobile's auth source
import { getAuthToken, getBaseUrl } from "../services/auth";
export function createMobileTRPCClient() {
return createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${getBaseUrl()}/trpc`,
transformer: superjson,
async headers() {
const token = await getAuthToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
})
]
});
}
export type TRPCClientError = TRPCClientErrorLike<AppRouter>;
(If mobile uses TanStack Query + hooks, mirror web/src/trpc/Provider.tsx with mobile-appropriate auth.)
mobile/src/api.tsGrep, migrate call site by call site, same pattern as Phase 3 step 5.
mobile/src/api.tsrm mobile/src/api.ts
Run: cd /Users/mg/workspace/nodetool/mobile && npm run typecheck
Expected: passes.
git add -A mobile/
git commit -m "refactor(mobile): migrate to @trpc/client; delete generated api.ts"
packages/cli/src/nodetool.tsFiles:
Modify: packages/cli/src/nodetool.ts
Step 1: Add a trpc client factory at the top of nodetool.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@nodetool/websocket/trpc";
function createClient(apiUrl: string) {
return createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${apiUrl}/trpc`,
transformer: superjson
})
]
});
}
apiGet/apiPost callExample — the workflows list command currently does:
const data = await apiGet(opts.apiUrl, `/api/workflows?limit=${opts.limit}`);
becomes:
const client = createClient(opts.apiUrl);
const data = await client.workflows.list.query({ limit: opts.limit });
Apply to the ~10 sites (the grep in Phase 3 covers them).
apiGet/apiPost helpersThey’re now unused. Remove the fetch-based helpers at the top of nodetool.ts.
Run:
cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/cli && npm test --workspace=packages/cli
Expected: passes.
git add packages/cli/
git commit -m "refactor(cli): migrate nodetool command to @trpc/client"
electron/src/api.tsFiles:
Modify: electron/src/api.ts
Step 1: Replace the fetch-based client with tRPC
// electron/src/api.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@nodetool/websocket/trpc";
import { Workflow } from "./types";
import { logMessage } from "./logger";
import { getServerUrl } from "./utils";
export let isConnected = false;
function makeClient() {
return createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${getServerUrl("")}/trpc`,
transformer: superjson
})
]
});
}
export async function fetchWorkflows(): Promise<Workflow[]> {
logMessage("Fetching workflows from server...");
try {
const client = makeClient();
const data = await client.workflows.list.query({});
logMessage(`Successfully fetched ${data.workflows?.length ?? 0} workflows`);
return data.workflows ?? [];
} catch (error) {
if (error instanceof Error) {
logMessage(`Failed to fetch workflows: ${error.message}`, "error");
}
return [];
}
}
Migrate any other fetch-based helpers in the file the same way.
Run:
cd /Users/mg/workspace/nodetool/electron && npm run typecheck && npm test
Expected: passes.
git add electron/src/api.ts
git commit -m "refactor(electron): migrate main-process api client to @trpc/client"
admin-client.ts and api-user-manager.tsFiles:
packages/deploy/src/admin-client.tsModify: packages/deploy/src/api-user-manager.ts
In each file, replace the fetch-based request method with a tRPC client. Example admin-client.ts:
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "@nodetool/websocket/trpc";
export class AdminClient {
private client: ReturnType<typeof createTRPCClient<AppRouter>>;
constructor(private baseUrl: string, token: string) {
this.client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: `${baseUrl}/trpc`,
transformer: superjson,
headers: { Authorization: `Bearer ${token}` }
})
]
});
}
async listWorkflows() {
return this.client.workflows.list.query({});
}
// ...
}
Step 2: Same treatment for api-user-manager.ts
Step 3: Typecheck + test deploy
Run:
cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/deploy && npm test --workspace=packages/deploy
Expected: passes.
git add packages/deploy/
git commit -m "refactor(deploy): migrate admin-client and api-user-manager to @trpc/client"
http-api.ts to only REST-staying functionsFiles:
Modify: packages/websocket/src/http-api.ts
Step 1: Grep for which handleXxx functions are still imported
Run: cd /Users/mg/workspace/nodetool && grep -rn "handle[A-Z]" packages/websocket/src/routes
The remaining handlers should be for: binary asset routes, binary storage routes, binary file routes, binary workspace read, workflow dsl/gradio export, nodes metadata. Everything else is dead code.
For each handleXxx function that no routes/*.ts imports anymore, delete it along with any helpers used only by it.
Keep:
getUserId (still used by REST-staying routes)parseLimitthe specific handleXxx functions still imported
Run: cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/websocket
Expected: passes.
Run: cd /Users/mg/workspace/nodetool/packages/websocket && npm test
Expected: all pass.
git add packages/websocket/src/http-api.ts
git commit -m "refactor(websocket): shrink http-api.ts to REST-staying handlers only"
*-api.ts helpersFiles:
Delete: packages/websocket/src/{collection,cost,file,models,settings,skills,storage,users,workspace}-api.ts — whichever files are fully migrated and have no remaining exports. oauth-api.ts and openai-api.ts are retained.
Step 1: For each file, verify no imports remain
Run: cd /Users/mg/workspace/nodetool && for f in collection-api cost-api file-api models-api settings-api skills-api storage-api users-api workspace-api; do echo "=== $f ==="; grep -rn "\"../$f\"\|\"./$f\"\|from .*$f" packages/websocket/src; done
Expected: each file has zero remaining imports from the rest of the codebase.
cd /Users/mg/workspace/nodetool
rm packages/websocket/src/cost-api.ts # already deleted in Phase 2
# Repeat for each file with no remaining imports
file-api.ts still has the binary handler):Keep the file but remove the functions now served by tRPC.
Run:
cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/websocket && npm test --workspace=packages/websocket
Expected: passes.
git add -A packages/websocket/src/
git commit -m "refactor(websocket): delete fully-migrated REST handler files"
routes/*.tsFiles:
packages/websocket/src/routes/*.ts (each remaining file)Modify: packages/websocket/src/server.ts (remove register calls for deleted route plugins)
Run: cd /Users/mg/workspace/nodetool && ls packages/websocket/src/routes/
For each file still present, verify it only exposes endpoints from the “stays REST” list:
health.ts ✓oauth.ts ✓openai.ts ✓assets.ts — only binary (thumbnail, package asset, by-filename/:filename)workflows.ts — only dsl/gradio exportnodes.ts — only /metadatafiles.ts — only binary read/writestorage.ts — only binary PUT/GETworkspace.ts — only binary readIf a file is empty (no routes declared), delete it and remove the app.register(...) line in server.ts.
server.tsFor every deleted file, delete its import and its await app.register(...) line.
Run:
cd /Users/mg/workspace/nodetool && npm run typecheck --workspace=packages/websocket && npm test --workspace=packages/websocket
Expected: passes.
git add -A packages/websocket/
git commit -m "refactor(websocket): audit and clean up surviving REST routes"
/trpc auth bypass list in server.tsFiles:
Modify: packages/websocket/src/server.ts
Step 1: Inspect the auth-hook public-routes list
In the auth onRequest hook near line ~417, confirm the public-routes list contains only:
/health/ready/api/oauth/*/api/assets/packages and /api/assets/packages/* (still public)/api/nodes/metadataNot /trpc/* — all tRPC procedures should go through the standard auth flow and get a userId. Individual procedures use publicProcedure if they want to allow anonymous access (the healthz example is one).
Step 2: If anything is off, fix it
Step 3: Test that unauthenticated tRPC calls work in dev (localhost→userId=1)
Run dev server locally and: curl http://127.0.0.1:7777/trpc/healthz
Expected: 200 with ok: true.
Also try a protected procedure: curl http://127.0.0.1:7777/trpc/costs.summary
Expected: 200 with summary data (localhost dev user).
git add packages/websocket/src/server.ts
git commit -m "chore(websocket): verify auth hook public-route list is correct post-migration"
Files: none
Run: cd /Users/mg/workspace/nodetool && npm run check
Expected: typecheck + lint + test all pass across all workspaces.
Run: cd /Users/mg/workspace/nodetool && npm run build:packages
Expected: all packages build successfully.
# In terminal 1:
cd /Users/mg/workspace/nodetool && npm run dev
# In terminal 2:
cd /Users/mg/workspace/nodetool/web && npm start
# Navigate to http://localhost:3000, verify the app loads data correctly
Then close everything.
Step 4: If anything breaks, fix and repeat
Step 5: Commit any fixes discovered
Template for the PR body:
## Summary
Big-bang migration of the JSON REST API under `/api/*` to tRPC. The OpenAI-compatible `/v1/*`, OAuth, MCP, health, admin, binary/stream downloads, and `/api/nodes/metadata` remain REST.
## Domains migrated
| Domain | Procedures | Server LOC delta | Client call sites migrated |
|---|---|---|---|
| costs | 5 | -128 (deleted) | N |
| settings | K | -S | M |
...
## Test plan
- [ ] All per-router unit tests pass (`createCaller` based)
- [ ] Integration smoke tests pass (`app.inject`)
- [ ] Web app loads, lists workflows, opens a workflow, runs a node
- [ ] Mobile app launches and lists workflows
- [ ] `nodetool workflows list` and `nodetool workflows run` work
- [ ] Electron desktop app launches and lists workflows
- [ ] Deploy package `admin-client` smoke-tested against a running server
## Out of scope
- WebSocket `/ws` protocol unchanged
- `/agent` socket unchanged
- OpenAI `/v1/*` unchanged
- OAuth, MCP, health, admin, binary downloads unchanged
gh pr create --draft when ready.
http-api.ts have accumulated subtleties (pagination cursors, optional filters, default values) that the zod schemas and router procedures must reproduce.next_start_key, total_tokens, workflow_id), and external consumers (deploy, cli) rely on these shapes. tRPC + superjson doesn’t force camelCase; preserve the existing keys.npm run typecheck --workspace=packages/websocket after every router change. The AppRouter type surface changes on every new router, and downstream clients will see the change immediately.createCaller unit tests are the safety net. If you find yourself wanting to skip them for a “trivial” procedure, the procedure is fetching something that could regress — write the test.