diff --git a/.agents/skills/add-block-preview/SKILL.md b/.agents/skills/add-block-preview/SKILL.md new file mode 100644 index 00000000000..158922058e5 --- /dev/null +++ b/.agents/skills/add-block-preview/SKILL.md @@ -0,0 +1,61 @@ +--- +name: add-block-preview +description: Gate a block's visibility — ship an unreleased block as a preview (hidden until revealed via AppConfig/env), reveal it to admins/orgs, GA it, or kill-switch a shipped block +argument-hint: +--- + +# Add Block Preview Skill + +You manage **block visibility gating** in Sim — hiding blocks from every discovery surface (toolbar, cmd+K search, copilot @-mentions, agent tool picker, mothership VFS/metadata/tools, Access Control list, public docs/catalog) while **never** gating execution of already-placed instances. + +## The model + +Three levers, evaluated in `apps/sim/lib/core/config/block-visibility.ts` and folded into the registry accessors (`apps/sim/blocks/registry.ts`): + +1. **`preview: true`** on the `BlockConfig` (static, in code) — the block is default-hidden EVERYWHERE (hosted, self-hosted, dev, SSR) until revealed. Fail-closed. +2. **The hosted `block-visibility` AppConfig document** — per-block rule keyed by the existing block type: + + ```jsonc + { + "": { + "enabled": false, // required. true = GA (visible to everyone) + "orgIds": ["org_..."], // optional allowlist clauses (any match reveals) + "userIds": ["user_..."], + "adminEnabled": true // platform admins (user.role === 'admin') + } + } + ``` + +3. **`PREVIEW_BLOCKS` env** (comma-separated block types) — the off-AppConfig reveal path for self-hosters and local dev. + +A revealed block that is not globally GA (`enabled !== true`, or env-revealed) renders with a **" (Preview)"** name suffix on discovery surfaces. `getBlock()` stays pure, so placed instances keep their canonical name and always execute. + +## Lifecycle of a preview block + +1. **Author** the block normally (`/add-block` etc.) and set `preview: true` on its `BlockConfig`. **Ship no `BlockMeta` and no docs until GA** — `check-block-registry` deliberately skips preview blocks in meta coverage, and `generate-docs` skips them at every gate. +2. **Local dev:** set `PREVIEW_BLOCKS=` in your env to see it (with the suffix). +3. **Merge/deploy.** The block's code is live everywhere but visible nowhere — no AppConfig rule exists and self-hosters have no env entry. +4. **Hosted preview:** add a rule to the `block-visibility` AppConfig document and start a deployment (no code deploy): + - Admins only: `{ "enabled": false, "adminEnabled": true }` + - Design-partner org: `{ "enabled": false, "orgIds": ["org_123"] }` + - GA via config (code cleanup pending): `{ "enabled": true }` — suffix disappears everywhere within ~30s (AppConfig TTL) + client refetch. + + Same runbook as `feature-flags`: edit the hosted document, `aws appconfig start-deployment` with the `sim--fast` strategy (see the infra README). +5. **GA cleanup:** delete `preview: true` from the block (now visible to self-hosters on their next upgrade), add its `BlockMeta` + regen docs, and drop the AppConfig entry. For a v2 upgrade, this is also when v1 gets `hideFromToolbar: true` (the superseded-version paradigm). + +## Kill switch (shipped blocks) + +To pull an already-GA block from discovery surfaces on hosted (incident, deprecation): add `{ "": { "enabled": false } }` to the document. Allowlist clauses can carve out exceptions. **Execution is NOT stopped** — workflows already using the block keep running; the kill switch only prevents new placement/discovery. + +## Invariants (do not violate) + +- **Execution is never gated.** The executor, serializer, drop-naming, and `isBlockTypeAccessControlExempt` resolve via pure `getBlock`. Do not add visibility checks to execution paths. +- **Clone-not-remove:** gated blocks stay in `getAllBlocks()` output as clones with `hideFromToolbar: true` — `.find`-by-type consumers rely on this. Never filter them out. +- **Keys are registry block types.** Never `custom_block_*` (parse drops them — custom blocks have their own enabled/disabled lifecycle). +- **The shared hidden-predicate is `isHiddenUnder`** (`apps/sim/blocks/visibility/context.ts`). Never restate the preview/disabled rule inline at a new consumer. +- **Process-global caches stay ungated.** `getStaticComponentFiles` (VFS) and `getExposedIntegrationTools` build the ungated universe; per-viewer filtering happens at stamp/consumer time. Never move gating into a shared builder. +- Gating is **surface hiding, not secrecy** — the full config ships in the client JS bundle. Anything truly secret cannot be a registered block. + +## Tests + +Evaluation semantics: `apps/sim/lib/core/config/block-visibility.test.ts`. Registry projection: `apps/sim/blocks/visibility/visibility.test.ts`. When gating behavior changes, extend those — mock `isPlatformAdmin` for the admin clause; use the local `withAppConfig` harness. diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index 479a7bacc51..45780ca4a55 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -1,6 +1,7 @@ --- name: add-block description: Create or update a Sim integration block with correct subBlocks, conditions, dependsOn, modes, canonicalParamId usage, outputs, and tool wiring. Use when working on `apps/sim/blocks/blocks/{service}.ts` or aligning a block with its tools. +argument-hint: --- # Add Block Skill @@ -443,7 +444,7 @@ Maps multiple UI fields to a single serialized parameter: **Critical constraints:** - `canonicalParamId` must NOT match any other subblock's `id` in the same block (causes conflicts) -- `canonicalParamId` must be unique per block (only one basic/advanced pair per canonicalParamId) +- A `canonicalParamId` links exactly one basic/advanced pair for a single logical parameter. Do NOT reuse the same `canonicalParamId` for different parameters, even under mutually-exclusive conditions/operations - ONLY use `canonicalParamId` to link basic/advanced mode alternatives for the same logical parameter - Do NOT use it for any other purpose @@ -533,7 +534,10 @@ tools: { Block outputs only support: - `type` - The data type ('string', 'number', 'boolean', 'json', 'array') - `description` - Human readable description -- Nested object structure (for complex types) +- `condition` - Optional visibility condition +- `hiddenFromDisplay` - Optional flag to hide from the output display + +**Nested object/`properties` outputs are tool-output-only and will fail TypeScript at build time on block outputs.** For complex shapes use `type: 'json'` and describe the inner fields in the `description` string. ```typescript outputs: { @@ -556,7 +560,7 @@ outputs: { ### Typed JSON Outputs -When using `type: 'json'` and you know the object shape in advance, **describe the inner fields in the description** so downstream blocks know what properties are available. For well-known, stable objects, use nested output definitions instead: +When using `type: 'json'` and you know the object shape in advance, **describe the inner fields in the description** so downstream blocks know what properties are available. Block outputs have no nested `properties` form — always keep the output flat and put the shape in the `description`: ```typescript outputs: { @@ -568,26 +572,10 @@ outputs: { type: 'json', description: 'Zone plan information (id, name, price, currency, frequency, is_subscribed)', }, - - // BEST: Use nested output definition when the shape is stable and well-known - plan: { - id: { type: 'string', description: 'Plan identifier' }, - name: { type: 'string', description: 'Plan name' }, - price: { type: 'number', description: 'Plan price' }, - currency: { type: 'string', description: 'Price currency' }, - }, } ``` -Use the nested pattern when: -- The object has a small, stable set of fields (< 10) -- Downstream blocks will commonly access specific properties -- The API response shape is well-documented and unlikely to change - -Use `type: 'json'` with a descriptive string when: -- The object has many fields or a dynamic shape -- It represents a list/array of items -- The shape varies by operation +Nested object outputs (`plan: { id: { type: 'string' }, ... }`) are a **tool-output** feature only — `OutputFieldDefinition` for blocks does not allow them and they fail TypeScript at build time. If the output shape is unknown because the underlying tool response is undocumented, you MUST tell the user and stop. Unknown is not the same as variable. Never guess block outputs. @@ -627,96 +615,20 @@ export const ServiceV2Block: BlockConfig = { } ``` -## Block Metadata (BlockMeta) - -Every integration block **must** export a `{Service}BlockMeta` object at the bottom of the block file. This metadata drives the integration catalog, tag filters, and workflow template suggestions shown to users. - -### Structure - -```typescript -import type { BlockConfig, BlockMeta } from '@/blocks/types' - -// ... block definition above ... - -export const {Service}BlockMeta = { - tags: ['messaging', 'automation'], // Same tags as the block's tags field - url: 'https://{service}.com', // Canonical homepage of the external service - templates: [ // Optional but strongly encouraged - { - icon: {Service}Icon, - title: '{Service} use-case title', - prompt: 'Build a workflow that ...', - modules: ['agent', 'workflows'], // Modules the template uses - category: 'productivity', // Template category - tags: ['automation'], // Template-level tags - alsoIntegrations: ['slack'], // Other blocks referenced in the prompt (optional) - }, - ], - skills: [ // Optional but strongly encouraged - { - name: 'summarize-thread', // kebab-case, becomes the created skill's name - description: 'One line: what it does and when to use it.', - content: - '# Summarize Thread\n\n...\n\n## Steps\n1. ...\n\n## Output\n...', // markdown - }, - ], -} as const satisfies BlockMeta -``` - -### Rules - -- **Import `BlockMeta`** from `@/blocks/types` alongside `BlockConfig` -- **`tags`** must match the `tags` array on the block config exactly -- **`url`** is the canonical homepage of the external service the block integrates with (e.g. `'https://exa.ai'`, `'https://salesforce.com'`) — the catalog "link back to the tool". It is distinct from `BlockConfig.docsLink`, which points at Sim's own integration docs on `docs.sim.ai`. Use the service's real root domain over `https` (verify it actually resolves), no tracking params, no trailing slash. Omit `url` only for first-party/built-in blocks that have no external service (e.g. `agent`, `function`, `condition`, `api`, `response`) -- **Templates are optional** but should be added for any integration that has a recognizable use case — aim for 2–4 templates per block -- **Template `prompt`** should start with "Build a workflow that..." or "Create a workflow that..." and be concrete enough to generate a real workflow in Mothership -- **Template `modules`** lists the Sim modules the template relies on: `'knowledge-base' | 'tables' | 'files' | 'workflows' | 'scheduled' | 'agent'` -- **Template `category`** is one of: `'popular' | 'sales' | 'support' | 'engineering' | 'marketing' | 'productivity' | 'operations'` -- **`alsoIntegrations`** names other block types (e.g. `'slack'`, `'linear'`) referenced in the template prompt — helps the catalog surface this template when those blocks are selected -- Place the export **after** the main `{Service}Block` export, at the very bottom of the file - -#### `skills` — curated, ready-to-add agent skills - -`skills` is an optional array of `SuggestedSkill` (`{ name, description, content }`) shown on the integration's detail page; users click **Add** to create the skill in their workspace. Aim for 3–5 skills for mainstream services, 2–3 for niche/low-level ones. - -- **`name`** — kebab-case, lowercase letters/numbers/hyphens, ≤ 64 chars, unique within the integration, verb-led (e.g. `summarize-thread`). -- **`description`** — one line, ≤ 1024 chars: what it does and when to use it. -- **`content`** — markdown instructions for the agent (literal `\n` for newlines): a `# Title`, then `## Steps` and an output/guidance section. Keep ~600–2000 chars. -- **Ground every skill in operations the block actually exposes.** Cross-check each skill's steps against the block's `tools.access` list — never describe an action the integration cannot perform (e.g. "receive messages" when the block only sends). -- **Skills MUST be derived from real, popular use cases found online — never invented.** Before adding a skill, web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles). If you cannot source a use case as something people genuinely do with the service, do not add it. Do not hallucinate skills. - -### Register in the blocksMeta object - -After adding `{Service}BlockMeta` to the block file, register it in `apps/sim/blocks/registry.ts`: - -```typescript -// Add import (alongside the block import, alphabetically) -import { ServiceBlock, ServiceBlockMeta } from '@/blocks/blocks/service' - -// Add to blocksMeta object (alphabetically) -export const blocksMeta = { - // ... existing entries ... - service: ServiceBlockMeta, -} -``` - ## Registering Blocks -After creating the block, remind the user to: -1. Import `{Service}Block` and `{Service}BlockMeta` in `apps/sim/blocks/registry.ts` -2. Add to the `registry` object (alphabetically): -3. Add to the `blocksMeta` object (alphabetically): +After creating the block, remind the user to register it in `apps/sim/blocks/registry-maps.ts` (the data maps live here; `registry.ts` holds only the accessor functions). Add the import and an entry to each map alphabetically: ```typescript import { ServiceBlock, ServiceBlockMeta } from '@/blocks/blocks/service' -export const registry: Record = { +export const BLOCK_REGISTRY: Record = { // ... existing blocks ... service: ServiceBlock, } -export const blocksMeta = { - // ... existing entries ... +export const BLOCK_META_REGISTRY: Record = { + // ... existing metas ... service: ServiceBlockMeta, } ``` @@ -837,6 +749,13 @@ Please provide the SVG and I'll convert it to a React component. You can usually find this in the service's brand/press kit page, or copy it from their website. ``` +When converting the SVG: a **monochrome** logo (single white or black mark) must +use `fill='currentColor'`, never a hardcoded `#fff`/`#000000`. Block icons render +both inside their `bgColor` tile and "bare" on a neutral page (the home Suggested +actions list) in light and dark mode; a hardcoded white/black mark goes invisible +bare on the matching background. Multi-color brand logos keep their own fills. +Verify with `bun run check:bare-icons`. + ## Advanced Mode for Optional Fields Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes: @@ -893,6 +812,48 @@ Use `wandConfig` for fields that are hard to fill out manually, such as timestam All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MUST use `snake_case` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase. +## BlockMeta (Required) + +Every block file must export a `{Service}BlockMeta` alongside the block — **minimum 7 templates**. Look at existing examples in `apps/sim/blocks/blocks/` (e.g. `browser_use.ts`, `google_sheets.ts`) for the pattern. + +```typescript +import type { BlockMeta } from '@/blocks/types' + +export const {Service}BlockMeta = { + tags: ['tag1', 'tag2'], // IntegrationTag[] + url: 'https://{service}.com', // external service homepage (verify it resolves) — NOT docs.sim.ai + templates: [ + { + icon: {Service}Icon, + title: '{Service} ', // 2–5 words + prompt: 'Build a workflow that...', // specific use case, 1–3 sentences + modules: ['agent', 'workflows'], // 'agent' | 'workflows' | 'tables' | 'files' | 'scheduled' | 'knowledge-base' + category: 'operations', // 'operations' | 'marketing' | 'sales' | 'engineering' | 'productivity' | 'support' | 'popular' + tags: ['automation'], + alsoIntegrations: ['slack'], // optional — other block IDs referenced in the prompt + featured: true, // optional + }, + // ... at least 6 more + ], + skills: [ // SuggestedSkill[] — 3–5 mainstream, 2–3 niche + { + name: 'summarize-thread', // kebab-case, ≤64 chars, unique, verb-led + description: 'One line: what it does and when to use it.', // ≤1024 chars + content: + '# Summarize Thread\n\n...\n\n## Steps\n1. ...\n\n## Output\n...', // markdown + }, + // ... more + ], +} as const satisfies BlockMeta +``` + +Derive templates from the service's real use cases. Each prompt should name a concrete trigger, transformation, and output — not a generic description of what the service does. + +`skills` are curated, ready-to-add agent skills shown on the integration's detail page (users click **Add** to create them in their workspace). Two hard rules: + +- **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform. +- **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills. + ## Checklist Before Finishing - [ ] `integrationType` is set to the correct `IntegrationType` enum value @@ -906,16 +867,14 @@ All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MU - [ ] Tools.access lists all tool IDs (snake_case) - [ ] Tools.config.tool returns correct tool ID (snake_case) - [ ] Outputs match tool outputs -- [ ] Block registered in `registry.ts` blocks object (alphabetically) -- [ ] `{Service}BlockMeta` exported at bottom of block file with `tags` and `templates` -- [ ] `url` set on `{Service}BlockMeta` to the external service's verified homepage (omit only for first-party blocks with no external service) -- [ ] `skills` added to `{Service}BlockMeta`, each grounded in the block's `tools.access` and derived from a real online-sourced use case (not invented) -- [ ] `BlockMeta` imported from `@/blocks/types` alongside `BlockConfig` -- [ ] Block meta registered in `registry.ts` blocksMeta object (alphabetically) +- [ ] Block + meta registered in registry-maps.ts (`BLOCK_REGISTRY` / `BLOCK_META_REGISTRY`) - [ ] If icon missing: asked user to provide SVG - [ ] If triggers exist: `triggers` config set, trigger subBlocks spread - [ ] Optional/rarely-used fields set to `mode: 'advanced'` - [ ] Timestamps and complex inputs have `wandConfig` enabled +- [ ] Exported `{Service}BlockMeta` with at least 7 templates +- [ ] `url` set on `{Service}BlockMeta` to the external service's verified homepage (omit only for first-party blocks with no external service) +- [ ] `skills` added to `{Service}BlockMeta`, each grounded in `tools.access` and sourced from a real online use case (not invented) ## Final Validation (Required) @@ -929,4 +888,5 @@ After creating the block, you MUST validate it against every tool it references: - Type coercions in `tools.config.params` for any params that need conversion (Number(), Boolean(), JSON.parse()) 3. **Verify block outputs** cover the key fields returned by all tools 4. **Verify conditions** — each subBlock should only show for the operations that actually use it -5. **If any tool outputs are still unknown**, explicitly tell the user instead of guessing block outputs +5. **Verify `{Service}BlockMeta` is exported** with at least 7 templates, each having `icon`, `title`, `prompt`, `modules`, `category`, and `tags` +6. **If any tool outputs are still unknown**, explicitly tell the user instead of guessing block outputs diff --git a/.agents/skills/add-connector/SKILL.md b/.agents/skills/add-connector/SKILL.md index 2958623e156..ce2e29066ca 100644 --- a/.agents/skills/add-connector/SKILL.md +++ b/.agents/skills/add-connector/SKILL.md @@ -1,6 +1,7 @@ --- name: add-connector description: Add or update a Sim knowledge base connector for syncing documents from an external source, including auth mode, config fields, pagination, document mapping, tags, and registry wiring. Use when working in `apps/sim/connectors/{service}/` or adding a new external document source. +argument-hint: [api-docs-url] --- # Add Connector Skill @@ -86,7 +87,7 @@ export const {service}ConnectorMeta: ConnectorMeta = { configFields: [ // Rendered dynamically by the add-connector modal UI - // Supports 'short-input' and 'dropdown' types + // Supports 'short-input', 'dropdown', and 'selector' types — see ConfigField Types below ], // Optional: tag definitions are metadata too — declare them here @@ -457,6 +458,16 @@ Each entry has: Users can opt out of specific tags in the modal. Disabled IDs are stored in `sourceConfig.disabledTagIds`. The assigned mapping (`semantic id → slot`) is stored in `sourceConfig.tagSlotMapping`. +## `@/connectors/utils` Helpers + +Reuse these instead of inlining the same logic (the validator enforces them): + +- `htmlToPlainText(html)` — strip HTML to plain text before indexing `ExternalDocument.content`. Never index raw HTML. +- `computeContentHash(content)` — stable content hash for change detection. +- `parseTagDate(value)` — parse to a valid `Date` or `undefined` (guards Invalid Date). Use in `mapTags` for date fields. +- `joinTagArray(value)` — validate an array and join to a comma-separated string, or `undefined`. Use in `mapTags` for array/label fields. +- `parseMultiValue(value)` — normalize a value into a `string[]`. + ## mapTags — Metadata to Semantic Keys Maps source metadata to semantic tag keys. Required if `tagDefinitions` is set. @@ -465,13 +476,17 @@ using the `tagSlotMapping` stored on the connector. Return keys must match the `id` values declared in `tagDefinitions`. +Use the `@/connectors/utils` helpers for the common transforms — don't hand-roll date/array validation: + ```typescript +import { joinTagArray, parseTagDate } from '@/connectors/utils' + mapTags: (metadata: Record): Record => { const result: Record = {} - // Validate arrays before casting — metadata may be malformed - const labels = Array.isArray(metadata.labels) ? (metadata.labels as string[]) : [] - if (labels.length > 0) result.labels = labels.join(', ') + // joinTagArray validates the array and joins to a comma-separated string (undefined if empty) + const labels = joinTagArray(metadata.labels) + if (labels) result.labels = labels // Validate numbers — guard against NaN if (metadata.version != null) { @@ -479,11 +494,9 @@ mapTags: (metadata: Record): Record => { if (!Number.isNaN(num)) result.version = num } - // Validate dates — guard against Invalid Date - if (typeof metadata.lastModified === 'string') { - const date = new Date(metadata.lastModified) - if (!Number.isNaN(date.getTime())) result.lastModified = date - } + // parseTagDate returns a valid Date or undefined (guards against Invalid Date) + const lastModified = parseTagDate(metadata.lastModified) + if (lastModified) result.lastModified = lastModified return result } @@ -512,6 +525,24 @@ const response = await fetchWithRetry(url, { ... }, VALIDATE_RETRY_OPTIONS) If `ExternalDocument.sourceUrl` is set, the sync engine stores it on the document record. Always construct the full URL (not a relative path). +## Capped or Incomplete Listings — `syncContext.listingCapped` (REQUIRED) + +If `listDocuments` can ever return **less than the full source set** on a non-incremental sync — a `maxItems`/`maxDocuments`-style cap, or a transient per-item error that drops a still-existing document from the listing — it MUST set `syncContext.listingCapped = true` when that happens. + +The sync engine reconciles deletions by comparing the full listing against stored documents: anything not seen is **hard-deleted** (sync-engine.ts, gated on `!syncContext?.listingCapped`). A truncated listing without this flag deletes every real document beyond the cap. This was the single most common bug found when auditing connectors — do not omit it. + +```typescript +if (hitLimit && syncContext) { + syncContext.listingCapped = true +} +``` + +Rules: +- Set it when a user-configured cap truncates the listing while more documents exist +- Set it when a thrown error caused a still-present document to be skipped during listing +- Do NOT set it when the source is genuinely exhausted (deleted documents must still reconcile) +- Do NOT set it for intentional scope filters (e.g. a date cutoff) — out-of-scope documents should be reconciled normally + ## Sync Engine Behavior (Do Not Modify) The sync engine (`lib/knowledge/connectors/sync-engine.ts`) is connector-agnostic. It: @@ -580,6 +611,7 @@ export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { - `dependsOn` references selector field IDs (not `canonicalParamId`) - Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS` - [ ] `listDocuments` handles pagination with metadata-based content hashes +- [ ] `syncContext.listingCapped = true` set whenever the listing is truncated (max-items cap or transient per-item error) — required to prevent the engine's deletion reconciliation from removing unseen documents - [ ] `contentDeferred: true` used if content requires per-doc API calls (file download, export, blocks fetch) - [ ] `contentHash` is metadata-based (not content-based) and identical between stub and `getDocument` - [ ] `sourceUrl` set on each ExternalDocument (full URL, not relative) diff --git a/.agents/skills/add-enrichment/SKILL.md b/.agents/skills/add-enrichment/SKILL.md index f963ce14517..7b34e4c7c38 100644 --- a/.agents/skills/add-enrichment/SKILL.md +++ b/.agents/skills/add-enrichment/SKILL.md @@ -1,6 +1,7 @@ --- name: add-enrichment description: Add a code-defined table enrichment (registry entry) under `apps/sim/enrichments/` backed by an ordered provider cascade, ensuring every provider tool it calls has hosted-key support. Use when adding a per-row table enrichment that fills cells via existing Sim tools. +argument-hint: --- # Adding a Table Enrichment diff --git a/.agents/skills/add-feature-flag/SKILL.md b/.agents/skills/add-feature-flag/SKILL.md new file mode 100644 index 00000000000..9dd51575905 --- /dev/null +++ b/.agents/skills/add-feature-flag/SKILL.md @@ -0,0 +1,73 @@ +--- +name: add-feature-flag +description: Add a runtime gated feature flag (AppConfig-backed on prod, secret fallback off-prod), gated by org id, user id, or admin +argument-hint: +--- + +# Add Feature Flag Skill + +You add a **runtime, gated feature flag** to Sim — one that can be turned on for specific orgs, users, or admins and changed on prod with no redeploy (AWS AppConfig). When AppConfig isn't the source of truth, the flag falls back to a single **secret** (on/off only). + +## When to use this vs `env-flags.ts` + +- **Feature flag** (`@/lib/core/config/feature-flags.ts`): per-request, gated by `userId`/`orgId`/admin, changeable at runtime. This skill. +- **Env flag** (`@/lib/core/config/env-flags.ts`): deploy-time capability/environment detection (`isProd`, `isHosted`, `isBillingEnabled`). A module-load boolean. **Do not add gated flags here.** + +If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` instead. + +## The flag model + +A flag's **gating rule lives only in the hosted AppConfig document**. It is ON for a context when any clause matches: + +```ts +interface FeatureFlagRule { + enabled?: boolean // global default for everyone + orgIds?: string[] // allowlisted organization ids + userIds?: string[] // allowlisted user ids + admins?: boolean // platform admins (user.role === 'admin') +} +``` + +Critically, **none of this is expressible in code** — gating (especially `admins`) can only be set through AppConfig, so no environment can grant access from a code literal. Off-AppConfig (self-hosted/OSS/local), a flag is simply on or off, derived from its fallback secret. + +## Steps + +1. **Define the flag.** Add one entry to the `FEATURE_FLAGS` registry in `apps/sim/lib/core/config/feature-flags.ts`. Each entry is the flag's whole definition — name (kebab-case key), `description`, and the `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on globally): + + ```ts + const FEATURE_FLAGS = { + '': { + description: '', + fallback: '', + }, + } + ``` + + `fallback` is the env/secret key (typed as `keyof typeof env`), so add `` to `apps/sim/lib/core/config/env.ts` first (and the deployment's secret store) — it won't typecheck otherwise. Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `` a valid `FeatureFlagName`. + +2. **Gate the call site.** Call `isFeatureEnabled` with whatever ids you have — admin status is resolved internally, so callers never pass it: + + ```ts + import { isFeatureEnabled } from '@/lib/core/config/feature-flags' + + if (await isFeatureEnabled('', { userId, orgId })) { + // gated behavior + } + ``` + + - Missing ids are fine — a clause with no matching id is skipped; with no `userId`, the admin clause resolves to `false` without a DB read. + - Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup. + - **Client/UI flags:** resolve server-side (in a server component, route, or loader) and pass the boolean down as a prop. There is no client AppConfig. + +3. **(Prod) configure in AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the flag under `flags` in the hosted `feature-flags` document — including any `orgIds`/`userIds`/`admins` gating — and start a `sim--fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled. + +4. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts`: use `withAppConfig({ flags: { ... } })` to cover the gating rule (mock `isPlatformAdmin` for the `admins` clause), and toggle the fallback secret to cover the off-AppConfig path. + +5. **Clean up after rollout.** When the feature ships to everyone, delete the flag's entry from `FEATURE_FLAGS`, the `` env entry, the AppConfig document, the call sites, and the test. Leaving dead flags around is the main failure mode of flag systems. + +## Notes + +- Flag keys are `kebab-case`. +- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`. +- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only. +- The admin check reads the DB **replica** (`dbReplica`) and is resolved lazily, so an admin-gated flag adds at most one cheap replica read, and only when `admins` is the deciding clause. diff --git a/.agents/skills/add-hosted-key/SKILL.md b/.agents/skills/add-hosted-key/SKILL.md index 2cb257c2060..78f127b10f8 100644 --- a/.agents/skills/add-hosted-key/SKILL.md +++ b/.agents/skills/add-hosted-key/SKILL.md @@ -1,6 +1,7 @@ --- name: add-hosted-key description: Add hosted API key support to a tool so Sim provides the key (metered and billed to the workspace) when a user has not brought their own. Use when adding a `hosting` config to a tool under `apps/sim/tools/{service}/`. +argument-hint: --- # Adding Hosted Key Support to a Tool @@ -11,7 +12,7 @@ When a tool has hosted key support, Sim provides its own API key if the user has | Step | What | Where | |------|------|-------| -| 1 | Register BYOK provider ID | `tools/types.ts`, `app/api/workspaces/[id]/byok-keys/route.ts` | +| 1 | Register BYOK provider ID | `tools/types.ts`, `lib/api/contracts/byok-keys.ts` | | 2 | Research the API's pricing and rate limits | API docs / pricing page (before writing any code) | | 3 | Add `hosting` config to the tool | `tools/{service}/{action}.ts` | | 4 | Hide API key field when hosted | `blocks/blocks/{service}.ts` | @@ -30,10 +31,15 @@ export type BYOKProviderId = | 'your_service' ``` -Then add it to `VALID_PROVIDERS` in `app/api/workspaces/[id]/byok-keys/route.ts`: +Then add the same provider id to the `byokProviderIdSchema` enum in `lib/api/contracts/byok-keys.ts` (this is what the byok-keys route validates against): ```typescript -const VALID_PROVIDERS = ['openai', 'anthropic', 'google', 'mistral', 'your_service'] as const +export const byokProviderIdSchema = z.enum([ + 'openai', + 'anthropic', + // ...existing providers + 'your_service', +]) ``` ## Step 2: Research the API's Pricing Model and Rate Limits @@ -230,7 +236,7 @@ Both subblocks share the same `id: 'apiKey'`, so the same value flows to the too To exclude multiple operations, use an array: `{ field: 'operation', value: ['op_a', 'op_b'] }`. **Reference implementations:** -- **Exa** (`blocks/blocks/exa.ts`): `research` operation excluded from hosting — lines 309-329 +- **Exa** (`blocks/blocks/exa.ts`): `exa_research` operation excluded from hosting — duplicate `apiKey` pair around lines ~348-365 - **Google Maps** (`blocks/blocks/google_maps.ts`): `speed_limits` operation excluded from hosting (deprecated Roads API) ## Step 5: Add to the BYOK Settings UI @@ -284,7 +290,7 @@ This summary helps reviewers verify that the pricing and rate limiting are well- ## Checklist - [ ] Provider added to `BYOKProviderId` in `tools/types.ts` -- [ ] Provider added to `VALID_PROVIDERS` in the BYOK keys API route +- [ ] Provider added to `byokProviderIdSchema` enum in `lib/api/contracts/byok-keys.ts` - [ ] API pricing docs researched — understand per-unit cost and whether the API reports cost in responses - [ ] API rate limits researched — understand RPM/TPM limits, per-key vs per-account, and plan tiers - [ ] `hosting` config added to the tool with `envKeyPrefix`, `apiKeyParam`, `byokProviderId`, `pricing`, and `rateLimit` diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index f564d4685f1..7014bd93aa6 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -1,6 +1,7 @@ --- name: add-integration description: Add a complete Sim integration from API docs, covering tools, block, icon, optional triggers, registrations, and integration conventions. Use when introducing a new service under `apps/sim/tools`, `apps/sim/blocks`, and `apps/sim/triggers`. +argument-hint: [api-docs-url] --- # Add Integration Skill @@ -21,7 +22,7 @@ Adding an integration involves these steps in order: ## Step 1: Research the API Before writing any code: -1. Use Context7 to find official documentation: `mcp__plugin_context7_context7__resolve-library-id` +1. Use Context7 to find official documentation: `mcp__context7__resolve-library-id`, then fetch with `mcp__context7__query-docs` 2. Or use WebFetch to read API docs directly 3. Identify: - Authentication method (OAuth, API Key, both) @@ -128,8 +129,8 @@ export const {service}{Action}Tool: ToolConfig = { ### Block Structure ```typescript import { {Service}Icon } from '@/components/icons' -import type { BlockConfig, BlockMeta } from '@/blocks/types' -import { AuthMode } from '@/blocks/types' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' import { getScopesForService } from '@/lib/oauth/utils' export const {Service}Block: BlockConfig = { @@ -139,6 +140,8 @@ export const {Service}Block: BlockConfig = { longDescription: '...', docsLink: 'https://docs.sim.ai/integrations/{service}', category: 'tools', + integrationType: IntegrationType.X, // Primary category (see IntegrationType enum) + tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type) bgColor: '#HEXCOLOR', icon: {Service}Icon, authMode: AuthMode.OAuth, // or AuthMode.ApiKey @@ -177,32 +180,8 @@ export const {Service}Block: BlockConfig = { outputs: { /* ... */ }, } - -export const {Service}BlockMeta = { - tags: ['tag1', 'tag2'], // IntegrationTag values matching the service's capabilities - templates: [ - { - icon: {Service}Icon, - title: '{Service} use-case title', - prompt: 'Build a workflow that ...', - modules: ['agent', 'workflows'], - category: 'productivity', - tags: ['automation'], - alsoIntegrations: ['slack'], // Optional: other blocks referenced in the prompt - }, - ], -} as const satisfies BlockMeta ``` -### BlockMeta rules - -- **Tags**: Use `IntegrationTag` values from `@/blocks/types`. Do NOT add a `tags` field to the `BlockConfig` object — tags belong only in `BlockMeta`. -- **`integrationType`**: Must be a valid `IntegrationType` enum value (AI, Analytics, Commerce, Communication, Databases, DevOps, Documents, Email, HR, Marketing, Observability, Productivity, Sales, Search, Security, Support). Never invent a value that doesn't exist in the enum. -- **Templates**: Aim for 2–4 templates per integration. Prompts should be concrete enough to generate a real workflow in Mothership. Start with "Build a workflow that..." or "Create a workflow that...". -- **`alsoIntegrations`**: List other block type IDs referenced in the template prompt (e.g. `'slack'`, `'linear'`). -- Place `{Service}BlockMeta` at the very bottom of the file, after the main block export. -- Register it in both the import and the `blocksMeta` object in `registry.ts`. - ### Key SubBlock Patterns **Condition-based visibility:** @@ -259,6 +238,28 @@ export const {Service}BlockMeta = { - **Inputs section:** Must list canonical param IDs (e.g., `fileId`), NOT raw subblock IDs (e.g., `fileSelector`, `manualFileId`) - **Params function:** Must use canonical param IDs, NOT raw subblock IDs (raw IDs are deleted after canonical transformation) +### BlockMeta (Required) + +Export a `{Service}BlockMeta` in the same file as the block — **minimum 7 templates**. See `.agents/skills/add-block/SKILL.md` → "BlockMeta (Required)" for valid `modules` and `category` values and the full pattern. + +```typescript +export const {Service}BlockMeta = { + tags: ['tag1', 'tag2'], + templates: [ + { + icon: {Service}Icon, + title: '{Service} ', + prompt: 'Build a workflow that...', // concrete trigger → transformation → output + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['automation'], + alsoIntegrations: ['slack'], // when the prompt references another service + }, + // ... at least 6 more + ], +} as const satisfies BlockMeta +``` + ## Step 4: Add Icon ### File Location @@ -295,6 +296,31 @@ Once the user provides the SVG: 2. Create a React component that spreads props 3. Ensure viewBox is preserved from the original SVG +### Theme-safety (bare rendering) — REQUIRED + +The icon renders both inside its colored `bgColor` tile AND "bare" (no tile) on a +neutral page — e.g. the home **Suggested actions** list — in both light and dark +mode. A monochrome logo whose paths hardcode a single near-white or near-black +fill is invisible bare on the matching background (white-on-white in light mode, +black-on-black in dark mode). + +Rules when adding the SVG: + +- **Monochrome logos** (a single white or black mark): draw the shape with + `fill='currentColor'`, not `fill='#fff'` / `fill='#000000'`. It then inherits + white inside dark tiles, near-black inside light tiles (via + `getTileIconColorClass`), and the theme-aware `var(--text-icon)` bare — legible + everywhere. Do NOT set `iconColor` for these. +- **Multi-color brand logos** (their own vivid fills): keep the hardcoded fills. + They read on any background. Only set `iconColor` (a vivid brand hex, never a + near-black/near-white tile color) if the bare icon should adopt a brand tint. +- A large white shape with a tiny vivid accent (e.g. a logo where the body is the + white negative space) still vanishes bare — convert the body to `currentColor`. + +Verify with `bun run check:bare-icons` (also runs in CI). It flags purely +monochrome hazards; for partial-accent logos, eyeball the suggested-actions list +in both light and dark mode. + ## Step 5: Create Triggers (Optional) If the service supports webhooks, create triggers using the generic `buildTriggerSubBlocks` helper. @@ -380,21 +406,23 @@ export const tools: Record = { } ``` -### Block Registry (`apps/sim/blocks/registry.ts`) +### Block Registry (`apps/sim/blocks/registry-maps.ts`) + +The data maps (`BLOCK_REGISTRY` + `BLOCK_META_REGISTRY`) live in `registry-maps.ts`; `registry.ts` holds only the accessor functions. Add the import and an entry to each map alphabetically: ```typescript -// Add import (alphabetically) — include BlockMeta +// Add import (alphabetically) import { {Service}Block, {Service}BlockMeta } from '@/blocks/blocks/{service}' -// Add to blocks registry (alphabetically) -export const registry: Record = { +// Add to the config map (alphabetically) +export const BLOCK_REGISTRY: Record = { // ... existing blocks ... {service}: {Service}Block, } -// Add to blocksMeta registry (alphabetically) -export const blocksMeta = { - // ... existing entries ... +// Add to the catalog-meta map (alphabetically) +export const BLOCK_META_REGISTRY: Record = { + // ... existing metas ... {service}: {Service}BlockMeta, } ``` @@ -456,6 +484,8 @@ If creating V2 versions (API-aligned outputs): ### Block - [ ] Created `blocks/blocks/{service}.ts` +- [ ] Set `integrationType` to the correct `IntegrationType` enum value +- [ ] Set `tags` array with all applicable `IntegrationTag` values - [ ] Defined operation dropdown with all operations - [ ] Added credential field with `requiredScopes: getScopesForService('{service}')` - [ ] Added conditional fields per operation @@ -463,14 +493,10 @@ If creating V2 versions (API-aligned outputs): - [ ] Configured tools.access with all tool IDs - [ ] Configured tools.config.tool selector - [ ] Defined outputs matching tool outputs -- [ ] `integrationType` uses a valid `IntegrationType` enum value (no invented values) -- [ ] No `tags` field on the `BlockConfig` object (tags live only in `BlockMeta`) -- [ ] `{Service}BlockMeta` exported at bottom of file with `tags` and `templates` -- [ ] `BlockMeta` type imported from `@/blocks/types` alongside `BlockConfig` -- [ ] Block registered in `blocks/registry.ts` blocks object (alphabetically) -- [ ] Block meta registered in `blocks/registry.ts` blocksMeta object (alphabetically) +- [ ] Registered block + meta in `blocks/registry-maps.ts` (`BLOCK_REGISTRY` / `BLOCK_META_REGISTRY`) - [ ] If triggers: set `triggers.enabled` and `triggers.available` - [ ] If triggers: spread trigger subBlocks with `getTrigger()` +- [ ] Exported `{Service}BlockMeta` with at least 7 templates ### OAuth Scopes (if OAuth service) - [ ] Defined scopes in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS` @@ -482,6 +508,7 @@ If creating V2 versions (API-aligned outputs): - [ ] Asked user to provide SVG - [ ] Added icon to `components/icons.tsx` - [ ] Icon spreads props correctly +- [ ] Monochrome marks use `fill='currentColor'` (not hardcoded white/black) so the icon renders bare in light AND dark mode — verified with `bun run check:bare-icons` ### Triggers (if service supports webhooks) - [ ] Created `triggers/{service}/` directory @@ -503,6 +530,7 @@ If creating V2 versions (API-aligned outputs): - [ ] Verified `tools.config.params` correctly maps and coerces all param types - [ ] Verified every tool output and `transformResponse` path against documented or live-verified JSON responses - [ ] If any response schema remained unknown, explicitly told the user instead of guessing +- [ ] `{Service}BlockMeta` exported with at least 7 templates, each having `icon`, `title`, `prompt`, `modules`, `category`, and `tags` ## Example Command @@ -613,10 +641,10 @@ tools: { #### 3. Create Internal API Route -Create `apps/sim/app/api/tools/{service}/{action}/route.ts`. Internal tool routes are HTTP boundaries and follow the same contract policy as public routes — define the request/response shape in `apps/sim/lib/api/contracts/{service}-tools.ts` (or an existing `internal-tools.ts` / `communication-tools.ts` aggregate) and validate with canonical helpers from `@/lib/api/server`. Never write a route-local Zod schema. +Create `apps/sim/app/api/tools/{service}/{action}/route.ts`. Internal tool routes are HTTP boundaries and follow the same contract policy as public routes — define the request/response shape in `apps/sim/lib/api/contracts/tools/{service}.ts` (or an existing aggregate) and validate with canonical helpers from `@/lib/api/server`. Never write a route-local Zod schema. ```typescript -// apps/sim/lib/api/contracts/{service}-tools.ts +// apps/sim/lib/api/contracts/tools/{service}.ts import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' @@ -649,7 +677,7 @@ export type {Service}UploadResponse = z.output { const requestId = generateRequestId() + // Auth always runs BEFORE parseRequest — never validate untrusted input before authenticating. const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -685,16 +714,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) fileName = userFile.name } else if (data.fileContent) { + // Legacy: base64 string (backwards compatibility) fileBuffer = Buffer.from(data.fileContent, 'base64') fileName = 'file' } else { return NextResponse.json({ success: false, error: 'File required' }, { status: 400 }) } + // Now call external API with fileBuffer const response = await fetch('https://api.{service}.com/upload', { method: 'POST', headers: { Authorization: `Bearer ${data.accessToken}` }, - body: new Uint8Array(fileBuffer), + body: new Uint8Array(fileBuffer), // Convert Buffer for fetch }) // ... handle response diff --git a/.agents/skills/add-model/SKILL.md b/.agents/skills/add-model/SKILL.md index e46f22c43e5..34aea3031fb 100644 --- a/.agents/skills/add-model/SKILL.md +++ b/.agents/skills/add-model/SKILL.md @@ -1,6 +1,7 @@ --- name: add-model -description: Add a new LLM model to `apps/sim/providers/models.ts` with every pricing and capability value verified against the provider's live API docs (no hallucination), plus the repo-side touchpoints that are not data-driven — hosted-key billing, tests, and provider-code handling. Use when adding a model to an existing provider in `apps/sim/providers/models.ts`. +description: Add a new LLM model to apps/sim/providers/models.ts with specs verified against the provider's live API docs (no hallucination) +argument-hint: [docs-url] --- # Add Model Skill diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md index fc79a2d348d..e5fc364b598 100644 --- a/.agents/skills/add-tools/SKILL.md +++ b/.agents/skills/add-tools/SKILL.md @@ -1,6 +1,7 @@ --- name: add-tools -description: Create or update Sim tool configurations from service API docs, including typed params, request mapping, response transforms, outputs, and registry entries. Use when working in `apps/sim/tools/{service}/` or fixing tool definitions for an integration. +description: Create tool configurations for a Sim integration by reading API docs +argument-hint: [api-docs-url] --- # Add Tools Skill diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index 0f168a65be4..2720d94f99d 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -1,17 +1,18 @@ --- name: add-trigger -description: Create or update Sim webhook triggers using the generic trigger builder, service-specific setup instructions, outputs, and registry wiring. Use when working in `apps/sim/triggers/{service}/` or adding webhook support to an integration. +description: Create webhook or polling triggers for a Sim integration +argument-hint: --- # Add Trigger -You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks. +You are an expert at creating webhook and polling triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, polling infrastructure, and how triggers connect to blocks. ## Your Task -1. Research what webhook events the service supports -2. Create the trigger files using the generic builder -3. Create a provider handler if custom auth, formatting, or subscriptions are needed +1. Research what webhook events the service supports — if the service lacks reliable webhooks, use polling +2. Create the trigger files using the generic builder (webhook) or manual config (polling) +3. Create a provider handler (webhook) or polling handler (polling) 4. Register triggers and connect them to the block ## Hard Rule: No Guessed Webhook Payload Schemas @@ -161,23 +162,37 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { ### Block file (`apps/sim/blocks/blocks/{service}.ts`) +Wire triggers into the block so the trigger UI appears and `generate-docs.ts` discovers them. Two changes are needed: + +1. **Spread trigger subBlocks** at the end of the block's `subBlocks` array +2. **Add `triggers` property** after `outputs` with `enabled: true` and `available: [...]` + ```typescript import { getTrigger } from '@/triggers' export const {Service}Block: BlockConfig = { // ... - triggers: { - enabled: true, - available: ['{service}_event_a', '{service}_event_b'], - }, subBlocks: [ // Regular tool subBlocks first... ...getTrigger('{service}_event_a').subBlocks, ...getTrigger('{service}_event_b').subBlocks, ], + // ... tools, inputs, outputs ... + triggers: { + enabled: true, + available: ['{service}_event_a', '{service}_event_b'], + }, } ``` +**Versioned blocks (V1 + V2):** Many integrations have a hidden V1 block and a visible V2 block. Where you add the trigger wiring depends on how V2 inherits from V1: + +- **V2 uses `...V1Block` spread** (e.g., Google Calendar): Add trigger to V1 — V2 inherits both `subBlocks` and `triggers` automatically. +- **V2 defines its own `subBlocks`** (e.g., Google Sheets): Add trigger to V2 (the visible block). V1 is hidden and doesn't need it. +- **Single block, no V2** (e.g., Google Drive): Add trigger directly. + +`generate-docs.ts` deduplicates by base type (first match wins). If V1 is processed first without triggers, the V2 triggers won't appear in `integrations.json`. Always verify by checking the output after running the script. + ## Provider Handler All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`. @@ -342,6 +357,121 @@ export function buildOutputs(): Record { } ``` +## Polling Triggers + +Use polling when the service lacks reliable webhooks (e.g., Google Sheets, Google Drive, Google Calendar, Gmail, RSS, IMAP). Polling triggers do NOT use `buildTriggerSubBlocks` — they define subBlocks manually. + +### Directory Structure + +``` +apps/sim/triggers/{service}/ +├── index.ts # Barrel export +└── poller.ts # TriggerConfig with polling: true + +apps/sim/lib/webhooks/polling/ +└── {service}.ts # PollingProviderHandler implementation +``` + +### Polling Handler (`apps/sim/lib/webhooks/polling/{service}.ts`) + +```typescript +import { pollingIdempotency } from '@/lib/core/idempotency/service' +import type { PollingProviderHandler, PollWebhookContext } from '@/lib/webhooks/polling/types' +import { markWebhookFailed, markWebhookSuccess, resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils' +import { processPolledWebhookEvent } from '@/lib/webhooks/processor' + +export const {service}PollingHandler: PollingProviderHandler = { + provider: '{service}', + label: '{Service}', + + async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> { + const { webhookData, workflowData, requestId, logger } = ctx + const webhookId = webhookData.id + + try { + // For OAuth services: + const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId) + const config = webhookData.providerConfig as unknown as {Service}WebhookConfig + + // First poll: seed state, emit nothing + if (!config.lastCheckedTimestamp) { + await updateWebhookProviderConfig(webhookId, { lastCheckedTimestamp: new Date().toISOString() }, logger) + await markWebhookSuccess(webhookId, logger) + return 'success' + } + + // Fetch changes since last poll, process with idempotency + // ... + + await markWebhookSuccess(webhookId, logger) + return 'success' + } catch (error) { + logger.error(`[${requestId}] Error processing {service} webhook ${webhookId}:`, error) + await markWebhookFailed(webhookId, logger) + return 'failure' + } + }, +} +``` + +**Key patterns:** +- First poll seeds state and emits nothing (avoids flooding with existing data) +- Use `pollingIdempotency.executeWithIdempotency(provider, key, callback)` for dedup +- Use `processPolledWebhookEvent(webhookData, workflowData, payload, requestId)` to fire the workflow +- Use `updateWebhookProviderConfig(webhookId, partialConfig, logger)` for read-merge-write on state +- Use the latest server-side timestamp from API responses (not wall clock) to avoid clock skew + +### Trigger Config (`apps/sim/triggers/{service}/poller.ts`) + +```typescript +import { {Service}Icon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' + +export const {service}PollingTrigger: TriggerConfig = { + id: '{service}_poller', + name: '{Service} Trigger', + provider: '{service}', + description: 'Triggers when ...', + version: '1.0.0', + icon: {Service}Icon, + polling: true, // REQUIRED — routes to polling infrastructure + + subBlocks: [ + { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger' }, + // ... service-specific config fields (dropdowns, inputs, switches) ... + { id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' }, + ], + + outputs: { + // Must match the payload shape from processPolledWebhookEvent + }, +} +``` + +### Registration (3 places) + +1. **`apps/sim/triggers/constants.ts`** — add provider to `POLLING_PROVIDERS` Set +2. **`apps/sim/lib/webhooks/polling/registry.ts`** — import handler, add to `POLLING_HANDLERS` +3. **`apps/sim/triggers/registry.ts`** — import trigger config, add to `TRIGGER_REGISTRY` + +### Helm Cron Job + +Add to `helm/sim/values.yaml` under the existing polling cron jobs: + +```yaml +{service}WebhookPoll: + schedule: "*/1 * * * *" + concurrencyPolicy: Forbid + url: "http://sim:3000/api/webhooks/poll/{service}" +``` + +### Reference Implementations + +- Simple: `apps/sim/lib/webhooks/polling/rss.ts` + `apps/sim/triggers/rss/poller.ts` +- Complex (OAuth, attachments): `apps/sim/lib/webhooks/polling/gmail.ts` + `apps/sim/triggers/gmail/poller.ts` +- Cursor-based (changes API): `apps/sim/lib/webhooks/polling/google-drive.ts` +- Timestamp-based: `apps/sim/lib/webhooks/polling/google-calendar.ts` + ## Checklist ### Trigger Definition @@ -367,7 +497,17 @@ export function buildOutputs(): Record { - [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` - [ ] API key field uses `password: true` +### Polling Trigger (if applicable) +- [ ] Handler implements `PollingProviderHandler` at `lib/webhooks/polling/{service}.ts` +- [ ] Trigger config has `polling: true` and defines subBlocks manually (no `buildTriggerSubBlocks`) +- [ ] Provider string matches across: trigger config, handler, `POLLING_PROVIDERS`, polling registry +- [ ] First poll seeds state and emits nothing +- [ ] Added provider to `POLLING_PROVIDERS` in `triggers/constants.ts` +- [ ] Added handler to `POLLING_HANDLERS` in `lib/webhooks/polling/registry.ts` +- [ ] Added cron job to `helm/sim/values.yaml` +- [ ] Payload shape matches trigger `outputs` schema + ### Testing - [ ] `bun run type-check` passes -- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys +- [ ] Manually verify output keys match trigger `outputs` keys - [ ] Trigger UI shows correctly in the block diff --git a/.agents/skills/babysit/SKILL.md b/.agents/skills/babysit/SKILL.md index 6e5b883ee31..4af7dbbd855 100644 --- a/.agents/skills/babysit/SKILL.md +++ b/.agents/skills/babysit/SKILL.md @@ -100,8 +100,9 @@ round. Always check both conditions freshly after every push. git fetch origin staging && git log --oneline --reverse origin/staging..HEAD gh pr view --json commits -q '.commits[].messageHeadline' ``` - `--reverse` matches `git log`'s newest-first default to the PR commit list's oldest-first - order — without it a positional comparison can spuriously fail on any multi-commit branch. + `--reverse` makes `git log` oldest-first, matching the PR commit list's order — plain + `git log` is newest-first, so without it a positional comparison can spuriously fail on any + multi-commit branch. These two lists must describe the same commits. A review loop runs many pushes across many rounds; checking sync only before the push (step 5) and never after is how a bad push or a PR whose commit history quietly went stale between rounds goes unnoticed. diff --git a/.agents/skills/cleanup/SKILL.md b/.agents/skills/cleanup/SKILL.md index 3df93f3f6ed..c2b6fdf8de7 100644 --- a/.agents/skills/cleanup/SKILL.md +++ b/.agents/skills/cleanup/SKILL.md @@ -1,6 +1,7 @@ --- name: cleanup -description: Run all code quality skills in sequence — effects, memo, callbacks, state, React Query, and emcn design review +description: Run all code quality skills — effects, memo, callbacks, state, React Query, emcn design review, url-state, and comments — analyzing in parallel, then applying fixes sequentially +argument-hint: "[scope] [fix=true|false]" --- # Cleanup @@ -11,18 +12,50 @@ Arguments: User arguments: $ARGUMENTS -## Steps +## Step 1 — Parallel analysis (read-only) -Run each of these skills in order on the specified scope, passing through the scope and fix arguments. After each skill completes, move to the next. Do not skip any. +First parse the user's `$ARGUMENTS` into `scope` and `fix`: extract the `fix=true|false` token wherever it appears in the string (start, middle, or end), and treat everything else — with that token removed — as `scope`. Defaults: `scope` = your current changes, `fix` = true. The `fix` value is consumed by Step 3 — it does NOT propagate to these passes, which always run `fix=false`. -1. `/you-might-not-need-an-effect $ARGUMENTS` -2. `/you-might-not-need-a-memo $ARGUMENTS` -3. `/you-might-not-need-a-callback $ARGUMENTS` -4. `/you-might-not-need-state $ARGUMENTS` -5. `/react-query-best-practices $ARGUMENTS` -6. `/emcn-design-review $ARGUMENTS` +Spawn all eight passes concurrently as subagents in a **single message** (multiple Agent tool calls). Each runs its skill on the parsed `scope` with `fix=false` — analysis and proposals ONLY, no edits. Instruct each agent to return its findings as a structured list: for every proposed change, the file path, line range, a one-line description of the change, and the exact before/after so the orchestrator can apply it without re-deriving. -After all skills have run, output a summary of what was found and fixed (or proposed) across all six passes. +Run these eight in parallel, substituting the parsed `scope` for `` in each invocation (pass the real scope text, never the literal ``): + +1. `/you-might-not-need-an-effect fix=false` +2. `/you-might-not-need-a-memo fix=false` +3. `/you-might-not-need-a-callback fix=false` +4. `/you-might-not-need-state fix=false` +5. `/react-query-best-practices fix=false` +6. `/emcn-design-review fix=false` +7. `/you-might-not-need-url-state fix=false` +8. `/you-might-not-need-a-comment fix=false` + +## Step 2 — Converge + +Collect all findings into one list, **keeping each proposal tagged with the pass that produced it** — do NOT collapse a file's proposals into a single unlabeled patch, because Step 3 applies in pass order and needs those labels. Detect overlaps where two passes touch the same region (common: a state pass and an effect pass on the same block, or a memo and callback pass on the same component). Reconcile only genuine same-region conflicts, and drop proposals a sibling pass has made moot; a reconciled change inherits the pass label of whichever of its passes comes first in the Step 3 dependency order (effects → state → memo → callback → React Query → url-state → emcn → comments), so it is applied at the earliest safe point. Non-overlapping proposals stay as-is with their own labels. The output is a per-pass list of surviving changes, not a per-file patch. + +## Step 3 — Sequential apply + +If `fix=false`, skip this step — just report the proposals from Step 2. + +Otherwise apply the surviving changes yourself (in the main context, not delegated), iterating **pass by pass** in this dependency order so earlier structural changes settle before later passes build on them: + +1. effects → 2. state → 3. memo → 4. callback → 5. React Query → 6. url-state → 7. emcn design → 8. comments + +For each pass in turn, apply all of that pass's changes, then move to the next pass. A file touched by several passes is therefore edited once per pass, in this order — not once as a merged patch. This is what makes the ordering real: a single merged-per-file patch would collapse all passes into one edit and lose it. + +Comments apply last, on purpose: that pass operates on whatever the earlier structural passes settled the code into, so it never edits lines a sibling pass is about to delete or rewrite. + +**Treat every Step 1 proposal as snapshot-relative, not authoritative.** All passes analyzed the *original* files in parallel, so a proposal's line ranges and before/after text describe the code as it was *before* any edits — once an earlier pass has run, a later pass's snippet may no longer match. So for each change, before applying: + +1. Re-read the file and locate the target by its **content** (the proposal's `old_string` snippet), not by its line number — line numbers from Step 1 are only a hint for where to look, since earlier edits shift them. +2. If the `old_string` still matches verbatim, apply it — a content-anchored edit is safe even if its line moved. +3. If it no longer matches (an earlier pass altered that region), do **not** force the stale patch. Re-derive the change from the current code by re-applying that pass's rule to the construct, or drop it if a prior pass already made it moot. Never apply a proposal against text it wasn't computed from. + +After all edits, run `bun run lint:check` (it runs `turbo run lint:check` across the repo — there is no per-file target, so run the full check). + +## Step 4 — Summary + +Output a summary across all eight passes: what each found, what was applied vs. skipped-as-redundant, and any proposals that need a human decision. ## Boundary Audit Guidance diff --git a/.agents/skills/council/SKILL.md b/.agents/skills/council/SKILL.md index 0df112728be..698121ff012 100644 --- a/.agents/skills/council/SKILL.md +++ b/.agents/skills/council/SKILL.md @@ -1,6 +1,7 @@ --- name: council description: Spawn parallel task agents to explore a given area of the codebase from multiple angles, then use their findings to answer the question or build a plan. Use when a task needs broad fan-out exploration across many files before acting. +argument-hint: # No agents/openai.yaml by design: council is a meta/exploration utility (like cleanup, ship, you-might-not-need-*), not a service-integration builder, so it intentionally ships no standalone agent card. --- diff --git a/.agents/skills/emcn-design-review/SKILL.md b/.agents/skills/emcn-design-review/SKILL.md index b53d3e19582..89ae8d47843 100644 --- a/.agents/skills/emcn-design-review/SKILL.md +++ b/.agents/skills/emcn-design-review/SKILL.md @@ -1,6 +1,7 @@ --- name: emcn-design-review description: Review UI code for alignment with the emcn design system — components, tokens, patterns, and conventions +argument-hint: "[scope] [fix=true|false]" --- # EMCN Design Review diff --git a/.agents/skills/react-query-best-practices/SKILL.md b/.agents/skills/react-query-best-practices/SKILL.md index 8a595d1b4dc..4074fd5dc54 100644 --- a/.agents/skills/react-query-best-practices/SKILL.md +++ b/.agents/skills/react-query-best-practices/SKILL.md @@ -1,6 +1,7 @@ --- name: react-query-best-practices description: Audit React Query usage for best practices — key factories, staleTime, mutations, and server state ownership +argument-hint: "[scope] [fix=true|false]" --- # React Query Best Practices diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 43024747fe4..15ea661574f 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -1,6 +1,7 @@ --- name: ship description: Commit, push, and open a PR to staging in one shot — runs the cleanup pass and, when migrations changed, the db-migrate safety review first +argument-hint: "[optional context or scope notes]" --- # Ship Command diff --git a/.agents/skills/validate-connector/SKILL.md b/.agents/skills/validate-connector/SKILL.md index 31d4f4ac4ef..cea6e14ad73 100644 --- a/.agents/skills/validate-connector/SKILL.md +++ b/.agents/skills/validate-connector/SKILL.md @@ -1,6 +1,7 @@ --- name: validate-connector -description: Audit an existing Sim knowledge base connector against the service API docs and repository conventions, then report and fix issues in auth, config fields, pagination, document mapping, tags, and registry entries. Use when validating or repairing code in `apps/sim/connectors/{service}/`. +description: Validate an existing knowledge base connector against its service's API docs +argument-hint: [api-docs-url] --- # Validate Connector Skill @@ -152,6 +153,13 @@ For each API endpoint the connector calls: - [ ] No off-by-one errors in pagination tracking - [ ] The connector does NOT hit known API pagination limits silently (e.g., HubSpot search 10k cap) +### Deletion-Reconciliation Safety (`listingCapped`) — CRITICAL +The sync engine hard-deletes any stored document absent from a full listing. Audit every path where `listDocuments` can return less than the full source set: +- [ ] `syncContext.listingCapped = true` is set when a `maxItems`-style cap truncates the listing while more documents exist +- [ ] `listingCapped` is set when a transient per-item error drops a still-existing document from the listing +- [ ] `listingCapped` is NOT set when the source is genuinely exhausted (deleted documents must reconcile) or for intentional scope filters (date cutoffs) +This is the most common connector bug class — verify it explicitly against `sync-engine.ts`'s reconciliation gate. + ### Pagination State Across Pages - [ ] `syncContext` is used to cache state across pages (user names, field maps, instance URLs, portal IDs, etc.) - [ ] Cached state in `syncContext` is correctly initialized on first page and reused on subsequent pages @@ -289,6 +297,7 @@ Group findings by severity: - Incorrect response field mapping (accessing wrong path) - SOQL/query fields that don't exist on the target object - Pagination that silently hits undocumented API limits +- Missing `syncContext.listingCapped = true` when a cap or transient error truncates the listing — the sync engine hard-deletes the documents absent from the partial listing - Missing error handling that would crash the sync - `requiredScopes` not a subset of OAuth provider scopes - Query/filter injection: user-controlled values interpolated into OData `$filter`, SOQL, or query strings without escaping @@ -344,6 +353,7 @@ After fixing, confirm: - [ ] Validated scopes are sufficient for all API endpoints the connector calls - [ ] Validated token refresh config (`useBasicAuth`, `supportsRefreshTokenRotation`) - [ ] Validated pagination: cursor names, page sizes, hasMore logic, no silent caps +- [ ] Validated deletion-reconciliation safety: `syncContext.listingCapped` set on capped/error-truncated listings, not on genuine exhaustion or intentional scope filters - [ ] Validated content deferral: `contentDeferred: true` used when per-doc content fetch required, metadata-based `contentHash` consistent between stub and `getDocument` - [ ] Validated data transformation: plain text extraction, HTML stripping, content hashing - [ ] Validated tag definitions match mapTags output, correct fieldTypes diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index d21da3bab8d..a6da2ef56a1 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -1,6 +1,7 @@ --- name: validate-integration -description: Audit an existing Sim integration against the service API docs and repository conventions, then report and fix issues across tools, blocks, outputs, OAuth scopes, triggers, and registry entries. Use when validating or repairing a service integration under `apps/sim/tools`, `apps/sim/blocks`, or `apps/sim/triggers`. +description: Validate an existing Sim integration (tools, block, registry) against the service's API docs +argument-hint: [api-docs-url] --- # Validate Integration Skill @@ -24,7 +25,7 @@ Read **every** file for the integration — do not skip any: apps/sim/tools/{service}/ # All tool files, types.ts, index.ts apps/sim/blocks/blocks/{service}.ts # Block definition apps/sim/tools/registry.ts # Tool registry entries for this service -apps/sim/blocks/registry.ts # Block registry entry for this service +apps/sim/blocks/registry-maps.ts # Block + meta registry entry (BLOCK_REGISTRY / BLOCK_META_REGISTRY) apps/sim/components/icons.tsx # Icon definition apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider() apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes @@ -205,12 +206,15 @@ For **each tool** in `tools.access`: - [ ] `bgColor` uses the service's brand color hex - [ ] `icon` references the correct icon component from `@/components/icons` - [ ] `authMode` is set correctly (`AuthMode.OAuth` or `AuthMode.ApiKey`) -- [ ] Block is registered in `blocks/registry.ts` alphabetically - -### BlockMeta Skills (catalog) -- [ ] `{Service}BlockMeta.skills` is present (3–5 for mainstream services, 2–3 for niche/low-level) -- [ ] **Every skill is grounded** — its steps only use operations the block exposes in `tools.access`; flag any skill that implies an unsupported action (e.g. "receive messages" when the block only sends) -- [ ] **Every skill is real, not hallucinated** — web-search the service and confirm each skill maps to a popular use case attested online (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles). Rewrite or remove any skill you cannot source as something people genuinely do with the service. +- [ ] Block + meta are registered in `blocks/registry-maps.ts` (`BLOCK_REGISTRY` / `BLOCK_META_REGISTRY`) alphabetically + +### BlockMeta +- [ ] `{Service}BlockMeta` is exported in the same file as the block +- [ ] Has at least 7 templates, each with `icon`, `title`, `prompt`, `modules`, `category`, and `tags` +- [ ] Prompts describe concrete use cases, not generic descriptions of what the service does +- [ ] `alsoIntegrations` is set on any template whose prompt references another service +- [ ] `skills` present (3–5 mainstream, 2–3 niche), each grounded in `tools.access` — flag any skill implying an unsupported action +- [ ] **Each skill is real, not hallucinated** — web-search and confirm it maps to a popular use case attested online (vendor use-case pages, official docs describing the workflow, reputable "top automations" articles); rewrite/remove any you cannot source - [ ] Each skill has a kebab-case `name` (≤64 chars, unique), a one-line `description`, and markdown `content` with `# Title` + `## Steps` + an output/guidance section ### Block Inputs @@ -315,6 +319,7 @@ After fixing, confirm: - [ ] Validated memory load safety using `.agents/skills/memory-load-check/SKILL.md` when tools list/search/download/import/export/batch data - [ ] Validated error handling (error checks, meaningful messages) - [ ] Validated registry entries (tools and block, alphabetical, correct imports) +- [ ] Validated `{Service}BlockMeta` exported with at least 7 templates - [ ] Reported all issues grouped by severity - [ ] Fixed all critical and warning issues - [ ] Ran `bun run lint` after fixes diff --git a/.agents/skills/validate-model/SKILL.md b/.agents/skills/validate-model/SKILL.md index c05b2e3527b..d40f58d9eee 100644 --- a/.agents/skills/validate-model/SKILL.md +++ b/.agents/skills/validate-model/SKILL.md @@ -1,6 +1,7 @@ --- name: validate-model -description: Validate a model entry (or every model in a provider) in `apps/sim/providers/models.ts` against the provider's live API docs, reporting pricing and capability drift, dead capability flags, hosting/billing intent, and any field that cannot be verified. Use when auditing or repairing model entries under `apps/sim/providers/models.ts`. +description: Validate a model entry (or every model in a provider) in apps/sim/providers/models.ts against the provider's live API docs (no hallucination — reports what cannot be verified) +argument-hint: [model-id] --- # Validate Model Skill @@ -34,7 +35,7 @@ Capture per model: `id`, full `pricing`, full `capabilities`, `contextWindow`, ` ## Step 2: Live-fetch authoritative sources -Use the canonical provider URL table in the `add-model` skill (`.claude/commands/add-model.md`, or its mirror `.agents/skills/add-model/SKILL.md`), Step 1, as the single source of truth — fetch the models index, pricing, and reasoning/parameter caveats pages listed there for the target provider. If you update one table, update the other in the same change. +Use the canonical provider URL table in the `add-model` skill (`.agents/skills/add-model/SKILL.md`), Step 1, as the single source of truth — fetch the models index, pricing, and reasoning/parameter caveats pages listed there for the target provider. Secondary cross-check (use at least one): OpenRouter, Artificial Analysis, CloudPrice. diff --git a/.agents/skills/validate-trigger/SKILL.md b/.agents/skills/validate-trigger/SKILL.md index 113fcccf486..7e4bccb84f8 100644 --- a/.agents/skills/validate-trigger/SKILL.md +++ b/.agents/skills/validate-trigger/SKILL.md @@ -1,6 +1,7 @@ --- name: validate-trigger -description: Audit an existing Sim webhook trigger against the service's webhook API docs and repository conventions, then report and fix issues across trigger definitions, provider handler, output alignment, registration, and security. Use when validating or repairing a trigger under `apps/sim/triggers/{service}/` or `apps/sim/lib/webhooks/providers/{service}.ts`. +description: Validate an existing Sim webhook trigger against provider API docs and repository conventions +argument-hint: [api-docs-url] --- # Validate Trigger diff --git a/.agents/skills/you-might-not-need-a-callback/SKILL.md b/.agents/skills/you-might-not-need-a-callback/SKILL.md index d621465957a..51962f68201 100644 --- a/.agents/skills/you-might-not-need-a-callback/SKILL.md +++ b/.agents/skills/you-might-not-need-a-callback/SKILL.md @@ -1,6 +1,7 @@ --- name: you-might-not-need-a-callback description: Analyze and fix useCallback anti-patterns in your code +argument-hint: "[scope] [fix=true|false]" --- # You Might Not Need a Callback @@ -16,25 +17,37 @@ User arguments: $ARGUMENTS Read before analyzing: 1. https://react.dev/reference/react/useCallback — official docs on when useCallback is actually needed -## When useCallback IS needed +## The one rule that matters -- Passing a callback to a child wrapped in `React.memo` (to preserve referential equality) -- The callback is a dependency of another hook (`useEffect`, `useMemo`) -- The callback is used in a custom hook that documents referential stability requirements +`useCallback` is only useful when **something observes the reference**. Ask: does anything care if this function gets a new identity on re-render? + +Observers that care about reference stability: +- A `useEffect` that lists the function in its deps array +- A `useMemo` that lists the function in its deps array +- Another `useCallback` that lists the function in its deps array +- A child component wrapped in `React.memo` that receives the function as a prop +- A custom hook that documents a referential-stability requirement for the callback + +If none of those apply — if the function is only called inline, or passed to a non-memoized child, or assigned to a native element event — the reference is unobserved and `useCallback` adds overhead with zero benefit. ## Anti-patterns to detect -1. **useCallback on functions not passed as props or deps**: If the function is only called within the same component and isn't in any dependency array, useCallback adds overhead for no benefit. Just declare the function normally. -2. **useCallback with exhaustive deps that change every render**: If the dependency array includes values that change on every render, useCallback recalculates every time. The memoization is wasted. Either stabilize the deps (use refs) or remove the useCallback. -3. **useCallback on event handlers passed to native elements**: `