These rules apply to files under apps/sim/ in addition to the repository root AGENTS.md.
- Single Responsibility: Each component, hook, store has one clear purpose
- Composition Over Complexity: Break down complex logic into smaller pieces
- Type Safety First: TypeScript interfaces for all props, state, return types
- Predictable State: Zustand for global state, useState for UI-only concerns
- Every protected read, write, canonical resource lookup, or authorization-sensitive reference resolution enters through an authorized application use case.
- Define one stable semantic operation with its role, workspace-key policy, principal kinds, and delegated services. Internal, v2, Copilot, and trusted-tool adapters call the same use case when domain behavior is the same.
- Surface adapters authenticate and build a
Principal, rate-limit, parse, map, and present. They do not query protected data, authorize resources, implement business transactions, or record semantic audit. - Application use cases canonicalize scope, authorize current access, execute managers/repositories, project semantic audit, and trigger shared domain effects. Application code stays surface-neutral and never imports
app/api/**,next/server, route contracts/presenters, or Copilot handlers. - Copilot uses
createCopilotApplicationAdapter; it does not own separate protected business logic. Compound protected mutations require a top-level semantic application operation. - Never substitute billing attribution, an uploader, creator, or API-key owner for the acting principal. Fail fast when the identity or policy cannot be represented.
- Use the
migrate-application-operationskill for new and migrated protected endpoints, tool commands, and resource methods.
apps/
├── sim/ # this app (Next.js: UI + API routes + workflow editor)
│ ├── app/ # Next.js app router (pages, API routes)
│ ├── blocks/ # Block definitions and registry
│ ├── components/ # Shared UI (emcn/, ui/)
│ ├── executor/ # Workflow execution engine
│ ├── hooks/ # Shared hooks (queries/, selectors/)
│ ├── lib/ # App-wide utilities
│ ├── providers/ # LLM provider integrations
│ ├── stores/ # Zustand stores
│ ├── tools/ # Tool definitions
│ └── triggers/ # Trigger definitions
└── realtime/ # Bun Socket.IO server (collaborative canvas)
packages/ # @sim/* — audit, auth, db, logger, realtime-protocol,
# security, tsconfig, utils, platform-authz,
# workflow-persistence, workflow-types
The Socket.IO collaborative-canvas server lives in a separate workspace at
apps/realtime/. It shares DB + auth with apps/sim via the @sim/*
packages. apps/* → packages/* only — packages never import from apps/*.
Do not add imports from @/lib/webhooks/providers/*, @/executor/*,
@/blocks/*, or @/tools/* to any package consumed by apps/realtime —
those heavyweight registries stay in this app. apps/realtime calls back
into this app only over internal HTTP with INTERNAL_API_SECRET. CI enforces
these boundaries via scripts/check-monorepo-boundaries.ts and
scripts/check-realtime-prune-graph.ts.
Features live under app/workspace/[workspaceId]/:
feature/
├── components/ # Feature components
├── hooks/ # Feature-scoped hooks
├── utils/ # Feature-scoped utilities (2+ consumers)
├── feature.tsx # Main component
└── page.tsx # Next.js page entry
- Components: PascalCase (
WorkflowList) - Hooks:
useprefix (useWorkflowOperations) - Files: kebab-case (
workflow-list.tsx) - Stores:
stores/feature/store.ts - Constants: SCREAMING_SNAKE_CASE
- Interfaces: PascalCase with suffix (
WorkflowListProps)
- Always use absolute imports from
@/...; do not add relative imports. - Use barrel exports only when a folder has 3+ exports; do not re-export through non-barrel files.
- Use
import typefor type-only imports. - Do not use
any; prefer precise types orunknownwith guards.
- Use
'use client'only when hooks or browser-only APIs are required. - Define a props interface for every component.
- Extract constants with
as constwhere appropriate. - Use Tailwind classes and
cn()for conditional classes; avoid inline styles unless CSS variables are the intended mechanism. - Keep styling local to the component; do not modify global styles for feature work.
Boundary HTTP request and response shapes for all routes under apps/sim/app/api/** live in apps/sim/lib/api/contracts/** (one file per resource family). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract.
- Each contract is built with
defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })from@/lib/api/contracts. - Contracts export named schemas AND named TypeScript type aliases (e.g.,
export type CreateFolderBody = z.input<typeof createFolderBodySchema>). Clients import the named aliases — neverz.input<...>/z.output<...>in hooks. - Shared identifier schemas live in
apps/sim/lib/api/contracts/primitives.ts(e.g.,workspaceIdSchema,workflowIdSchema). - Audit script:
bun run check:api-validationenforces boundary policy and prints ratchet metrics for route Zod imports, route-local schema constructors, routeZodErrorreferences, client hook Zod imports, and related counters. It must pass on PRs.bun run check:api-validation:strictis the strict CI gate and additionally fails on annotations with empty reasons. - Domain validators that are not HTTP boundaries — tools, blocks, triggers, connectors, realtime handlers, and internal helpers — may still use Zod directly. The contract rule is boundary-only.
A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes four annotation forms:
// boundary-raw-fetch: <reason>— placed on the line directly above a rawfetch(call insideapps/sim/hooks/queries/**,apps/sim/hooks/selectors/**, or any other client/UI source underapps/sim/**that targets a same-origin/api/...URL. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests.// double-cast-allowed: <reason>— placed on the line directly above anas unknown as Xcast outside test files.// boundary-raw-json: <reason>— placed on the line directly above a rawawait request.json()/await req.json()read (or the multi-lineawait request.clone().json()shim variant) in a route handler. Use only when the body is a JSON-RPC envelope, a tolerant.catch(() => ({}))parse, or otherwise cannot go throughparseRequest.// untyped-response: <reason>— placed on the line directly above aschema: z.unknown()/schema: z.object({}).passthrough()/schema: z.record(z.string(), z.unknown())response declaration (or a simple alias to one of those) in a contract file. Use only when the response body is genuinely opaque (user-supplied data, third-party passthrough).
Placement rule: the annotation must immediately precede the call or cast. Up to three non-empty preceding comment lines are tolerated, so additional context comments above the annotation are fine. The reason must be non-empty after trimming — annotations with empty reasons fail strict mode (annotationsMissingReason).
Whole-file allowlists for routes (legitimate non-boundary or auth-handled routes that legitimately import Zod for non-boundary reasons) go through INDIRECT_ZOD_ROUTES in scripts/check-api-validation-contracts.ts, not per-line annotations.
Examples:
// boundary-raw-fetch: streaming SSE chunks must be processed as they arrive
const response = await fetch(`/api/copilot/chat/stream?chatId=${chatId}`, { signal })// double-cast-allowed: legacy provider type lacks the discriminator field we need
const provider = config as unknown as LegacyProvider// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
const body = await request
.clone()
.json()
.catch(() => undefined)// untyped-response: forwards firecrawl /v2/parse response unchanged for downstream tool consumers
output: z.unknown(),Every route method must run inside withRouteHandler. Ordinary internal and v2 JSON/binary routes use defineInternalJsonRoute, defineV2JsonRoute, or the matching binary/stream builder. These builders already apply withRouteHandler; never wrap them again. Use raw withRouteHandler only for explicit protocol or lifecycle exceptions such as streaming, multipart control, large-body admission, OAuth, or public execution.
Routes never import { z } from 'zod' and never define route-local boundary schemas. Declarative builders consume contracts and own authentication, admission, parsing, use-case execution, response validation, and error projection. A raw special route consumes the same contracts and validates with canonical helpers from @/lib/api/server, after authentication and cheap admission:
parseRequest(contract, request, context, options?)— fully contract-bound routes; parses params, query, body, and headers in one call. Pass{}forcontexton routes without route params, or the route'scontextargument when route params exist. Returns a discriminated union; checkparsed.successand returnparsed.responseon failure.validationErrorResponse(error)andgetValidationErrorMessage(error, fallback)— produce 400 responses from aZodError.validationErrorResponseFromError(error)— when handling unknown caught errors that may or may not be aZodError.isZodError(error)— type guard. Routes never useinstanceof z.ZodError.
export const PATCH = defineInternalJsonRoute({
contract: renameWidgetContract,
auth: internalSessionAuth,
operation: widgetOperations.rename,
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }),
errorPolicy: internalWidgetErrorPolicy,
mapInput: ({ params, body }) => ({
widgetId: params.widgetId,
assertedWorkspaceId: params.workspaceId,
name: body.name,
}),
useCase: renameWidget,
present: ({ widget }) => ({ success: true, widget }),
})The contract, operation, and use case must agree at definition time. Authentication and request-rate admission happen before parsing; canonical loading and authorization happen in the application use case. The presenter returns only the surface success body.
Routes under apps/sim/app/api/v1/** use the shared middleware in apps/sim/app/api/v1/middleware.ts for auth, rate-limit, and workspace access. Compose contract validation inside that middleware — never reimplement auth/rate-limit per-route.
Never export a bare async function GET/POST/.... Export the result of a shared builder or, for a documented special route, withRouteHandler(...).
When adding a new route + client surface, follow this order. Each step has one place it lives.
- Author the contract first in
apps/sim/lib/api/contracts/<domain>.ts(or a subdirectory for large domains:knowledge/,selectors/,tools/). Define one schema per request slice (params,query,body,headers) and one for the response, then wrap withdefineRouteContract. Export named type aliases (z.inputfor inputs,z.outputfor outputs). - Define the semantic operation and application use case under
apps/sim/lib/<domain>/application/. The use case owns canonical loading, asserted-scope checks, current authorization, business behavior, semantic audit, and shared domain effects. - Implement the route adapter in
apps/sim/app/api/<path>/route.tswith the appropriate shared builder. Declare auth, operation, rate policy, error policy, input mapping, use case, and presenter. Auth always runs before parsing. Use rawwithRouteHandleronly for an explicit special route, and keep protected work in application use cases. - Add the React Query hook in
apps/sim/hooks/queries/<domain>.ts. UserequestJson(contract, input)for the call. Build a hierarchical query-key factory (all→lists()→list(workspaceId)→details()→detail(id)) so invalidations can target prefixes. - Use the hook in the component. The mutation's
dataanderrorare fully typed from the contract; surfaceerror.message(already extracted from the response body'serrorormessagefield byrequestJson).
LLMs will write contracts that compile but are sloppy. The human reviewer should optimize attention on:
requiredvsoptionalvsnullableis correct.optional()allows omission;nullable()allowsnull; chaining both creates a tri-state that's almost never what you want.- Response schema matches the route's actual JSON output. The most common drift bug — route emits a field the schema doesn't declare, or omits a required field. Walk every
NextResponse.json(...)callsite against the schema. - Error messages are descriptive.
'fileName cannot be empty'beats'Required'. Use the second arg ofmin(1, '...'),nonempty('...'), etc. For cross-field refines, usesuperRefinewith apathand a message that names the failing field. - Bounds are set on arrays (
.min(1),.max(N)), strings (.min(1).max(N)for IDs/names), and numbers (.min().max()for limits/sizes). z.unknown()is a smell unless the data is genuinely arbitrary (provider passthrough, user-defined tool result, JSON-RPC envelope). When kept, must be annotated// untyped-response: <specific reason>in aschema:slot.- Discriminated unions over plain unions when the wire has a discriminant field — gives clients exhaustive narrowing.
CI (bun run check:api-validation:strict) catches structural violations (Zod imports in routes, raw request.json(), double casts, missing annotations). It does not catch these schema-quality judgments — that's the human's job in PR review.
Hooks in apps/sim/hooks/queries/** consume contracts the same way routes do. Every same-origin JSON call must go through requestJson(contract, ...) from @/lib/api/client/request instead of raw fetch:
- Hooks import named type aliases from
@/lib/api/contracts/**. Never writez.input<...>/z.output<...>in hooks, and neverimport { z } from 'zod'in client code. requestJsonparses params, query, body, and headers against the contract on the way out and validates the JSON response on the way back. Hooks always forwardsignalfor cancellation.- Documented exceptions for raw
fetch: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests. Mark each rawfetchwith a TSDoc comment explaining which exception applies.
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'
async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise<EntityList> {
const data = await requestJson(listEntitiesContract, {
query: { workspaceId },
signal,
})
return data.entities
}
export function useEntityList(workspaceId?: string) {
return useQuery({
queryKey: entityKeys.list(workspaceId),
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
enabled: Boolean(workspaceId),
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}- Use Vitest.
- Prefer
@vitest-environment nodeunless DOM APIs are required. - Use
vi.hoisted()+vi.mock()+ static imports; do not usevi.resetModules()+vi.doMock()+ dynamic imports except for true module-scope singletons. - Do not use
vi.importActual(). - Prefer mocks and factories from
@sim/testing.
- Never create
utils.tsfor single consumer - inline it - Create
utils.tswhen 2+ files need the same helper - Check existing sources before duplicating (
lib/has many utilities) - Location:
lib/(app-wide) →feature/utils/(feature-scoped) → inline (single-use)