Skip to content

Commit fe184d3

Browse files
improvement(whatsapp): validate + improve integration skill for file inputs/outputs (#5942)
* improvement(whatsapp): validate + improve integration skill for file inputs/outputs * fix lint * add whatsapp subblock migration
1 parent d64739c commit fe184d3

38 files changed

Lines changed: 3015 additions & 415 deletions

File tree

.agents/skills/add-block/SKILL.md

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -257,48 +257,94 @@ When your block accepts file uploads, use the basic/advanced mode pattern with `
257257
},
258258
```
259259

260+
**Keep the pair to one logical thing.** Basic is the file upload, advanced is *only* a reference to
261+
a file from a previous block. Gmail attachments are the reference implementation
262+
(`apps/sim/blocks/blocks/gmail.ts``attachmentFiles` / `attachments`).
263+
264+
Do not overload the advanced side with alternate identifiers (a remote URL, a provider asset ID, a
265+
path). A subblock whose meaning changes based on what the string looks like is impossible to reason
266+
about, forces the params function to sniff the value, and makes the field's type meaningless. Give
267+
each alternative its own subblock outside the pair:
268+
269+
```typescript
270+
// ✓ Good — the pair is "a file"; other sources are their own fields
271+
{ id: 'mediaFile', type: 'file-upload', canonicalParamId: 'media', mode: 'basic' },
272+
{ id: 'mediaFileRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced' },
273+
{ id: 'mediaId', type: 'short-input', mode: 'advanced' }, // separate concept
274+
{ id: 'mediaLink', type: 'short-input', mode: 'advanced' }, // separate concept
275+
276+
// ✗ Bad — one field meaning three things, resolved by guessing
277+
{ id: 'mediaRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced',
278+
placeholder: 'File reference, media ID, or public URL' },
279+
```
280+
281+
When several fields are mutually exclusive alternatives, mark them all `required: false` and enforce
282+
"exactly one" at execution — a conditionally-required canonical pair rejects the workflow before the
283+
other paths ever get a chance to supply the value.
284+
260285
**Critical constraints:**
261286
- `canonicalParamId` must NOT match any subblock's `id` in the same block
262-
- Values are stored under subblock `id`, not `canonicalParamId`
287+
- A canonical group is **block-wide**, not per-operation: `buildCanonicalIndex` keys groups by
288+
`canonicalParamId` across every subblock, and a group has exactly one `basicId`. Two operations
289+
that each need a file pair need two distinct `canonicalParamId` values.
290+
- All members of a group must share the same `required` status
263291

264292
### Normalizing File Input in tools.config
265293

266-
Use `normalizeFileInput` to handle all input variants:
294+
Put the normalization in `tools.config.params`, never in `tools.config.tool``tool` runs at
295+
serialization, before variable resolution, so a `<block.output>` file reference is not yet a value
296+
there.
267297

268298
```typescript
269299
import { normalizeFileInput } from '@/blocks/utils'
270300

271301
tools: {
272302
access: ['service_upload'],
273303
config: {
274-
tool: (params) => {
275-
// Check all field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy)
276-
const normalizedFile = normalizeFileInput(
277-
params.uploadFile || params.fileRef || params.fileContent,
278-
{ single: true }
279-
)
280-
if (normalizedFile) {
281-
params.file = normalizedFile
304+
tool: (params) => `service_${params.operation}`,
305+
params: (params) => {
306+
// Read the CANONICAL id, not the subblock ids
307+
const { file: fileParam, ...rest } = params
308+
const file = normalizeFileInput(fileParam, { single: true })
309+
return {
310+
...rest,
311+
...(file ? { file } : {}),
282312
}
283-
return `service_${params.operation}`
284313
},
285314
},
286315
}
287316
```
288317

289-
**Why this pattern?**
290-
- Values come through as `params.uploadFile` or `params.fileRef` (the subblock IDs)
291-
- `canonicalParamId` only controls UI/schema mapping, not runtime values
292-
- `normalizeFileInput` handles JSON strings from advanced mode template resolution
318+
**Where the value actually lives at runtime.** The subblock `id` is where the UI *stores* the value,
319+
but it is not what the params function receives. `extractBlockParams`
320+
(`apps/sim/serializer/index.ts`) collapses each canonical group at serialization time:
321+
322+
```typescript
323+
const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean)
324+
sourceIds.forEach((id) => delete params[id]) // subblock ids are deleted
325+
if (chosen !== undefined) params[group.canonicalId] = chosen
326+
```
327+
328+
So by the time `tools.config.params(inputs)` runs (`executor/handlers/generic/generic-handler.ts`),
329+
`params.uploadFile` and `params.fileRef` are **gone** and the value is under `params.file`. Reading a
330+
subblock id there yields `undefined` and silently sends no file.
331+
332+
Only the active mode's value survives — `getCanonicalValues` returns the basic value in basic mode
333+
and the first non-empty advanced value in advanced mode, so a stale value in the dormant mode can
334+
never leak. `normalizeFileInput` then handles the JSON string that advanced-mode template resolution
335+
produces.
336+
337+
Note that `generic-handler` merges rather than replaces (`{ ...inputs, ...transformedParams }`), so
338+
omitting a key from the returned object does not strip it from what the tool receives. Tools simply
339+
ignore params they do not declare.
293340

294341
### File Input Types in `inputs`
295342

296-
Use `type: 'json'` for file inputs:
343+
Declare the **canonical** id with `type: 'json'` — the subblock ids never reach `inputs`:
297344

298345
```typescript
299346
inputs: {
300-
uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' },
301-
fileRef: { type: 'json', description: 'File reference from previous block' },
347+
file: { type: 'json', description: 'File to upload (UserFile or reference)' },
302348
// Legacy field for backwards compatibility
303349
fileContent: { type: 'string', description: 'Legacy: base64 encoded content' },
304350
}

.agents/skills/add-integration/SKILL.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ export const {Service}Block: BlockConfig = {
213213
```typescript
214214
// Basic: Visual selector
215215
{
216-
id: 'channel',
216+
id: 'channelSelector',
217217
type: 'channel-selector',
218218
mode: 'basic',
219219
canonicalParamId: 'channel',
@@ -228,10 +228,19 @@ export const {Service}Block: BlockConfig = {
228228
}
229229
```
230230

231+
Note neither subblock `id` is `channel` — the canonical id is a third name that both members map
232+
onto, and it is the only one that survives serialization.
233+
231234
**Critical Canonical Param Rules:**
232235
- `canonicalParamId` must NOT match any subblock's `id` in the block
233-
- `canonicalParamId` must be unique per operation/condition context
234-
- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter
236+
- `canonicalParamId` must be unique **block-wide**, not per operation. `buildCanonicalIndex` keys
237+
groups by `canonicalParamId` across all subblocks and a group holds exactly one `basicId`, so two
238+
operations that each need their own pair must use two different canonical ids
239+
- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter.
240+
A pair carries ONE concept — for files that means upload (basic) + file reference (advanced), as
241+
in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate
242+
identifiers like a URL or a provider asset ID; give those their own subblocks, mark all the
243+
mutually exclusive sources `required: false`, and enforce "exactly one" at execution
235244
- `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent
236245
- Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions
237246
- **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes)

.claude/commands/add-block.md

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -256,48 +256,94 @@ When your block accepts file uploads, use the basic/advanced mode pattern with `
256256
},
257257
```
258258

259+
**Keep the pair to one logical thing.** Basic is the file upload, advanced is *only* a reference to
260+
a file from a previous block. Gmail attachments are the reference implementation
261+
(`apps/sim/blocks/blocks/gmail.ts``attachmentFiles` / `attachments`).
262+
263+
Do not overload the advanced side with alternate identifiers (a remote URL, a provider asset ID, a
264+
path). A subblock whose meaning changes based on what the string looks like is impossible to reason
265+
about, forces the params function to sniff the value, and makes the field's type meaningless. Give
266+
each alternative its own subblock outside the pair:
267+
268+
```typescript
269+
// ✓ Good — the pair is "a file"; other sources are their own fields
270+
{ id: 'mediaFile', type: 'file-upload', canonicalParamId: 'media', mode: 'basic' },
271+
{ id: 'mediaFileRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced' },
272+
{ id: 'mediaId', type: 'short-input', mode: 'advanced' }, // separate concept
273+
{ id: 'mediaLink', type: 'short-input', mode: 'advanced' }, // separate concept
274+
275+
// ✗ Bad — one field meaning three things, resolved by guessing
276+
{ id: 'mediaRef', type: 'short-input', canonicalParamId: 'media', mode: 'advanced',
277+
placeholder: 'File reference, media ID, or public URL' },
278+
```
279+
280+
When several fields are mutually exclusive alternatives, mark them all `required: false` and enforce
281+
"exactly one" at execution — a conditionally-required canonical pair rejects the workflow before the
282+
other paths ever get a chance to supply the value.
283+
259284
**Critical constraints:**
260285
- `canonicalParamId` must NOT match any subblock's `id` in the same block
261-
- Values are stored under subblock `id`, not `canonicalParamId`
286+
- A canonical group is **block-wide**, not per-operation: `buildCanonicalIndex` keys groups by
287+
`canonicalParamId` across every subblock, and a group has exactly one `basicId`. Two operations
288+
that each need a file pair need two distinct `canonicalParamId` values.
289+
- All members of a group must share the same `required` status
262290

263291
### Normalizing File Input in tools.config
264292

265-
Use `normalizeFileInput` to handle all input variants:
293+
Put the normalization in `tools.config.params`, never in `tools.config.tool``tool` runs at
294+
serialization, before variable resolution, so a `<block.output>` file reference is not yet a value
295+
there.
266296

267297
```typescript
268298
import { normalizeFileInput } from '@/blocks/utils'
269299

270300
tools: {
271301
access: ['service_upload'],
272302
config: {
273-
tool: (params) => {
274-
// Check all field IDs: uploadFile (basic), fileRef (advanced), fileContent (legacy)
275-
const normalizedFile = normalizeFileInput(
276-
params.uploadFile || params.fileRef || params.fileContent,
277-
{ single: true }
278-
)
279-
if (normalizedFile) {
280-
params.file = normalizedFile
303+
tool: (params) => `service_${params.operation}`,
304+
params: (params) => {
305+
// Read the CANONICAL id, not the subblock ids
306+
const { file: fileParam, ...rest } = params
307+
const file = normalizeFileInput(fileParam, { single: true })
308+
return {
309+
...rest,
310+
...(file ? { file } : {}),
281311
}
282-
return `service_${params.operation}`
283312
},
284313
},
285314
}
286315
```
287316

288-
**Why this pattern?**
289-
- Values come through as `params.uploadFile` or `params.fileRef` (the subblock IDs)
290-
- `canonicalParamId` only controls UI/schema mapping, not runtime values
291-
- `normalizeFileInput` handles JSON strings from advanced mode template resolution
317+
**Where the value actually lives at runtime.** The subblock `id` is where the UI *stores* the value,
318+
but it is not what the params function receives. `extractBlockParams`
319+
(`apps/sim/serializer/index.ts`) collapses each canonical group at serialization time:
320+
321+
```typescript
322+
const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean)
323+
sourceIds.forEach((id) => delete params[id]) // subblock ids are deleted
324+
if (chosen !== undefined) params[group.canonicalId] = chosen
325+
```
326+
327+
So by the time `tools.config.params(inputs)` runs (`executor/handlers/generic/generic-handler.ts`),
328+
`params.uploadFile` and `params.fileRef` are **gone** and the value is under `params.file`. Reading a
329+
subblock id there yields `undefined` and silently sends no file.
330+
331+
Only the active mode's value survives — `getCanonicalValues` returns the basic value in basic mode
332+
and the first non-empty advanced value in advanced mode, so a stale value in the dormant mode can
333+
never leak. `normalizeFileInput` then handles the JSON string that advanced-mode template resolution
334+
produces.
335+
336+
Note that `generic-handler` merges rather than replaces (`{ ...inputs, ...transformedParams }`), so
337+
omitting a key from the returned object does not strip it from what the tool receives. Tools simply
338+
ignore params they do not declare.
292339

293340
### File Input Types in `inputs`
294341

295-
Use `type: 'json'` for file inputs:
342+
Declare the **canonical** id with `type: 'json'` — the subblock ids never reach `inputs`:
296343

297344
```typescript
298345
inputs: {
299-
uploadFile: { type: 'json', description: 'Uploaded file (UserFile)' },
300-
fileRef: { type: 'json', description: 'File reference from previous block' },
346+
file: { type: 'json', description: 'File to upload (UserFile or reference)' },
301347
// Legacy field for backwards compatibility
302348
fileContent: { type: 'string', description: 'Legacy: base64 encoded content' },
303349
}

.claude/commands/add-integration.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export const {Service}Block: BlockConfig = {
212212
```typescript
213213
// Basic: Visual selector
214214
{
215-
id: 'channel',
215+
id: 'channelSelector',
216216
type: 'channel-selector',
217217
mode: 'basic',
218218
canonicalParamId: 'channel',
@@ -227,10 +227,19 @@ export const {Service}Block: BlockConfig = {
227227
}
228228
```
229229

230+
Note neither subblock `id` is `channel` — the canonical id is a third name that both members map
231+
onto, and it is the only one that survives serialization.
232+
230233
**Critical Canonical Param Rules:**
231234
- `canonicalParamId` must NOT match any subblock's `id` in the block
232-
- `canonicalParamId` must be unique per operation/condition context
233-
- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter
235+
- `canonicalParamId` must be unique **block-wide**, not per operation. `buildCanonicalIndex` keys
236+
groups by `canonicalParamId` across all subblocks and a group holds exactly one `basicId`, so two
237+
operations that each need their own pair must use two different canonical ids
238+
- Only use `canonicalParamId` to link basic/advanced alternatives for the same logical parameter.
239+
A pair carries ONE concept — for files that means upload (basic) + file reference (advanced), as
240+
in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate
241+
identifiers like a URL or a provider asset ID; give those their own subblocks, mark all the
242+
mutually exclusive sources `required: false`, and enforce "exactly one" at execution
234243
- `mode` only controls UI visibility, NOT serialization. Without `canonicalParamId`, both basic and advanced field values would be sent
235244
- Every subblock `id` must be unique within the block. Duplicate IDs cause conflicts even with different conditions
236245
- **Required consistency:** If one subblock in a canonical group has `required: true`, ALL subblocks in that group must have `required: true` (prevents bypassing validation by switching modes)

.claude/rules/sim-integrations.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ The full authoring instructions — tool/block/icon/trigger scaffolding, SubBloc
1515

1616
- Tool IDs are `snake_case` (`service_action`). Register tools in `tools/registry.ts`, blocks in `blocks/registry-maps.ts` (the `BLOCK_REGISTRY` config map + `BLOCK_META_REGISTRY` catalog-meta map, alphabetically — `blocks/registry.ts` holds only the accessor functions), triggers in `triggers/registry.ts`.
1717
- Type coercions (`Number()`, etc.) belong in `tools.config.params` (runs at execution, after variable resolution) — never in `tools.config.tool` (runs at serialization; coercing there destroys dynamic `<Block.output>` references).
18-
- `canonicalParamId` must NOT match any subblock's `id`, must be unique per operation/condition context, and all subblocks in a canonical group must share the same `required` status. The `inputs` section and the params function reference canonical IDs, not raw subblock IDs.
18+
- `canonicalParamId` must NOT match any subblock's `id`, must be unique **block-wide** (groups are keyed by canonical id across every subblock and hold exactly one `basicId`, so two operations that each need a pair need two different canonical ids), and all subblocks in a canonical group must share the same `required` status. The `inputs` section and the params function reference canonical IDs, not raw subblock IDs — the serializer deletes the subblock IDs and republishes the active member's value under the canonical ID.
19+
- A canonical pair carries ONE concept. For files that is upload (basic) + file reference (advanced), as in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate identifiers (URL, provider asset ID) — give those their own subblocks, mark mutually exclusive sources `required: false`, and enforce "exactly one" at execution.
1920
- Blocks must also set the catalog/UI metadata fields `integrationType`, `tags`, `authMode`, `docsLink`, and export a `{Service}BlockMeta` — see the `/add-block` skill's BlockMeta section for details.

0 commit comments

Comments
 (0)