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 run insidewithRouteHandler. Ordinary internal and v2 handlers use the shared JSON/binary route builders, which already apply it; never double-wrap a builder. Use rawwithRouteHandleronly for documented protocol or lifecycle exceptions. See "API Route Pattern" 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 - Type-checking: Run
bun run type-check(per workspace) orbunx turbo run type-check(all of them). Do not remove the@typescript/nativealias from the rootdevDependencies— nothing imports it, but it is what makes a baretscresolve to the native TypeScript 7 compiler instead of the ~10x slower JavaScript TypeScript 6 one that@typescript/typescript6pulls in transitively.bun run check:native-typecheckenforces this
- 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 minimum role, workspace-key policy, allowed principal kinds, and delegated services. Internal APIs, v2 APIs, Copilot, and trusted tools call the same use case when the domain behavior is the same.
- Surface adapters authenticate and construct a
Principal, apply request-rate policy, parse contracts, map input, and present results. They never query protected data, decide resource authorization, implement business transactions, or record semantic audit. - Application use cases load canonical context, compare asserted scope, authorize current access, execute managers/repositories, project semantic audit, and trigger shared domain effects. Managers accept canonical IDs and scope, never credentials or principals.
- Copilot is a surface adapter. Use
createCopilotApplicationAdapterand the domain's registered operation object; do not create Copilot-only authorization or business implementations. - Protected compound mutations belong in one top-level semantic application operation. Do not sequence independently committing mutations in a route or tool adapter.
- Never substitute a billing owner, uploader, creator, or API-key owner for the acting principal. Fail fast when the identity model or operation policy cannot express the caller.
- Use the
migrate-application-operationskill whenever creating or migrating a protected endpoint, tool command, or resource method.
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
├── platform-authz/ # @sim/platform-authz — workspace + workflow authz (subpath exports)
├── realtime-protocol/ # @sim/realtime-protocol — socket op constants + zod schemas
├── security/ # @sim/security — safeCompare
├── tsconfig/ # shared tsconfig presets
├── utils/ # @sim/utils
├── 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 (
@sim/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.
Behavior-preserving render-performance idioms — lazy-init object refs, hoist closure-free values/functions to module scope, pre-index repeated lookups with Map/Set, and never mutating a shared array in place — are in .claude/rules/sim-react-performance.md (which also explains why toSorted/toReversed are unsafe on client render paths despite the ES2023 tsconfig lib — SWC does not polyfill prototype methods, so use [...arr].sort()). For the render-timing effect/state anti-patterns use the /you-might-not-need-* skills and verify against the running UI.
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 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 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
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.
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'
export const ENTITY_LIST_STALE_TIME = 60 * 1000
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: ENTITY_LIST_STALE_TIME,
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, assigned from a named exported constant, never an inline numeric literal — a server-side prefetch hydrating the same query key must import and reuse that constant so the two never drift out of sync - 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: ENTITY_LIST_STALE_TIME,
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) })
},
})
}Shareable client view-state (active tab/panel, filters, search query, pagination, selected entity id, view mode, a deep-linked drawer/modal) lives in the URL via nuqs — not in a store synced with effects, and never read via useSearchParams().get(...) / new URLSearchParams(window.location.search). Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand (canvas pan/zoom, cursor, drag, resize widths, live collaborative selection).
Co-locate a search-params.ts per feature exporting the parser map (single source of truth, shared by client useQueryStates/useQueryState and server createSearchParamsCache). Never import { z } in client code for params — use nuqs parsers. Full decision framework, conventions, the debounced-input pattern, and the workflow-editor carve-out are in .claude/rules/sim-url-state.md.
A list orders itself the way the user already reads the same things somewhere else. Resource menus (+ attach, @ mention, resource-tab +) mirror the sidebar top-down; a row or root context menu mirrors that surface's toolbar, left-to-right becoming top-to-bottom; tab strips mirror their nav. Platform-only entries (desktop Browser, Terminal) trail the shared set.
Encode the order in ONE exported constant and sort by it — never a hand-maintained literal per menu (RESOURCE_MENU_ORDER / byResourceMenuOrder in home/components/mothership-view/components/resource-registry). Render mixed item kinds in a single ordered pass; emitting all submenu-backed families and then all flat ones silently pins every submenu to the top no matter what the constant says. Divergence is allowed only for search ranking, user-controlled ordering, and recency. Full rule in .claude/rules/sim-list-ordering.md.
Use Tailwind only, no inline styles. Use cn() from @sim/emcn 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 components, cn, and tokens from the @sim/emcn barrel; icons come from the @sim/emcn/icons subpath, and CSS modules from their file path. Never deep-import other component subpaths. 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/platform-authz/workflow, @/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-maps.ts— theBLOCK_REGISTRYconfig map andBLOCK_META_REGISTRYcatalog-meta map (alphabetically).blocks/registry.tsholds only the accessor functions (getBlock,getAllBlocks, …). 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.
Table column types are registry entries in apps/sim/lib/table/column-types/ — one file per type owning its label, icon, storage cast, coercion, validation, conversion compatibility, formatting, and editor. Record<ColumnType, …> on registry.ts and registry.server.ts is a compile-time completeness gate: adding a type to the union errors until both entries exist.
Never add a case 'sometype': outside column-types/ — a missing arm fails silently (a wrong jsonbCast breaks every filter on the column). If a consumer needs per-type knowledge, add a registry field. Use /add-column-type for the full procedure.