softr-vibe-coding 2.13.5 → 2.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +20 -5
- package/SKILL.md +2 -0
- package/datasources/fields.md +2 -1
- package/datasources/multi-datasource.md +12 -0
- package/datasources/reading.md +138 -4
- package/datasources/softr-database.md +5 -0
- package/package.json +1 -1
- package/references/anti-patterns.md +2 -0
- package/references/softr-mcp.md +88 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to this skill are documented here. Versions follow [Semantic
|
|
|
4
4
|
|
|
5
5
|
Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
|
|
6
6
|
|
|
7
|
+
## [2.14.1] - 2026-10-06
|
|
8
|
+
- Release 2.14.1
|
|
9
|
+
- Add nine more verified Softr facts from the 2026-09 production build
|
|
10
|
+
|
|
11
|
+
## [2.14.0] - 2026-10-06
|
|
12
|
+
- Release 2.14.0
|
|
13
|
+
- Add Softr runtime and Workflows facts verified on a 2026-09-18/19 production build
|
|
14
|
+
|
|
7
15
|
## [2.13.5] - 2026-10-06
|
|
8
16
|
- Release 2.13.5
|
|
9
17
|
- Correct the MCP FILTER-condition claim and eight other conflicts found in the 2026-10-06 audit
|
package/README.md
CHANGED
|
@@ -199,7 +199,16 @@ softr-vibe-coding/
|
|
|
199
199
|
│ │ # after a resume, update_field/update_table fixes;
|
|
200
200
|
│ │ # Oct 6 2026: MCP-written FILTER conditions are
|
|
201
201
|
│ │ # inert (set them in Studio), Workflows tools as
|
|
202
|
-
│ │ # workflow_*, denied by-id fetch per backend
|
|
202
|
+
│ │ # workflow_*, denied by-id fetch per backend;
|
|
203
|
+
│ │ # Workflows engine facts (CUSTOM_CODE contract,
|
|
204
|
+
│ │ # string-array loops, sample-based validation,
|
|
205
|
+
│ │ # replace_node new ids, re-firing triggers,
|
|
206
|
+
│ │ # write modes, serialExecution, continueOnError),
|
|
207
|
+
│ │ # Softr DB row-gating recipe, what a push leaves
|
|
208
|
+
│ │ # alone, DATETIME create shape, offset paging;
|
|
209
|
+
│ │ # OAuth grant per ticked workspace, email
|
|
210
|
+
│ │ # senders, formulas fixed at creation, loop
|
|
211
|
+
│ │ # counter, workflow time zone and publish state
|
|
203
212
|
│ ├── browser-checks.md # Checking a pushed block in a browser with
|
|
204
213
|
│ │ # the agent-browser CLI (ask before installing):
|
|
205
214
|
│ │ # preview cookie, shadow-DOM refs grepped in the
|
|
@@ -270,20 +279,26 @@ softr-vibe-coding/
|
|
|
270
279
|
│ # select: as a module-scope identifier, the union-of-
|
|
271
280
|
│ # selects read payload (a conditional select is not
|
|
272
281
|
│ # privacy), Actions per table (Sep 18 2026);
|
|
273
|
-
│ # block Visibility gates its endpoints (Oct 5 2026)
|
|
282
|
+
│ # block Visibility gates its endpoints (Oct 5 2026);
|
|
283
|
+
│ # every row carries its record id (Oct 6 2026)
|
|
274
284
|
├── reading.md # useRecords, filtering, sorting, pagination,
|
|
275
285
|
│ # metrics, charts, current user; no detail-page
|
|
276
286
|
│ # auto-scoping, useRecords ignores enabled:false,
|
|
277
287
|
│ # server-side linked-record filters (Sep 18 2026);
|
|
278
|
-
│ # where/orderBy aliases resolve per hook
|
|
288
|
+
│ # where/orderBy aliases resolve per hook, operator
|
|
289
|
+
│ # semantics, filters fail open, userGroups poll,
|
|
290
|
+
│ # excluded useRecord = no record (Oct 6 2026)
|
|
279
291
|
├── writing.md # Mutations, sequential write queues, uploads,
|
|
280
292
|
│ # linked record format, cross-table writes;
|
|
281
293
|
│ # Actions register per table (Sep 18 2026)
|
|
282
294
|
├── fields.md # getFieldValue(), field type shapes, record
|
|
283
295
|
│ # structure, debug utilities; date-only fields
|
|
284
|
-
│ # parsed as local dates
|
|
296
|
+
│ # parsed as local dates, multi-value lookup shape
|
|
297
|
+
│ # (Oct 6 2026)
|
|
285
298
|
├── rest-api.md # useProxyFetch + useQuery (full docs)
|
|
286
|
-
├── softr-database.md # Native DB — field IDs, no rate limits
|
|
299
|
+
├── softr-database.md # Native DB — field IDs, no rate limits; checkbox,
|
|
300
|
+
│ # formula float, EMAIL lists, link label = display
|
|
301
|
+
│ # field, Zapier replaces multi-links (Oct 6 2026)
|
|
287
302
|
├── airtable.md # Column names, PAT vs OAuth, rate limits
|
|
288
303
|
├── google-sheets.md # Text formatting, 50-100 user cap
|
|
289
304
|
├── hubspot.md # 15 objects (listed ≠ usable), field model,
|
package/SKILL.md
CHANGED
|
@@ -73,6 +73,8 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
|
|
|
73
73
|
- Detail page: the record is fetched with `useRecord({ select, recordId, enabled: !!recordId })` using `useCurrentRecordId()`, and the code checks `data.id === recordId` before rendering — never `useRecords({ count: 1 })`, which returns the table's FIRST row (Hard Constraint 25)
|
|
74
74
|
- No list query relies on `enabled: false` — `useRecords` fetches anyway; conditional list queries live in a child component mounted only when needed, or carry a match-nothing `where` (Hard Constraint 26)
|
|
75
75
|
- Every alias a hook's `where` / `orderBy` names is in **that hook's own** `select` — anything else crashes the block at runtime (Hard Constraint 29)
|
|
76
|
+
- Every `where` has been **seen to narrow** the result (compare row counts with and without it) — a filter on a field outside the connection's read-select union is silently ignored and returns everything ([datasources/reading.md](datasources/reading.md#filters-fail-open))
|
|
77
|
+
- Role checks read `window.__softr_current_user.userGroups` from state with a bounded poll, and role-dependent UI waits until it settles — the global has no change event and an early `[]` means not loaded yet ([datasources/reading.md](datasources/reading.md#current-user))
|
|
76
78
|
- Date-only values are parsed with `toLocalDate()`, never `new Date()` — midnight UTC renders a day early west of Greenwich ([datasources/fields.md](datasources/fields.md#date-only-fields-arrive-as-midnight-utc))
|
|
77
79
|
- No field is "hidden" from some viewers by a conditional / second `select` on the same connection — the browser receives the union of every read select on that connection (Hard Constraint 23)
|
|
78
80
|
- All imports use named imports (no `import React from 'react'`)
|
package/datasources/fields.md
CHANGED
|
@@ -82,11 +82,12 @@ You'll see exactly which field is an object. Add `getFieldValue()` around it.
|
|
|
82
82
|
| Date Range | `{ from: string, to: string }` |
|
|
83
83
|
| Rating, Duration | `string or number or null` |
|
|
84
84
|
| Select | `{ label: string, id: string }` |
|
|
85
|
-
| Linked Record (via useRecord/useRecords) | `{ label: string, id: string }` — usually an array of these, but a link can arrive as a **single object** (verified live 2026-09-18); normalise with `Array.isArray(v) ? v : (v ? [v] : [])` |
|
|
85
|
+
| Linked Record (via useRecord/useRecords) | `{ label: string, id: string }` — usually an array of these, but a link can arrive as a **single object** (verified live 2026-09-18); normalise with `Array.isArray(v) ? v : (v ? [v] : [])`. `label` is the linked table's display field, so it changes if that field does (Softr Database, verified 2026-09-19; [softr-database.md](softr-database.md#gotchas)) |
|
|
86
86
|
| Linked Record (via useLinkedRecords) | `{ id: string, title: string }` -- different! |
|
|
87
87
|
| User, Created By, Updated By | `{ avatarUrl, id, name, email }` |
|
|
88
88
|
| Attachment | `{ filename, id, type, url }` |
|
|
89
89
|
| Formula | `string or number` |
|
|
90
|
+
| Lookup (multi-value) | array of **strings**, one element per linked record: `["#1042#"]`, and `[]` when empty (verified live 2026-09-19, Softr Database). `getFieldValue()` joins it for display; a `contains` filter on it tests each element ([reading.md](reading.md#operator-semantics-on-the-server)) |
|
|
90
91
|
|
|
91
92
|
## Record Structure
|
|
92
93
|
|
|
@@ -106,6 +106,18 @@ the block — it is simply not rendered. Anyone can read it in the network tab.
|
|
|
106
106
|
What does *not* join the union: a mutation hook's `fields:` select. Write-only fields stay out of
|
|
107
107
|
the read payload.
|
|
108
108
|
|
|
109
|
+
**Every row carries its record id, whatever the select.** The union governs *fields*; the records
|
|
110
|
+
endpoint returns each row's own record id beside them however narrow the select is. A connection
|
|
111
|
+
whose only select was one email field still gave anyone who crafted the request every row's id
|
|
112
|
+
with its email (noted in a production block's security review, 2026-09-19). So wherever knowing a
|
|
113
|
+
record id lets someone act (a by-id fetch, an update addressed by `recordId`, a link write that a
|
|
114
|
+
Source condition then keys on), treat ids as access keys: a connection hands every row it releases,
|
|
115
|
+
with its id, to every viewer allowed to call it. Narrowing the select does not withhold ids; only
|
|
116
|
+
fewer rows (a Source condition) or a gated page or block does. A link field in a select ships the
|
|
117
|
+
*linked* records' ids too (`{ id, label }`). To filter by a link without shipping them, filter on
|
|
118
|
+
a readonly key looked up through the link instead
|
|
119
|
+
([reading.md](reading.md#operator-semantics-on-the-server)).
|
|
120
|
+
|
|
109
121
|
**Remedy.** Connect the **same table a second time** — Softr allows it, and the second connection
|
|
110
122
|
gets its own `dataSourceId` — and read the private field only through that connection, from a
|
|
111
123
|
hook that non-privileged browsers never run:
|
package/datasources/reading.md
CHANGED
|
@@ -9,9 +9,9 @@ Fetching, filtering, sorting, pagination, metrics, charts, and current user.
|
|
|
9
9
|
- [useRecord -- Fetch a Single Record](#userecord----fetch-a-single-record) — the detail-page pattern; no auto-scoping
|
|
10
10
|
- [useLinkedRecords -- Fetch Linked/Related Options](#uselinkedrecords----fetch-linkedrelated-options)
|
|
11
11
|
- [useFieldOptions -- Fetch Single/Multi-Select Choices](#usefieldoptions----fetch-singlemulti-select-choices)
|
|
12
|
-
- [Filtering](#filtering) — incl. [server-side linked-record filters](#filtering-by-a-linked-record-server-side)
|
|
12
|
+
- [Filtering](#filtering) — incl. [operator semantics on the server](#operator-semantics-on-the-server), [filters fail open](#filters-fail-open), [server-side linked-record filters](#filtering-by-a-linked-record-server-side)
|
|
13
13
|
- [Sorting](#sorting)
|
|
14
|
-
- [Current User](#current-user)
|
|
14
|
+
- [Current User](#current-user) — user groups need a short, bounded poll
|
|
15
15
|
- [Metrics](#metrics)
|
|
16
16
|
- [Chart Data](#chart-data)
|
|
17
17
|
|
|
@@ -112,10 +112,12 @@ import { useState, useEffect } from "react";
|
|
|
112
112
|
var result = useRecords({ select: select, count: 100 });
|
|
113
113
|
|
|
114
114
|
useEffect(function() {
|
|
115
|
-
|
|
115
|
+
// `!result.error` is defensive: never re-request a page while the hook reports an error
|
|
116
|
+
// (how a failed later page surfaces has not been verified).
|
|
117
|
+
if (result.hasNextPage && !result.isFetchingNextPage && result.status === "success" && !result.error) {
|
|
116
118
|
result.fetchNextPage();
|
|
117
119
|
}
|
|
118
|
-
}, [result.hasNextPage, result.isFetchingNextPage, result.status, result.fetchNextPage]);
|
|
120
|
+
}, [result.hasNextPage, result.isFetchingNextPage, result.status, result.error, result.fetchNextPage]);
|
|
119
121
|
```
|
|
120
122
|
|
|
121
123
|
## useRecord -- Fetch a Single Record
|
|
@@ -148,6 +150,14 @@ the server which record the page is "about". Consequences:
|
|
|
148
150
|
returns. So always pass `enabled: !!recordId` (`useRecord` honours `enabled: false` — no
|
|
149
151
|
request is made) and verify `data.id === recordId` before rendering or, worse, writing.
|
|
150
152
|
|
|
153
|
+
**A record the connection's Source conditions exclude comes back as no record, not as an error**
|
|
154
|
+
(verified live 2026-09-18, Softr Database). The by-id request answers HTTP 200 with an empty
|
|
155
|
+
body, not 403 or 404, so `useRecord` reports no error and holds no record. Render that as "not
|
|
156
|
+
found"; never wait for a 403/404 to learn the viewer was refused. A production block also sends
|
|
157
|
+
any denial-shaped error (401/403/404, or a JSON parse error on an empty body) to the same "not
|
|
158
|
+
found" state, in case a later build answers differently, and keeps the error panel with a retry
|
|
159
|
+
for real failures.
|
|
160
|
+
|
|
151
161
|
**A recordId-less `useRecord` — what the older note here meant, and its limits.** This file used
|
|
152
162
|
to say that `useRecord({ select })` with no `recordId` "loads the record the block is bound to
|
|
153
163
|
via its data-source binding in Studio" (seen on one deployed Airtable-backed stats block, July
|
|
@@ -259,6 +269,47 @@ where: q.and(
|
|
|
259
269
|
)
|
|
260
270
|
```
|
|
261
271
|
|
|
272
|
+
### Operator semantics on the server
|
|
273
|
+
|
|
274
|
+
*Softr Database: `is` verified live 2026-09-18, `contains` 2026-09-19.* What the operators do once
|
|
275
|
+
the filter reaches the server:
|
|
276
|
+
|
|
277
|
+
- **Text `is` is case-insensitive.** `q.text("email").is("Ann@Example.com")` matches
|
|
278
|
+
`ann@example.com`. Compare client-side when case matters.
|
|
279
|
+
- **`contains` is a case-insensitive substring test, and on a multi-value lookup it tests each
|
|
280
|
+
element** — never the elements joined into one string. Multi-value lookups arrive in the browser
|
|
281
|
+
as arrays of strings ([fields.md](fields.md#common-field-type-shapes)). To match one whole value inside a
|
|
282
|
+
lookup, wrap every value in delimiters it cannot contain (a formula such as
|
|
283
|
+
`CONCATENATE("#", {Order No}, "#")`, looked up through the link), search for the delimited
|
|
284
|
+
value, and re-check the exact value client-side: an undelimited `contains("1042")` also
|
|
285
|
+
matches `10420`.
|
|
286
|
+
- **`contains("")` returned 0 rows, not every row** (measured 2026-09-19 against a lookup field; a
|
|
287
|
+
plain text field was not probed). Don't build on it either way. When a hook must match nothing
|
|
288
|
+
until a value exists, give it an explicit sentinel, as in option 2 under
|
|
289
|
+
[`useRecords` ignores `enabled: false`](#userecords-ignores-enabled-false); for `contains` the
|
|
290
|
+
sentinel must not be a substring of any real value either.
|
|
291
|
+
|
|
292
|
+
```jsx
|
|
293
|
+
var KEY_NONE = "#no-key#"; // no real "#<number>#" key can contain this
|
|
294
|
+
var orderKey = orderNo ? "#" + orderNo + "#" : "";
|
|
295
|
+
|
|
296
|
+
// orderKeys = a lookup, through the link, of the order's "#<number>#" key formula
|
|
297
|
+
var lines = useRecords({ from: ds.lines, select: lineSelect, count: 100,
|
|
298
|
+
where: q.text("orderKeys").contains(orderKey || KEY_NONE) });
|
|
299
|
+
|
|
300
|
+
// The server test is a substring test: keep only rows whose lookup holds the exact key.
|
|
301
|
+
// (items = the flattened pages of `lines`)
|
|
302
|
+
var mine = !orderKey ? [] : items.filter(function(r) {
|
|
303
|
+
var v = r.fields.orderKeys;
|
|
304
|
+
var keys = Array.isArray(v) ? v : (v ? [String(v)] : []);
|
|
305
|
+
return keys.indexOf(orderKey) !== -1;
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
A key like this also lets a block filter by a link without selecting the link field, which would
|
|
310
|
+
ship the linked records' ids to the browser
|
|
311
|
+
([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)).
|
|
312
|
+
|
|
262
313
|
### Filter and sort aliases must be in the same hook's select
|
|
263
314
|
|
|
264
315
|
*Seen live 2026-09-18 (Softr Database, on a `useMetric`).* Aliases are resolved **per hook**, not
|
|
@@ -279,6 +330,37 @@ Adding the field to the select also adds it to the connection's read payload
|
|
|
279
330
|
([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)),
|
|
280
331
|
so put a filter on a private field on the connection that is allowed to carry it.
|
|
281
332
|
|
|
333
|
+
### Filters fail open
|
|
334
|
+
|
|
335
|
+
*Verified live 2026-09-19 (Softr Database).* A filter on a field that is **not in the
|
|
336
|
+
connection's read-select union**
|
|
337
|
+
([multi-datasource.md](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects))
|
|
338
|
+
is **silently ignored**: no error, and the query returns everything, as if there were no `where`.
|
|
339
|
+
(This was recorded for the filter a block sends with its request, the hook's `where`. Source
|
|
340
|
+
conditions were not part of the finding.) How a block's own `where` can name a field outside the
|
|
341
|
+
union while passing the per-hook alias rule above was not established: the session that found it
|
|
342
|
+
also sent hand-built requests to the endpoint, which can name any field. Either way there is no
|
|
343
|
+
way to filter on a field without shipping it: leave it out of every read select and the filter
|
|
344
|
+
stops applying.
|
|
345
|
+
|
|
346
|
+
The two rules fail in opposite directions. An alias missing from the hook's own `select` crashes
|
|
347
|
+
the block ([above](#filter-and-sort-aliases-must-be-in-the-same-hooks-select)); a field missing from
|
|
348
|
+
the connection's union drops the filter without a word. A block that compiles and renders
|
|
349
|
+
plausible rows has passed the first check and proved nothing about the second.
|
|
350
|
+
|
|
351
|
+
Treat every `where` as unproven until you have seen it narrow:
|
|
352
|
+
|
|
353
|
+
1. Confirm each field the `where` names is in a read select on that connection.
|
|
354
|
+
2. Load the block as a viewer who can see more rows than the filter should leave (an admin is
|
|
355
|
+
usually easiest) and compare the count with and without the `where`, or read the response in
|
|
356
|
+
the network tab: 7 rows without it and 3 with it, not 7 and 7.
|
|
357
|
+
3. Where an ignored filter would make the block show the wrong rows (another order's lines on
|
|
358
|
+
this order's page), apply the same condition client-side to every row as well, so the block
|
|
359
|
+
stays correct even if the server returns everything.
|
|
360
|
+
|
|
361
|
+
A `where` is not access control in any case ([Current User](#current-user)); this is about the
|
|
362
|
+
block showing the rows it says it shows.
|
|
363
|
+
|
|
282
364
|
### Filtering by a linked record (server-side)
|
|
283
365
|
|
|
284
366
|
*Verified live 2026-09-18 (Softr Database, network capture).* A linked-record field filters on
|
|
@@ -302,6 +384,10 @@ On the wire the alias is resolved to the field id:
|
|
|
302
384
|
different connections may use the same alias name for different fields, and each filter resolves
|
|
303
385
|
against its own hook's `select`.
|
|
304
386
|
|
|
387
|
+
**`isOneOf` filters a link the same way**, for "linked to any of these" (verified live 2026-09-18):
|
|
388
|
+
`q.array("order").isOneOf(orderIds)` goes out as `operator: "IS_ONE_OF"` with the id array as
|
|
389
|
+
`value`, next to `hasAllOf([id])`.
|
|
390
|
+
|
|
305
391
|
`orderId` must be a real id when the hook runs — `useRecords` cannot be switched off with
|
|
306
392
|
`enabled: false` ([above](#userecords-ignores-enabled-false)), so mount this query in a child
|
|
307
393
|
component that only renders once the parent record has loaded.
|
|
@@ -359,6 +445,54 @@ var userGroups = softrUser.userGroups || [];
|
|
|
359
445
|
var isPremium = userGroups.some(function(g) { return g.name === "Premium Member"; });
|
|
360
446
|
```
|
|
361
447
|
|
|
448
|
+
**`window.__softr_current_user` has no change event.** It is a plain global the Softr shell fills
|
|
449
|
+
in, and nothing re-renders the block when it lands. An empty `userGroups` early on means the
|
|
450
|
+
shell is not ready yet, not that the user has no groups; read once at mount, an admin can be
|
|
451
|
+
settled as a non-admin for good. The shell is usually ready before the block's data arrives. Two
|
|
452
|
+
production blocks (2026-09-18) cover the case where it isn't: they hold the user in state, poll
|
|
453
|
+
with a bound, and treat an empty list as not loaded yet, since every logged-in user carries at
|
|
454
|
+
least Softr's predefined groups.
|
|
455
|
+
|
|
456
|
+
```jsx
|
|
457
|
+
import { useState, useEffect } from "react";
|
|
458
|
+
|
|
459
|
+
// Module scope. An empty userGroups means the shell is still filling in: return null until then.
|
|
460
|
+
function readShellUser() {
|
|
461
|
+
var u = window.__softr_current_user || null;
|
|
462
|
+
if (!u || !Array.isArray(u.userGroups) || u.userGroups.length === 0) return null;
|
|
463
|
+
return u;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// In Block():
|
|
467
|
+
var [shellUser, setShellUser] = useState(readShellUser());
|
|
468
|
+
var [groupsSettled, setGroupsSettled] = useState(!!readShellUser());
|
|
469
|
+
|
|
470
|
+
useEffect(function() {
|
|
471
|
+
if (groupsSettled) return;
|
|
472
|
+
var tries = 0;
|
|
473
|
+
var timer = setInterval(function() {
|
|
474
|
+
tries += 1;
|
|
475
|
+
var found = readShellUser();
|
|
476
|
+
if (found) {
|
|
477
|
+
clearInterval(timer);
|
|
478
|
+
setShellUser(found);
|
|
479
|
+
setGroupsSettled(true);
|
|
480
|
+
} else if (tries >= 14) { // about 2 s at 150 ms, then settle with no groups
|
|
481
|
+
clearInterval(timer);
|
|
482
|
+
setGroupsSettled(true);
|
|
483
|
+
}
|
|
484
|
+
}, 150);
|
|
485
|
+
return function() { clearInterval(timer); };
|
|
486
|
+
}, [groupsSettled]);
|
|
487
|
+
|
|
488
|
+
var userGroups = (shellUser && shellUser.userGroups) || [];
|
|
489
|
+
var isAdmin = userGroups.some(function(g) { return g.name === "Admin"; });
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Gate anything role-dependent on `groupsSettled` (show a skeleton until then), so a viewer never
|
|
493
|
+
flashes the wrong panel. The bound settles a viewer whose list never fills in, instead of leaving
|
|
494
|
+
them on a skeleton.
|
|
495
|
+
|
|
362
496
|
## Metrics
|
|
363
497
|
|
|
364
498
|
```jsx
|
|
@@ -102,6 +102,11 @@ No API rate limits. Softr Database queries run internally without external API c
|
|
|
102
102
|
- **Formula boolean values are strings.** A formula that evaluates to true returns `"1"`, not `true`. Always compare with `=== "1"` or `=== "0"`.
|
|
103
103
|
- **Field IDs are opaque codes.** You cannot guess them from column names. Look them up via the ranked list above (MCP `database_list_fields` / bundled CLI / network inspector / Studio field drawer) — the generic Field Inspector block does NOT work for Softr Database.
|
|
104
104
|
- **Relationships** work similarly to linked records in Airtable but use Softr's internal record IDs.
|
|
105
|
+
- **A checkbox reads back as a real boolean** (`true` / `false`), not a string like a formula's boolean above (verified live 2026-09-18).
|
|
106
|
+
- **Formula arithmetic is floating-point.** `1.15 * 400` rendered as `459.99999999999994` (seen 2026-09-18; the stored 1.15 was exact, the product was not). Wrap money and any other displayed product in `ROUND(…, 2)`.
|
|
107
|
+
- **An EMAIL field does not enforce one address.** A full read of a production table on 2026-09-01 found EMAIL-typed fields holding comma-separated lists. Split and trim before treating the value as one address. Passed whole into an email's To field, the addresses all see each other.
|
|
108
|
+
- **A link's `label` is the linked table's display field** (verified 2026-09-19). Change that table's display field in Studio and every label changes with it, so anything that matches on a label (block code, a Source condition, a workflow reference such as `[*].label`) silently stops matching. Match on the record id, or read the value from its own field.
|
|
109
|
+
- **Softr's Zapier "Update Record" action replaces a multi-link field's whole set** (confirmed by a Studio test, 2026-09-01). A zap that writes one record id into a link that allows several wipes the earlier links. Write the existing ids plus the new one.
|
|
105
110
|
|
|
106
111
|
## Best For
|
|
107
112
|
- New projects starting from scratch
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "softr-vibe-coding",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.1",
|
|
4
4
|
"description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"softr-vibe-coding": "bin/cli.js"
|
|
@@ -23,6 +23,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
23
23
|
| `select: isAdmin ? adminSelect : publicSelect` (or an inline `q.select({...})` in the hook options) in a **multi-datasource** block | The select cannot be attributed to a connection and the query returns records with `fields: {}` — no compile error, no runtime error, just empty fields (verified live 2026-09-18). `select:` / `fields:` must be a **plain module-scope identifier**. In a single-datasource block the ternary "works", but as a union of both branches (row above) — so it is never the tool it looks like. See [datasources/multi-datasource.md](../datasources/multi-datasource.md#select-must-be-a-plain-module-scope-identifier) |
|
|
24
24
|
| `useRecords({ select, count: 1 })` on a detail page, expecting the page's record — or a `useRecord` with no / null `recordId` | There is **no detail-page auto-scoping**: the runtime sends `pageContext: null`, so `count: 1` returns the table's FIRST row, and a null-id `useRecord` falls back to a list call (verified live 2026-09-18). It passes a test on the first record and fails on every other. Use `useRecord({ from, select, recordId, enabled: !!recordId })` with `recordId = useCurrentRecordId()` (which does return the URL's `recordId`; the call hits `/records/<id>`), and verify `data.id === recordId` before rendering or writing. See [datasources/reading.md](../datasources/reading.md#userecord----fetch-a-single-record) |
|
|
25
25
|
| `where: q.text("status")…` or `orderBy` naming an alias that is not in **that hook's own** `select` | Crashes the whole block at runtime — "Could not find an alias for subject \"undefined\"", Softr's "Oh snap" panel — though the push compiled clean (seen live 2026-09-18 on a `useMetric`). Aliases resolve per hook, not per connection: add the field to the hook's select (it then joins the connection's read payload). Hard Constraint 29; see [datasources/reading.md](../datasources/reading.md#filter-and-sort-aliases-must-be-in-the-same-hooks-select) |
|
|
26
|
+
| Trusting that a `where` ran because the block compiled and shows plausible rows | A filter on a field that is not in the connection's read-select union is **silently ignored**: no error, and the query returns everything (verified live 2026-09-19). The alias rule (row above) catches only aliases missing from the hook's own select, and it fails loudly; this one fails open. Prove each `where` narrows: as a viewer who can see more rows than it should leave (an admin, say), compare the count with and without it (7 → 3, not 7 → 7), and apply the condition client-side as well wherever an ignored filter would show the wrong rows. See [datasources/reading.md](../datasources/reading.md#filters-fail-open) |
|
|
26
27
|
| `useRecords({ ..., enabled: false })` / `enabled: someFlag` to defer or withhold a list query | **`useRecords` ignores `enabled: false`** — literal or variable, it fetches anyway (verified live 2026-09-18). `useRecord` honours it. To make a list query conditional, mount the hook in a **child component rendered only when needed** (the only option that sends no request), or give it a match-nothing `where`. Never rely on `enabled` to keep a table away from viewers who should not load it. See [datasources/reading.md](../datasources/reading.md#userecords-ignores-enabled-false) |
|
|
27
28
|
|
|
28
29
|
## Mutations
|
|
@@ -112,6 +113,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
112
113
|
| Anti-Pattern | Correct Approach |
|
|
113
114
|
|---|---|
|
|
114
115
|
| `currentUser.role` for tiers | `window.__softr_current_user.userGroups` |
|
|
116
|
+
| Reading `window.__softr_current_user.userGroups` once at mount and treating `[]` as "no groups" | The global has **no change event**: nothing re-renders the block when the shell fills it in, and an early empty `userGroups` means not loaded yet. Read once, an admin can be settled as a non-admin for good. Hold it in state, poll with a bound (about 2 s), and gate role-dependent UI on the poll having settled. Pattern from two production blocks, 2026-09-18; see [datasources/reading.md](../datasources/reading.md#current-user) |
|
|
115
117
|
|
|
116
118
|
## Editable Settings
|
|
117
119
|
|
package/references/softr-mcp.md
CHANGED
|
@@ -193,6 +193,8 @@ Permissions are chosen per workspace across **three areas with bundled levels**
|
|
|
193
193
|
|
|
194
194
|
For block-building work you need **Applications & Forms: Full access** (to create/edit blocks) plus at least **Databases: View only** (schema discovery). Integrations browsing rides on Applications & Forms read access.
|
|
195
195
|
|
|
196
|
+
**Workspace membership is not enough.** The OAuth grant covers only the workspaces ticked on the authorization screen. On 2026-09-01 a user who was a member of a client's workspace in Softr still could not reach it through the MCP; re-authorizing the connector with that workspace ticked fixed it. When an app or workspace you can open in Studio is missing from `application_list` / `workspace_list`, check the grant under Settings → API tokens → Authorized apps.
|
|
197
|
+
|
|
196
198
|
## Vibe coding block tools
|
|
197
199
|
|
|
198
200
|
Before writing any block code through the MCP, call `vibe_coding_block_get_docs` — it returns the current version of the [Vibe Coding Developer Guide](https://docs.softr.io/vibe-coding-developer-guide), which is the authority on hook signatures if it and this skill ever disagree. On runtime *behaviour* the guide's prose can lag a live capture, and where it does this skill says so: the guide still describes `useRecords({ enabled })` as a way to defer loading (checked 2026-10-06), while a 2026-09-18 network capture showed `useRecords` fetching anyway ([reading.md](../datasources/reading.md#userecords-ignores-enabled-false)). Trust the capture until a newer one says otherwise.
|
|
@@ -338,6 +340,22 @@ default permissions (see the next section for why that can be a security problem
|
|
|
338
340
|
the restoration). If page-level visibility is the access control in your app, record that decision
|
|
339
341
|
so nobody chases the reset after every round; if it is not, re-tighten and read back.
|
|
340
342
|
|
|
343
|
+
**What a push leaves alone, and when it goes live** (our observations on Softr Database, not
|
|
344
|
+
from the docs):
|
|
345
|
+
|
|
346
|
+
- **A code push does not clear Source conditions** (verified 2026-09-18). Only the Actions reset.
|
|
347
|
+
- **Disconnecting and reconnecting a data source does.** `vibe_coding_block_disconnect_data_source`,
|
|
348
|
+
then `vibe_coding_block_connect_data_source` with the same table, brings the block back bound to
|
|
349
|
+
that table with an **empty** condition (2026-09-10). Other blocks bound to the same data source id
|
|
350
|
+
kept theirs. That makes it a way to clear a broken condition when the filter tool cannot be called,
|
|
351
|
+
and it also means a reconnect done for any other reason drops the row gate: read the conditions
|
|
352
|
+
back afterwards. The next code push re-derives the block's Actions at the defaults, as any push does.
|
|
353
|
+
- **A push lands in the draft.** The live app serves it only after the next app publish, and that
|
|
354
|
+
publish, whoever runs it, takes every other pending draft change live with it, unversioned
|
|
355
|
+
settings edits included. Before calling a block "staged", compare the app's last publish time
|
|
356
|
+
(`application_get`) with the version's `createdAt` (`vibe_coding_block_list_versions`): on
|
|
357
|
+
2026-09-01 a block we believed staged went live with a publish 28 minutes after it was saved.
|
|
358
|
+
|
|
341
359
|
### The array-argument rejection, and why it is a security issue
|
|
342
360
|
|
|
343
361
|
**Several workspace-server tools take an array argument, and a call that sends it as a JSON *string*
|
|
@@ -480,7 +498,7 @@ and these are the gates that actually exist on them (`<connection>` was recorded
|
|
|
480
498
|
| **Page VIEW permission** | **Yes.** A viewer who cannot view the page gets **403** ("block/action visibility rules…") from the block's datasource endpoint — crafting the request by hand does not get around it |
|
|
481
499
|
| **The block's Visibility** (`predefinedUserGroup` + `customUserGroupIds`; `vibe_coding_block_set_visibility`) | **Yes.** A viewer outside the block's group gets the same **403**, on list and by-id, even where the page lets them in. The body reads like a write error on a read: "You cannot add or edit a record because either the block/action visibility rules, user group conditions, or the user/record data in the datasource has changed." Per block: the same table on an ungated block stays open. Five code pushes left the setting intact |
|
|
482
500
|
| **The connection's Source conditions** (Source tab / `vibe_coding_block_set_data_source_record_filters`) | **Yes — and they are the only server-side ROW gate.** A by-id request for a record the condition excludes answers differently per backend: **HTTP 200 with an empty body** on Softr Database (2026-09-18), **404** on HubSpot (2026-10-05). Treat both as "not found" |
|
|
483
|
-
| A `where` filter in the block's code | No — it is a request parameter the caller controls |
|
|
501
|
+
| A `where` filter in the block's code | No — it is a request parameter the caller controls. It can also **fail open**: on 2026-09-19 (Softr Database) a request filter on a field that was not in the connection's read-select union was silently ignored, with no error, and the query returned everything. Confirm the filtered field is selected on the connection and prove the `where` narrows the result (compare counts with and without it); see [reading.md](../datasources/reading.md#filters-fail-open). A `where` naming an alias missing from its own hook's `select` fails the other way and crashes the block (Hard Constraint 29) |
|
|
484
502
|
| Which fields the block *renders*, a second / conditional `q.select`, `enabled: false` on `useRecords` | No — the endpoint returns the union of the connection's read selects to anyone allowed to call it (see [multi-datasource.md](../datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)) |
|
|
485
503
|
|
|
486
504
|
The consequence to design around: **on a page any logged-in user may view, every datasource
|
|
@@ -531,7 +549,7 @@ verified on HubSpot on 2026-10-05, the same way.* There are two forms, and **the
|
|
|
531
549
|
pick it in a block's Source tab, save, and read `dataSources[].condition` back with
|
|
532
550
|
`vibe_coding_block_get_settings`. That is how the user-field form was found.
|
|
533
551
|
- **CONTAINS against a list of emails has a substring trap:** `bob@x.com` matches a field holding
|
|
534
|
-
`jbob@x.com`. Prefer IS against a single-email field. If a record must hold several emails, the
|
|
552
|
+
`jbob@x.com`. Prefer IS against a single-email field, and remember that an EMAIL-typed field can still hold a list ([../datasources/softr-database.md](../datasources/softr-database.md#gotchas)). If a record must hold several emails, the
|
|
535
553
|
delimiter trick the embedded form was meant to provide does not work, so accept the trap or
|
|
536
554
|
split the data.
|
|
537
555
|
- When a row gate must follow something other than the user's email (a company, a team), compare
|
|
@@ -539,6 +557,22 @@ verified on HubSpot on 2026-10-05, the same way.* There are two forms, and **the
|
|
|
539
557
|
untested (Softr Database so far), store an email on the record and compare it with
|
|
540
558
|
`{USER:::EMAIL}`. Staff who need every row get a group-gated block with unfiltered connections
|
|
541
559
|
([above](#what-the-server-enforces-on-a-blocks-data-endpoints)), not a wider condition.
|
|
560
|
+
- **A Softr Database recipe for "everyone named on the record, plus staff"** (verified 2026-09-18
|
|
561
|
+
in a production app):
|
|
562
|
+
- `CONTAINS` is a case-insensitive **substring** test. It works against a FORMULA text field and
|
|
563
|
+
against a LOOKUP of one (subject `type: "TEXT"`). So a formula that joins every email on the
|
|
564
|
+
record (lower-cased, comma-delimited) gates rows with `<formula> CONTAINS {USER:::EMAIL}`,
|
|
565
|
+
substring trap included (above).
|
|
566
|
+
- Related tables follow the parent through a LOOKUP of that formula, with the same condition on
|
|
567
|
+
the lookup.
|
|
568
|
+
- A LOOKUP that brings an email over a link field works with `IS_ONE_OF {USER:::EMAIL}`.
|
|
569
|
+
- There is no group token, so a **constant** formula listing the staff emails stands in for one:
|
|
570
|
+
`<access formula> CONTAINS {USER:::EMAIL}` OR `<staff formula> CONTAINS {USER:::EMAIL}`. This is
|
|
571
|
+
the OR widening warned about above, chosen on purpose. A staff-only connection carries the
|
|
572
|
+
second rule alone. Adding a staff member then takes two edits: the user group and the formula.
|
|
573
|
+
- The MCP cannot edit a formula after creation, so the staff list is changed in Studio.
|
|
574
|
+
- `vibe_coding_block_set_data_source_record_filters` takes one flat level: the rules joined by a
|
|
575
|
+
single AND or a single OR, no nested groups.
|
|
542
576
|
|
|
543
577
|
For HubSpot specifics (association-based scoping, owner fields), see
|
|
544
578
|
[../datasources/hubspot.md](../datasources/hubspot.md#row-scoping--who-sees-which-records).
|
|
@@ -565,6 +599,11 @@ From the official MCP docs — these hold for MCP-driven and Studio-driven edits
|
|
|
565
599
|
- **Changing the code resets action permissions.** Any code change rebuilds the block's record actions at default visibility — restrictions to user groups must be re-applied. (This is Hard Constraint 21 in SKILL.md, now officially documented: tighten Action permissions only after the LAST redeploy.) The defaults: ADD_RECORD follows the block's own visibility, while UPDATE_RECORD and DELETE_RECORD are reset to logged-in users (per Softr, 2026-10-01).
|
|
566
600
|
- **A block with an unconnected data source saves without complaint**, then errors when the page loads. If a freshly created block looks broken but the code seems right, check its data source connection first.
|
|
567
601
|
|
|
602
|
+
Ours, not from the docs: **Source conditions and Action permissions are not versioned.** The version
|
|
603
|
+
history cannot date a change to either (noted 2026-09-10). Whether restoring a version brings back an
|
|
604
|
+
older condition we have not tested. What a code push does and does not reset is in
|
|
605
|
+
[Verifying a push](#verifying-a-push--the-deployed-source-is-the-only-proof).
|
|
606
|
+
|
|
568
607
|
## Application management tools
|
|
569
608
|
|
|
570
609
|
The Applications area goes well beyond reads (roster as delivered 2026-10-01; behavior not individually exercised unless stated):
|
|
@@ -577,6 +616,8 @@ The Applications area goes well beyond reads (roster as delivered 2026-10-01; be
|
|
|
577
616
|
| Publish / preview | `application_preview`, `application_publish` |
|
|
578
617
|
| Workspace | `workspace_list`, `workspace_list_email_senders`, `get_workspace_integrations` (distinct from the [integrations drill-down](#browsing-integrations-external-data-sources) below) |
|
|
579
618
|
|
|
619
|
+
**Email senders, as the MCP shows them** (read 2026-09-10 and 2026-09-18): `workspace_list_email_senders` lists each workspace sender with a `confirmed` flag, and an address that was added but never verified reads `confirmed: false`. `application_get` showed the app's own sender as `<subdomain>@softr.app`. In a workflow, a `SOFTR_SEND_EMAIL` node chooses its sender through the optional `emailSenderSignatureId` input. Before publishing a workflow that must send from a particular address, check that the address is in the list and confirmed.
|
|
620
|
+
|
|
580
621
|
Combined with the database tools (`database_create` / `database_create_table` / `database_create_field`) and `vibe_coding_block_create` + `application_publish`, the tool set for scaffolding a full app end to end now exists. (Existence-verified only — that pipeline hasn't been run live; treat the first full scaffold as an experiment, not a routine.)
|
|
581
622
|
|
|
582
623
|
**Etiquette from the server's own instructions:** after changing a block, link the page as `https://studio.softr.io/applications/{applicationId}/pages/{pageId}`; offer `application_preview` or `application_publish`, but **only publish when the user asks**.
|
|
@@ -727,7 +768,18 @@ Known limits and behaviors (per official docs):
|
|
|
727
768
|
is no evidence that nothing changed.
|
|
728
769
|
- **Field descriptions are readable** since 2026-10-01 (per Softr). Before then a description
|
|
729
770
|
could be written but no read returned it.
|
|
730
|
-
-
|
|
771
|
+
- **`database_create_field` for a DATETIME takes `options: {"includeTime": true}`** (ours, verified
|
|
772
|
+
2026-09-18). The options shape `database_list_fields` returns for an existing DATETIME field is
|
|
773
|
+
rejected, so do not copy a field definition from a read into a create.
|
|
774
|
+
- **A formula cannot be changed after creation.** `database_update_field` with a new `formula` is
|
|
775
|
+
refused: `BAD_REQUEST` "[formula] can only be set when the field is created; delete the field and
|
|
776
|
+
create it again to change it" (seen 2026-09-18, under the old name `update_field`). Edit the formula
|
|
777
|
+
in Studio instead.
|
|
778
|
+
- **SINGLE_LINE_TEXT fields carry a 1,024-character `maxLength`** (ours: two fields of a production
|
|
779
|
+
table, found in a 2026-09-10 schema audit and confirmed live 2026-09-18, recorded in a block's code
|
|
780
|
+
comment). A block that appends to such a field has to keep the total under it; what a longer write
|
|
781
|
+
does was not tested. Use LONG_TEXT for anything that grows.
|
|
782
|
+
- Limits: 100 records per `database_create_records` call, 200 records per read (silently capped, not an error), 2 group-by fields in `database_aggregate_records`. For big tables prefer a filter or aggregate over paging. The read cap is per call, not a ceiling: `database_list_records` takes `offset`, and on 2026-09-01 `limit` 200 with `offset` 0 to 2,400 read a 2,549-row table in 13 calls, every record id unique, no gap or overlap (ours).
|
|
731
783
|
|
|
732
784
|
Typical Vibe Coding uses: "list every field on `Wigs` with id, name, type, and dropdown options", "what's the option id for `Payment status` = 'Partially paid'?", "show 3 sample records so we know value shapes", "verify the field id in my `q.select()` exists". This eliminates the field-id-typo / wrong-option-uuid class of bugs entirely.
|
|
733
785
|
|
|
@@ -755,7 +807,7 @@ those; see [the rename note](#tool-names--the-2026-10-01-rename).
|
|
|
755
807
|
|
|
756
808
|
See SKILL.md's NavigationAction action-types list.
|
|
757
809
|
- **Softr-native actions:** `BRANCH`, `FILTER`, `WAIT`, `LOOP_ACTION_GROUP` (run each list item through the same steps), `SOFTR_SEND_EMAIL`, `CALL_API` (REST), `WEBPAGE_SCRAPPER`, `PDF_TO_TEXT`, `COMPRESS_FILES` (zip + download link), `TRANSFORM_DATA`, `RESPONDED_TO_WEBHOOK` (custom HTTP response to the webhook caller); Softr DB record CRUD incl. bulk update/delete and find; Softr Apps user management (find / create / delete / deactivate / activate / invite user, send push notification).
|
|
758
|
-
- **`CUSTOM_CODE`:** runs custom **JavaScript or Python** inside a workflow.
|
|
810
|
+
- **`CUSTOM_CODE`:** runs custom **JavaScript or Python** inside a workflow. Its input and output contract (an object `inputData`, the result under `$.body`) is the `CUSTOM_CODE` bullet in the build-loop findings below.
|
|
759
811
|
- **AI actions:** Softr AI, OpenAI, Anthropic, Gemini, and Mistral each ship Write / Summarize / Categorize / Custom-prompt nodes; OpenAI adds gpt-image-2 image generation. Pinecone, Firecrawl, Replicate, and Linkup nodes exist too.
|
|
760
812
|
- **Integration apps (top of 56):** Stripe (36 nodes), QuickBooks (24), ActiveCampaign (23), SharePoint (22), Asana (18), Gmail/Attio/Brevo/Resend (12 each), ClickUp/Zendesk (10), Airtable/Notion/Cal.com/HubSpot/Xero/DocuSign/Apollo (9 each), Sheets/Excel (8), monday/SQL/Jira (7), Slack/Telegram (6), plus Salesforce, Coda, Calendly, Twilio, Zoom, Linear, Trello, form tools (Typeform/Tally/Jotform/Fillout), and more.
|
|
761
813
|
|
|
@@ -765,19 +817,49 @@ those; see [the rename note](#tool-names--the-2026-10-01-rename).
|
|
|
765
817
|
- **Test-first is mandated:** every testable node needs a test run before its outputs become referenceable by downstream nodes. Each node carries a `testRunMode` — `REAL_ONLY`, `MOCK_ONLY`, or `MOCK_AND_REAL` — so some nodes can only be tested against real side effects while others mock. See the test-safety rules under build-loop findings below before testing anything against a production workspace.
|
|
766
818
|
- **Workflows are owned by a workspace, and can now be pinned to an app** (verified 2026-10-05 from the tool definitions). `workflow_create` still requires a `workspaceId`; its optional `applicationId` "pins the workflow to it, so it is listed on that app's Workflows tab", and `workflow_list({ applicationId })` lists the workflows pinned to an app. Leave `applicationId` out for a workflow that belongs to the workspace as a whole. Pinning or not, `application_preview` / `application_publish` do not apply to workflows. Link a workflow as `https://studio.softr.io/workflow/{workflowId}`.
|
|
767
819
|
|
|
768
|
-
**Build-loop findings (verified live 2026-09-01, first end-to-end production build — 10 workflows):**
|
|
820
|
+
**Build-loop findings (verified live 2026-09-01, first end-to-end production build — 10 workflows; bullets dated later come from a second production build, 2026-09-18/19):**
|
|
769
821
|
|
|
770
|
-
- **`workflow_create` instantiates an OLD version of the trigger node.** Immediately call `workflow_replace_trigger_node` with the **same trigger type** — the replacement lands at the current version with the current inputs. Example: `updateField` on `SOFTR_TABLES_RECORD_UPDATED` (fire only when a
|
|
822
|
+
- **`workflow_create` instantiates an OLD version of the trigger node.** Immediately call `workflow_replace_trigger_node` with the **same trigger type** — the replacement lands at the current version with the current inputs. Example: `updateField` on `SOFTR_TABLES_RECORD_UPDATED` (fire only when a watched field changes; it takes an UPDATED_AT-type field, see "Trigger scope" below) only exists at v1.2.0; the version `workflow_create` instantiates doesn't have it. Do it before anything references the trigger: the replacement gets a **new node id** (see the `workflow_replace_node` bullet below).
|
|
771
823
|
- **A FILTER condition written over MCP is inert — set it in Studio** (corrected 2026-10-06; this bullet used to say the MCP writes it). `workflow_update_node_inputs` with inputName `"condition"` accepts an `{operator, conditions: [...]}` object and stores it in the node's `inputs.condition`, a field the engine does not read. The engine evaluates the condition on the FILTER node's **outgoing path** (the `paths` entry whose `fromActionId` is the filter), and only the Studio builder writes that. **A filter built over MCP passes every run.** A 2026-09-19 audit of this very build showed it three ways: the one workflow actually running had empty `inputs` and its whole condition on the path; two others, edited in Studio afterwards, held one condition in `inputs.condition` and a different one on the path, so Studio reads and writes only the path.
|
|
772
824
|
- **The official MCP docs agree:** "Branch and filter conditions can't be set through MCP yet ... deciding what sends a run down each path is something you finish in the builder" (docs.softr.io/mcp/workflows, checked 2026-10-05), and the FILTER spec declares `inputs: {}`. BRANCH conditions were not tested separately here; treat them the same way.
|
|
773
825
|
- **How to build one:** add the FILTER over MCP if that is convenient, then open it in Studio, set its clauses and save. Never trust `inputs.condition` as a record of what the filter does; it can disagree with the path.
|
|
774
826
|
- **How to check one:** read the workflow back with `workflow_get` and find the `paths` entry whose `fromActionId` is the filter; the condition must be there. FILTER nodes cannot be run with `workflow_test_node`, so this read-back is the only check before a real run.
|
|
775
827
|
- **`LOOP_ACTION_GROUP`'s `loopVariables.items` must reference a plain array**, e.g. `$.records` — a `[*]` projection (e.g. `$.records[*].fields.X`) is rejected by the validator. Per-item references **inside** the loop use `{loopActionGroup.<id>:::loopVariables.items.fields.<fieldId>}` (use the bracket form for ids that start with a digit).
|
|
828
|
+
- **Over a plain array of strings** (a `CUSTOM_CODE` node's `$.body.<key>`, say), the item *is* the value: reference it as bare `{loopActionGroup.<id>:::loopVariables.items}`, nothing after `items` (2026-09-19; accepted by the validator, not yet exercised by a run).
|
|
829
|
+
- **References into the loop are rejected until the source node's saved sample holds at least one item** (the validator says so). Test the source node on a record that yields a non-empty array before wiring the steps inside the loop.
|
|
830
|
+
- **To put a step inside the loop**, call `workflow_add_node` with `compositeNodeId: <loopNodeId>`. The step lands in the loop's own `actions` / `paths`, not the workflow's.
|
|
831
|
+
- **The loop has a `loopCounter` input**, `{ start, end, step, maxIterations }`. `maxIterations` can be set over MCP and reads back (2026-09-19). We have not run a loop long enough to reach the cap.
|
|
832
|
+
- **An empty loop does not stop the run** (recorded 2026-09-19). Zero items means zero iterations and a normal completion, and the steps after the loop still run. A guard stamp placed after a loop therefore fires even when nobody was emailed, and consumes the notice. A gate on the item count has to cover the loop and the stamp together; gating only the stamp leaves the guard unset, and the next edit sends again.
|
|
776
833
|
- **`workflow_update_node_inputs` batches validate against the STORED node state**, not the batch-in-progress — an update that depends on another update in the same batch fails validation. Split dependent updates into sequential calls.
|
|
834
|
+
- **`CUSTOM_CODE` contract** (2026-09-18/19; found by testing, documented nowhere we know of):
|
|
835
|
+
- `inputData` must be a JSON **object**, name → value. The `[{key, value}]` shape other `KEY_VALUE_MAP` inputs take fails the run with `script_args must be an object.`
|
|
836
|
+
- The code reads `inputData.<name>` and ends with a top-level `return { … }`; the body runs wrapped in a function.
|
|
837
|
+
- **The engine wraps what you return:** the node's output is `{ body: <returned object>, statusCode: 200 }`, so downstream references read **`$.body.<key>`**, never `$.<key>`.
|
|
838
|
+
- **The order of `inputData` keys is not kept.** The stored map came back reordered (2026-09-19). If the code must read some inputs after others, order them in the code (by key name, say), and assert on sets and counts in tests, not on order.
|
|
839
|
+
- **There is no native split / list / array-from-text step.** `workflow_list_node_types` has none, and `TRANSFORM_DATA` takes per-field formulas rather than producing a list. Turning a text field of comma-separated addresses into an array a loop can walk takes a `CUSTOM_CODE` node.
|
|
840
|
+
- Testing it: see the test-safety rules below.
|
|
841
|
+
- **Reference validation runs against saved samples** (2026-09-18/19). `workflow_update_node_inputs` checks every `{outputs.<nodeId>:::$.path}` reference against the referenced node's **saved test sample** and rejects a path that does not resolve there: `path "$.fields.<fieldId>.label" does not resolve against node "…"'s sample output`. A record trigger's sample is not yours to choose: re-running `workflow_test_node` on the trigger returns the same record every time. If that record has the field empty (a blank SELECT has no `.label`), the reference cannot be written over MCP at all. Studio's variable picker offers the path regardless of the sample, so add such a reference in Studio.
|
|
842
|
+
- **Pass link fields bare.** A `[*].label` projection on a link field (`$.fields.<linkId>[*].label`) does not resolve when the link is empty, and kills the node. Pass the bare field (`$.fields.<linkId>`) into a `CUSTOM_CODE` node and read the labels in code (2026-09-19).
|
|
843
|
+
- **`workflow_replace_node` swaps a node's type in place and gives it a new node id** (2026-09-19). The node keeps its place in the graph, which is how a step can be rebuilt as a different type without deleting anything, but every `{outputs.<old id>:::…}` reference to it now points at nothing. Before replacing a node, read the workflow with `workflow_get` and search every input for its id: a node whose output is interpolated into an email body would leave those values blank. `workflow_replace_trigger_node` does the same to the trigger (2026-09-18): new id, so every reference to the trigger has to be re-pointed and the trigger re-tested, which is why it belongs right after `workflow_create`. Both leave the discarded node's sample behind in `nodeSamples`. Nothing references it and it changes nothing, no tool removes it, and it is not evidence that the current node was ever tested.
|
|
844
|
+
- **A workflow's own record write re-fires its record-updated trigger** (seen on a production workflow, 2026-09-01). When the trigger watches the table's last-modified field, the workflow's final write is itself an edit. Two guard patterns, both used in our builds:
|
|
845
|
+
- **A send-once stamp:** a "…Sent" date field that the filter requires to be empty, written once at the end, outside any loop. It is the dedupe and the loop guard in one.
|
|
846
|
+
- **Clear the request in the same write:** when the trigger reacts to a request field, the write that acts on it also empties it (`CLEAR_VALUE`), so the re-fire finds nothing to do.
|
|
847
|
+
|
|
848
|
+
Never remove the guard, and never relax the filter to something that stays true after the write. The filter itself has to be set in Studio (see the FILTER bullet above).
|
|
849
|
+
- **Trigger scope** (2026-09-18):
|
|
850
|
+
- `SOFTR_TABLES_RECORD_UPDATED`'s optional `updateField` takes a field of type **UPDATED_AT** (a last-modified field), not any field. A table without one cannot set it, and the trigger then fires on every edit of every record. It is a plain select input: `workflow_get_dynamic_input_options` rejects it.
|
|
851
|
+
- **A workflow has exactly one trigger**, and "Record added" (`SOFTR_TABLES_NEW_RECORD`) and "Record updated" (`SOFTR_TABLES_RECORD_UPDATED`) are separate trigger types, so reacting to both takes two workflows. Whether creating a record also emits an "updated" event is unknown; make the pair idempotent so it does not matter.
|
|
852
|
+
- **Record-write field modes** (written over MCP 2026-09-18). Each field in a Softr Database record write carries a `modificationType`. `ADD_VALUE` adds to a multi-value field (multi-select, multi-link) and keeps what is there; `REPLACE_VALUE` overwrites the whole value, so on a multi-link it drops every earlier link; `CLEAR_VALUE` empties the field (sent with `value: ""`). Use `ADD_VALUE` to give a record one more link or option. These write steps have not run yet (testing one performs the real write), so how `ADD_VALUE` treats a value that is already present is unverified.
|
|
853
|
+
- **`serialExecution: true`** in a workflow's configuration (set with `workflow_update_configuration`, confirmed by read-back, 2026-09-18) makes runs queue instead of overlapping. We set it on a workflow that appends to a multi-link field: two runs at the same moment would each read-modify-write the same array, and one append could be lost.
|
|
854
|
+
- **`continueOnError` is stored on the path, not on the node** (2026-09-19). Turned on with `workflow_update_node_continue_on_error`, it shows up as a `SUCCEEDED OR FAILED` condition on the node's outgoing `paths` entry. Without it a failed step ends the run, so a guard stamp after it never happens and the whole notice goes out again on the next edit. On the **last step inside a loop** the entry has the condition but **no `toActionId`**, and whether the engine reads that as "carry on with the next item" is unproven. Test it with one deliberately bad item mid-list and check that the items after it still ran.
|
|
855
|
+
- **Time-based sends** (a proof of concept, 2026-09-01): a formula field flips (to `"yes"`, say) on the target day, a filtered view picks up the records where it has flipped, and a "Record enters view" trigger fires when one enters.
|
|
856
|
+
- **Each workflow has its own `configuration.timeZone`.** In one workspace every MCP-built workflow read `UTC` and an older one `Europe/Athens` (2026-09-19). Read it with `workflow_get` before relying on dates, times or a time-of-day window inside a workflow.
|
|
857
|
+
- **Read publication state from `workflow_get`.** A workflow that was never published reads `enabled: false` and `enabledVersion: null` (2026-09-01); the one running workflow in the same workspace read `enabledVersion: 0` (2026-09-19).
|
|
777
858
|
- **Test-safety rules** (which `testRunMode` means what in practice):
|
|
778
859
|
- Record-**write** nodes (`SOFTR_TABLES_UPDATE_RECORD` etc.) are `REAL_ONLY` — **never test them against a production workspace**; the test performs the real write.
|
|
779
860
|
- `SOFTR_SEND_EMAIL` is `MOCK_AND_REAL` — **always pass `mode: "mock"`**.
|
|
780
861
|
- Triggers and `GET_RECORDS` are `REAL_ONLY` but read-only-safe; a record-updated / enters-view trigger test just samples an existing record.
|
|
862
|
+
- `CUSTOM_CODE` is `REAL_ONLY` too, but a node whose code calls no integration and writes nothing is safe to test, and testing it is how you get the non-empty sample later references need (2026-09-19).
|
|
781
863
|
|
|
782
864
|
**Why this matters to block work:** Softr Workflows are now the Softr-native answer to the "block writes to its own table, backend cascades the rest" pattern — for **Softr Database backends** what [airtable-automations.md](airtable-automations.md) is for Airtable backends. See the cross-table alternatives in [../datasources/writing.md](../datasources/writing.md#cross-table-operations).
|
|
783
865
|
|