everything-dev
add-a-route
Add one API endpoint end to end in everything.dev — contract route with Zod schemas, Effect-native handler in the plugin router, UI call via useApiClient, route file rendering the data, typecheck, publish. Use when adding an API endpoint, wiring a new UI page to app data, or debugging the contract/handler/client chain.
Add a route
One endpoint, end to end. The slice below is complete — copy it, rename, and it runs. Everything is typed from the contract: the handler's input/context, the generated client, and the UI's response shape.
Where the route lives
| The task in front of you | Put it in |
|---|---|
| App-shell glue (health, infra) | api/src/contract.ts + api/src/index.ts |
| A feature (things, registry, proposals, votes, chat) | a plugin under plugins/<name>/ |
Feature routes live in plugins; api/ is a slim shell. New plugins: copy plugins/_template/ (everything-dev#plugin-development).
The slice
1. Contract (plugins/<name>/src/contract.ts)
import { oc } from "@orpc/contract";
import { z } from "zod";
import { FORBIDDEN, NOT_FOUND, UNAUTHORIZED } from "every-plugin/errors";
export const WidgetSchema = z.object({
id: z.string(),
title: z.string(),
});
export const contract = oc.router({
getWidget: oc
.route({ method: "GET", path: "/widgets/{id}" })
.input(z.object({ id: z.string().min(1) }))
.output(z.object({ widget: WidgetSchema }))
.errors({ NOT_FOUND, UNAUTHORIZED, FORBIDDEN }),
});
export type ContractType = typeof contract;Path params ({id}) bind to the Zod input keys automatically.
2. Handler (plugins/<name>/src/index.ts)
export default createPlugin.withPlugins<PluginsClient>()({
// variables, secrets, context, contract — see the template
createRouter: (builder) => ({
getWidget: builder.getWidget.effect(function* ({ input, context, errors }) {
if (!context.userId) {
return yield* Effect.fail(errors.UNAUTHORIZED({ apiKeyProvided: !!context.apiKey }));
}
const widgets = yield* WidgetsService;
const widget = yield* widgets.getWidget(input.id);
return { widget };
}),
}),
});The tag (WidgetsService) must be exposed from the returned initialize layer. Handlers are Effect generators — see Rules below.
3. UI call (any component)
import { useApiClient } from "@/app";
const apiClient = useApiClient();
const { data } = await apiClient.template.listThings({ limit: 20 });The client is namespaced by the plugin key in bos.app.ts — a widgets plugin is apiClient.widgets.* — and typed from the contract; ui/src/lib/api-types.gen.ts regenerates on pnpm run typecheck / bos dev.
4. Route file (ui/src/routes/<area>.tsx)
File-based routing — TanStack Router generates the tree. See everything-dev#ui-integration for loaders, SSR, and the page-header/testid conventions.
5. Verify, then publish
pnpm run typecheck # regenerates types, then checks everything
pnpm run build # the train — never per-workspace raw builds
pnpm run deploy # preflight → build → upload → publish → imageThe tasks you will actually be given
"Add a read-only endpoint for X." Contract → handler → UI query. No auth schema needed; still add .errors({ NOT_FOUND }) for typed misses.
"Add an endpoint only signed-in users can write." Same slice, plus the context.userId guard from step 2. For roles, orgs, or API-key permissions, load everything-dev#api-and-auth for the middleware pattern.
"The UI can't see the new route." Types are generated: run pnpm run typecheck (self-sufficient), or restart bos dev. If the client name is wrong, the plugin key in bos.app.ts is the namespace.
What comes back when it fails
| word | do |
|---|---|
| Type 'DecoratedMiddleware'… in .use() | middleware typing does not compose with .use() — use the local-middleware pattern (plugins/proposals/src) |
| UNAUTHORIZED type error | its data requires { apiKeyProvided: boolean }; all-optional shapes still need explicit data: {} |
| Effect lint flags floating effects | pnpm run lint:effect; tsc clean is not enough |
| ModuleFederationError / __webpack_modules__ | the deployed plugin's mf-manifest.json is older than the host's — bos mf check, then republish the plugin |
| 404 on the new route | the route lives in a plugin — the path is /api/rpc/<pluginKey>/… or /api/<contract path> |
Rules to work by
- .effect(function* ...), never new .handler(async). Services come from yield* Tag in generators; Context.get(context["effect/context"], Tag) is reserved for streaming (async-generator) handlers.
- Effect.fail, not throws. Definitive failure exits are return yield* Effect.fail(...); shared shapes come from every-plugin/errors.
- No Effect.provide(Tag, Layer.effect(...)) for persistent deps. Build scoped services with buildScoped(...) inside initialize — transient scopes release immediately.
- Typecheck before publish. pnpm run typecheck regenerates all types.