Date: 2026-04-17
Status: Approved (brainstorm)
Scope: Replace the JSON REST API under /api/* with tRPC procedures. Keep the OpenAI-compatible /v1/* chat server, OAuth, MCP, health probes, admin, binary/stream downloads, and /api/nodes/metadata as REST.
web, mobile, cli, electron main).web/src/api.ts and the openapi-typescript/openapi-fetch toolchain.http-api.ts + *-api.ts helpers + routes/*.ts plugins) into one file per domain./ws MsgPack workflow kernel protocol./agent WebSocket route./v1/* endpoints.JSON request/response endpoints under:
/api/assets/* — list, CRUD, search, metadata. Binary download and thumbnail endpoints stay REST./api/workflows/* — list, CRUD, run, autosave, versions, generate-name, tools, examples, public lookup. dsl-export and gradio-export stay REST (file download responses)./api/jobs/*/api/messages/*/api/threads/*/api/nodes/* JSON search/enumeration. /api/nodes/metadata stays REST (public, unauth, boot-time)./api/settings/*/api/storage/* — JSON operations only. Binary PUT/GET stay REST./api/users/*/api/workspace/*/api/files/* — JSON ops only./api/costs/*/api/skills/*/api/collections/*/api/models/*/api/mcp-config/*/v1/* — OpenAI-compatible chat server./api/oauth/* — browser redirects, third-party callbacks./mcp — Model Context Protocol./health, /ready — infra probes./admin/secrets/* — out-of-band deploy tooling with shared master key./api/nodes/metadata — public, unauth, consumed before client boots./api/* (asset download, thumbnail, package asset file, storage GET/PUT of bytes, DSL export, Gradio export).fastifyStatic.Router code lives in packages/websocket/src/trpc/:
trpc/
index.ts # initTRPC with superjson transformer + errorFormatter; exports publicProcedure/protectedProcedure
context.ts # createContext({ req, apiOptions, registry, pythonBridge }) → Context
error-formatter.ts # TRPCError ↔ ApiErrorCode mapping
middleware.ts # protectedProcedure enforcing ctx.userId !== null
router.ts # appRouter composing sub-routers; export type AppRouter
routers/
workflows.ts
assets.ts
jobs.ts
messages.ts
threads.ts
nodes.ts
settings.ts
users.ts
workspace.ts
files.ts
costs.ts
skills.ts
collections.ts
models.ts
mcp-config.ts
storage.ts
Each router file absorbs both the current routes/<domain>.ts plugin and the logic from the matching *-api.ts helper. The 2.9k-line http-api.ts is decomposed across these files; functions that still serve REST-staying routes (binary/stream/metadata) remain in a slimmed-down http-api.ts.
Mounted onto the existing Fastify instance at prefix /trpc via @trpc/server/adapters/fastify:
import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
await app.register(fastifyTRPCPlugin, {
prefix: "/trpc",
trpcOptions: { router: appRouter, createContext }
});
The existing onRequest auth hook runs before tRPC and sets req.userId. createContext simply reads it — no duplicate auth logic.
Zod schemas live in @nodetool/protocol/src/api-schemas/ (zod is already a dep of the protocol package), one file per domain. Each file exports the input and output schemas for that domain’s procedures. Server procedures import them for validation; client code imports them for form validation where useful.
packages/protocol/src/api-schemas/
workflows.ts
assets.ts
jobs.ts
...
index.ts # re-exports
Complex entity types (Workflow, Asset, Job) already have TypeScript types in @nodetool/protocol or @nodetool/models. Zod schemas may reference these via z.custom<T>() where full runtime parsing is overkill, or be defined from scratch where input shape differs from the entity.
@trpc/server/adapters/fastify on the server.httpBatchLink on the clients. Batches simultaneous queries into one HTTP request.superjson as the tRPC transformer. Preserves Date, Map, Set, BigInt, undefined across the wire — removes the current JSON-over-HTTP string-date workarounds.Custom errorFormatter on the server extends shape.data with:
apiCode: a string from the existing ApiErrorCode enum (WORKFLOW_NOT_FOUND, ASSET_NOT_FOUND, INVALID_INPUT, etc.) so clients can discriminate between error types that share a TRPCError code.zodError: structured field-path errors for input validation failures (auto-populated by tRPC when zod throws).Procedure bodies call throwApiError(ApiErrorCode.X, "message", trpcCode?) — a small helper that constructs a TRPCError with the apiCode attached to cause. The tRPC errorFormatter reads cause.apiCode and surfaces it on shape.data.apiCode. This is a helper-at-call-site pattern (not a middleware) — explicit at the point the error is raised, no magic error-to-code translation layer.
Client helper isTRPCErrorWithCode(err, ApiErrorCode.X) for call sites that branch on specific errors.
interface Context {
userId: string | null;
registry: NodeRegistry;
apiOptions: HttpApiOptions;
pythonBridge: PythonStdioBridge;
getPythonBridgeReady: () => boolean;
}
userId comes from the Fastify auth hook (req.userId) — single source of truth. The existing getUserId(request, headerName) helper is retained only for the REST-staying handlers that read it directly; new tRPC procedures read ctx.userId.apiOptions/closures to the REST handlers. The existing construction in server.ts is lifted into a createContextFactory that closes over them and returns createContext.Web (web/src/trpc/):
client.ts — createTRPCReact<AppRouter>() + createTRPCClient with httpBatchLink({ url: BASE_URL + "/trpc" }) + superjson + auth middleware (Supabase JWT on production, no-op on localhost).Provider.tsx — wraps the app with trpc.Provider and QueryClientProvider. Replaces nothing (TanStack Query is already set up) — the new provider slots in alongside the existing QueryClient.web/src/serverState/*.ts — existing hooks (useWorkflow, useAssets, etc.) are rewritten on top of tRPC’s useQuery/useMutation/useInfiniteQuery. Same public hook signatures where possible so component call sites don’t change; internal implementation delegates to tRPC.web/src/api.ts, openapi-fetch + openapi-typescript deps, the openapi npm script.Mobile (mobile/src/trpc/): same pattern as web. Deletes mirror web’s.
CLI (packages/cli):
createTRPCClient<AppRouter>({ links: [httpBatchLink({ url })] }), URL from --api-url option.fetch() call sites in nodetool.ts migrate to typed tRPC procedure calls.Electron main (electron/src/api.ts): vanilla createTRPCClient same as CLI. ~5 call sites migrate.
packages/websocket/package.json exposes a subpath for the router, following the repo’s existing export-condition pattern (nodetool-dev for source, types for declarations, import/default for built dist):
"exports": {
".": { ... current ... },
"./trpc": {
"nodetool-dev": "./src/trpc/router.ts",
"types": "./src/trpc/router.ts",
"import": "./dist/trpc/router.js",
"default": "./dist/trpc/router.js"
}
}
Clients use import type { AppRouter } from "@nodetool/websocket/trpc" — type-only import, no runtime coupling (TypeScript erases type-only imports at build). The router module is structured so nothing at its top level executes on import; all procedures and sub-routers are declared as values inside the appRouter builder, which is tree-shakable.
client hook (useQuery)
→ httpBatchLink (batches sibling queries into one POST /trpc/…)
→ superjson encode
→ Fastify onRequest hook → sets req.userId from token
→ @trpc/server/adapters/fastify
→ createContext({ req, apiOptions, registry, pythonBridge })
→ zod .input() parse (400 on failure, with zodError in data)
→ procedure body
→ result
→ superjson encode
→ client: typed result, Date/Map/Set preserved
@trpc/* deps, create trpc/ skeleton, createContext, errorFormatter, Fastify mount, protocol api-schemas/ skeleton, superjson.costs) end-to-end — smallest server-side surface (cost-api.ts is 128 lines). Server router, protocol schemas, one web hook, one web call site. Verify transport, auth, error formatter, superjson, TanStack Query integration all work. This is the pattern the rest follows.web/src/serverState/ onto tRPC hooks. Delete web/src/api.ts. Remove openapi-fetch from web/.*-api.ts helper that only served a migrated endpoint is removed. http-api.ts keeps only what still serves REST-staying routes.appRouter.createCaller(ctx); a small e2e suite exercises the Fastify transport + auth + error formatter via app.inject().appRouter.createCaller({ ctx }) invokes procedures in-process. Fast, no HTTP. Replaces the current handler-level tests in http-api.test.ts.app.inject({ method: "POST", url: "/trpc/…", payload }) to verify the Fastify adapter, auth hook, and error formatter wire together correctly.createTRPCMsw).Server:
packages/websocket/src/http-api.ts — shrunk to cover only REST-staying endpoints.packages/websocket/src/{collection,cost,file,models,oauth,openai,settings,skills,storage,users,workspace}-api.ts — the parts feeding migrated endpoints deleted; OAuth and OpenAI helpers kept.packages/websocket/src/routes/{workflows,assets,jobs,messages,threads,nodes,settings,users,workspace,files,costs,skills,collections,models,mcp-config,storage}.ts — shrunk to REST-staying paths or deleted outright.Client:
web/src/api.ts (14k lines).openapi-fetch, openapi-typescript deps + openapi script in web/ and mobile/.createCaller harness.ApiError discriminated union as a secondary return type on the client (enabling exhaustive switch on error codes), or stay with the current “throw and branch” style./api/* endpoint that returns streaming JSON (e.g. progressive workflow generation) should move to tRPC with an async iterator return, or stay REST.openapi-fetch with @trpc/client + @trpc/react-query + superjson may net-add bundle size. Needs measurement before merge.