You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
- Linting / Audit:
bun run check:api-validationmust pass on PRs. Do not introduce route-local boundary Zod schemas, direct route Zod imports, or ad-hoc client wire types — see "API Contracts" and "API Route Pattern" below - Logging: Import
createLoggerfrom@sim/logger. Uselogger.info,logger.warn,logger.errorinstead ofconsole.log. Inside API routes wrapped withwithRouteHandler, loggers automatically include the request ID — no manualwithMetadata({ requestId })needed - API Route Handlers: All API route handlers (
GET,POST,PUT,DELETE,PATCH) must be wrapped withwithRouteHandlerfrom@/lib/core/utils/with-route-handler. This provides request ID tracking, automatic error logging for 4xx/5xx responses, and unhandled error catching. See "API Route Pattern" section below - Comments: Use TSDoc for documentation. No
====separators. No non-TSDoc comments - Styling: Never update global styles. Keep all styling local to components
- ID Generation: Never use
crypto.randomUUID(),nanoid, oruuidpackage. UsegenerateId()(UUID v4) orgenerateShortId()(compact) from@sim/utils/id - Common Utilities: Use shared helpers from
@sim/utilsinstead of inline implementations:sleep(ms)from@sim/utils/helpers— nevernew Promise(resolve => setTimeout(resolve, ms))toError(e)from@sim/utils/errors— normalize caught values toErrorgetErrorMessage(e, fallback?)from@sim/utils/errors— extract message string from unknown caught value; never writee instanceof Error ? e.message : 'fallback'structuredClone(value)— built-in deep clone; neverJSON.parse(JSON.stringify(...))omit(obj, keys)/filterUndefined(obj)from@sim/utils/object— object trimming; neverObject.fromEntries(Object.entries(...).filter(...))truncate(str, maxLength, suffix?)from@sim/utils/string— never inline slice + ellipsisbackoffWithJitter(attempt, retryAfterMs, options?)/parseRetryAfter(header)from@sim/utils/retry— shared retry pacing; never reimplement exponential backoff inline
- Package Manager: Use
bunandbunx, notnpmandnpx
- 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
apps/
├── sim/ # Next.js app (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/
├── audit/ # @sim/audit
├── auth/ # @sim/auth — shared Better Auth verifier
├── db/ # @sim/db — drizzle schema + client
├── logger/ # @sim/logger
├── realtime-protocol/ # @sim/realtime-protocol — socket op constants + zod schemas
├── security/ # @sim/security — safeCompare
├── tsconfig/ # shared tsconfig presets
├── utils/ # @sim/utils
├── workflow-authz/ # @sim/workflow-authz
├── workflow-persistence/ # @sim/workflow-persistence
└── workflow-types/ # @sim/workflow-types — pure BlockState/Loop/Parallel types
apps/* → packages/*only. Packages never import fromapps/*.apps/realtimeintentionally avoids Next.js, React, the block/tool registry, provider SDKs, and the executor. Do not add imports from@/lib/webhooks/providers/*,@/executor/*,@/blocks/*, or@/tools/*to any package consumed byapps/realtime. CI enforces this viascripts/check-monorepo-boundaries.tsandscripts/check-realtime-prune-graph.ts.- Auth is shared across both apps via the Better Auth "Shared Database Session" pattern (same
BETTER_AUTH_SECRET, same DB via@sim/db).
- 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. Never use relative imports.
// ✓ Good
import { useWorkflowStore } from '@/stores/workflows/store'
// ✗ Bad
import { useWorkflowStore } from '../../../stores/workflows/store'Use barrel exports (index.ts) when a folder has 3+ exports. Do not re-export from non-barrel files; import directly from the source.
- React/core libraries
- External libraries
- UI components (
@/components/emcn,@/components/ui) - Utilities (
@/lib/...) - Stores (
@/stores/...) - Feature imports
- CSS imports
Use import type { X } for type-only imports.
- No
any- Use proper types orunknownwith type guards - Always define props interface for components
as constfor constant objects/arrays- Explicit ref types:
useRef<HTMLDivElement>(null)
'use client' // Only if using hooks
const CONFIG = { SPACING: 8 } as const
interface ComponentProps {
requiredProp: string
optionalProp?: boolean
}
export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
// Order: refs → external hooks → store hooks → custom hooks → state → useMemo → useCallback → useEffect → return
}Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational.
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 — folders.ts, chats.ts, knowledge.ts, etc.). 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 (e.g.,
createFolderBodySchema) AND named TypeScript type aliases (e.g.,export type CreateFolderBody = z.input<typeof createFolderBodySchema>) - Clients (hooks, utilities, components) import the named type aliases from the contract file. They must never write
z.input<...>/z.output<...>themselves - Shared identifier schemas live in
apps/sim/lib/api/contracts/primitives.ts(e.g.,workspaceIdSchema,workflowIdSchema). Reuse these instead of redefining string-based ID schemas - 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 in client hooks (apps/sim/hooks/queries/**,apps/sim/hooks/selectors/**) AND any same-origin/api/...fetch elsewhere underapps/sim/**outside an API route handler. 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 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()response declaration 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 LegacyProviderEvery API route handler must be wrapped with withRouteHandler. This sets up AsyncLocalStorage-based request context so all loggers in the request lifecycle automatically include the request ID.
Routes never import { z } from 'zod' and never define route-local boundary schemas. They consume the contract from @/lib/api/contracts/** and validate with canonical helpers from @/lib/api/server:
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 failurevalidationErrorResponse(error)andgetValidationErrorMessage(error, fallback)— produce 400 responses from aZodErrorvalidationErrorResponseFromError(error)— when handling unknown caught errors that may or may not be aZodErrorisZodError(error)— type guard. Routes never useinstanceof z.ZodError
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { createFolderContract } from '@/lib/api/contracts/folders'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('FoldersAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(createFolderContract, request, {})
if (!parsed.success) return parsed.response
const { body } = parsed.data
logger.info('Creating folder', { workspaceId: body.workspaceId })
return NextResponse.json({ ok: true })
})export const POST = withRouteHandler(withAdminAuth(async (request) => {
return NextResponse.json({ ok: true })
}))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/... — always use export const METHOD = 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). - Implement the route in
apps/sim/app/api/<path>/route.ts. Auth always runs beforeparseRequest— never validate untrusted input before authenticating the caller. The route returns exactly the shape declared incontract.response.schema. - 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.
interface UseFeatureProps { id: string }
export function useFeature({ id }: UseFeatureProps) {
const idRef = useRef(id)
const [data, setData] = useState<Data | null>(null)
useEffect(() => { idRef.current = id }, [id])
const fetchData = useCallback(async () => { ... }, []) // Empty deps when using refs
return { data, fetchData }
}Stores live in stores/. Complex stores split into store.ts + types.ts.
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const initialState = { items: [] as Item[] }
export const useFeatureStore = create<FeatureState>()(
devtools(
(set, get) => ({
...initialState,
setItems: (items) => set({ items }),
reset: () => set(initialState),
}),
{ name: 'feature-store' }
)
)Use devtools middleware. Use persist only when data should survive reload with partialize to persist only necessary state.
All React Query hooks live in hooks/queries/. All server state must go through React Query — never use useState + fetch in components for data fetching or mutations.
Hooks 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. The// boundary-raw-fetchannotation is required not only in client hooks but for any same-origin/api/...fetch anywhere underapps/sim/**outside an API route handler — strict CI flags these regardless of location
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,
})
}Every file must have a hierarchical key factory with an all root key and intermediate plural keys for prefix invalidation:
export const entityKeys = {
all: ['entity'] as const,
lists: () => [...entityKeys.all, 'list'] as const,
list: (workspaceId?: string) => [...entityKeys.lists(), workspaceId ?? ''] as const,
details: () => [...entityKeys.all, 'detail'] as const,
detail: (id?: string) => [...entityKeys.details(), id ?? ''] as const,
}- Every
queryFnmust forwardsignalfor request cancellation - Every query must have an explicit
staleTime - Use
keepPreviousDataonly on variable-key queries (where params change), never on static keys
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, // OK: workspaceId varies
})
}- Use targeted invalidation (
entityKeys.lists()) not broad (entityKeys.all) when possible - For optimistic updates: use
onSettled(notonSuccess) for cache reconciliation —onSettledfires on both success and error - Don't include mutation objects in
useCallbackdeps —.mutate()is stable in TanStack Query v5
export function useUpdateEntity() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (variables) => { /* ... */ },
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) })
const previous = queryClient.getQueryData(entityKeys.detail(variables.id))
queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic */)
return { previous }
},
onError: (_err, variables, context) => {
queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous)
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: entityKeys.lists() })
queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) })
},
})
}Use Tailwind only, no inline styles. Use cn() from @/lib/core/utils/cn for conditional classes.
<div className={cn('base-classes', isActive && 'active-classes')} />For equal height and width, use the size-* shorthand — never h-[Npx] w-[Npx] or h-N w-N. Default icon size is size-[14px].
<Icon className='size-[14px] text-[var(--text-icon)]' />On chip components (see "EMCN Components"), drive chrome through PROPS, not className: error for the error state, icon/endAdornment for adornments, inputClassName for the inner field. className carries ONLY layout/sizing — never re-specify canonical chrome (border, fill, radius, height, text/icon color) or add focus rings. Full consumer rules in .claude/rules/sim-styling.md.
Import from @/components/emcn, never from subpaths (except CSS files). Use CVA only when 2+ genuine variants exist; otherwise plain cn().
The chip family is the canonical UI chrome and is progressively replacing the legacy EMCN primitives — always reach for the chip equivalent: ChipInput over Input, ChipTextarea over Textarea, ChipModal/ChipModalField over Modal, ChipSelect/ChipCombobox (searchable) or ChipDropdown (simple menu-select) over Select/Combobox, ChipSwitch over Switch, ChipDatePicker over a raw date field, Chip/ChipLink for pill buttons/links, ChipTag for inline tags/badges. For context/action menus the canonical control is DropdownMenu (not a chip, but the standard menu — not a hand-rolled popover). Components OWN their chrome (single source of truth) — consumers pass props, not class overrides. Authoring rules in .claude/rules/emcn-components.md; consumer rules in .claude/rules/sim-styling.md.
Inside a ChipModalBody, EVERY labeled field MUST be a ChipModalField — never hand-roll a field row (a raw <div> + a hand-rolled <p>/<label> title + a bare ChipInput/ChipTextarea). ChipModalBody applies px-2 + gap-4; ChipModalField adds ANOTHER px-2, so each field lands at effective px-4, exactly matching ChipModalHeader/ChipModalFooter (px-4). Hand-rolled rows skip the field's gutter and sit at px-2, visibly misaligned with the header/footer. For controls ChipModalField does not cover (ChipCombobox, ChipSelect, DatePicker, TimePicker, ButtonGroup, arbitrary JSX), use ChipModalField type='custom' with a title — it still applies the px-2 gutter and renders the canonical Label. Drive intent via props (title/value/onChange/error/hint/required/flush); never pass variant/className/id to the inner control, and never add a body-level wrapper <div> with a custom gap-* that fights ChipModalBody's gap-4.
Principles when building or migrating shared UI:
- One canonical source of truth for shared chrome — compose it, never re-derive it per consumer.
- Props-driven API over
classNameoverrides — reaching forclassNameto change chrome is a smell; expose a prop instead. - Discriminated-union props for modes (e.g.
ChipDropdown multiple) over near-duplicate components. - Delete legacy variants/components after migration — no parallel paths left behind.
- Plain
cn()for a single error/state toggle; CVA only for genuinely multiple variants. - Align consumers to the canonical defaults — normal weight,
--text-bodytext,--text-iconicons. - Verify referenced CSS vars exist — an undefined var silently falls back to
currentColor(black-bug).
Use Vitest. Test files: feature.ts → feature.test.ts. See .cursor/rules/sim-testing.mdc for full details.
@sim/db, @sim/db/schema, drizzle-orm, @sim/logger, @sim/workflow-authz, @/blocks/registry, @/lib/auth, @/lib/auth/hybrid, @/lib/core/utils/request, @trigger.dev/sdk, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. (The vi.mock('@/lib/auth', ...) in the example below is an override of the global mock so getSession can be controlled per-test.)
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
import { GET } from '@/app/api/my-route/route'
describe('my route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
})
it('returns data', async () => { ... })
})- NEVER use
vi.resetModules()+vi.doMock()+await import()— usevi.hoisted()+vi.mock()+ static imports - NEVER use
vi.importActual()— mock everything explicitly - NEVER use
mockAuth(),mockConsoleLogger(),setupCommonApiMocks()from@sim/testing— they usevi.doMock()internally - Mock heavy deps (
@/blocks,@/tools/registry,@/triggers) in tests that don't need them - Use
@vitest-environment nodeunless DOM APIs are needed (window,document,FormData) - Avoid real timers — use 1ms delays or
vi.useFakeTimers()
Use @sim/testing mocks/factories over local test data.
- Never create
utils.tsfor single consumer - inline it - Create
utils.tswhen 2+ files need the same helper - Check existing sources in
lib/before duplicating
New integrations are built in order: Tools → Block → Icon → (optional) Trigger. Always look up the service's API docs first.
Two hard rules that the skills assume:
- Tool IDs are
snake_case(service_action) and must be registered intools/registry.ts; blocks register inblocks/registry.ts(alphabetically). tools.config.toolruns during serialization (before variable resolution) — never doNumber()or other type coercions there, or dynamic references like<Block.output>are destroyed. Put all type coercions intools.config.params, which runs during execution after variables resolve.
For the full authoring instructions — SubBlock property tables, condition/dependsOn/required/mode/canonicalParamId syntax, required block metadata (integrationType, tags, authMode, docsLink, {Service}BlockMeta), file-input/normalizeFileInput patterns, and checklists — use the skills: /add-integration (end-to-end), /add-tools, /add-block, /add-trigger.