softr-vibe-coding 2.12.0 → 2.13.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 CHANGED
@@ -4,6 +4,19 @@ 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.13.1] - 2026-10-05
8
+ - Release 2.13.1
9
+ - Keep HubSpot association claims at their tested scope (second review round)
10
+ - Record HubSpot association writes: create with id strings, update with [{ id }] replaces the list (verified 2026-10-05)
11
+ - Fix three stale TOC anchors in helper-blocks.md
12
+ - Record that no read follows a write, and HubSpot's after-write changes (verified 2026-10-05)
13
+
14
+ ## [2.13.0] - 2026-10-05
15
+ - Release 2.13.0
16
+ - Record the USER::: user-field Source-condition token (verified 2026-10-05 on HubSpot)
17
+ - Block Visibility is enforced server-side on the records endpoints (verified 2026-10-05)
18
+ - Rewrite HubSpot guidance from the 2026-10-05 verification run
19
+
7
20
  ## [2.12.0] - 2026-10-01
8
21
  - Add references/browser-checks.md: checking a pushed block in a browser with agent-browser (verified 2026-10-01)
9
22
 
package/README.md CHANGED
@@ -263,7 +263,8 @@ softr-vibe-coding/
263
263
  │ # the from: parameter, getting the datasource UUIDs,
264
264
  │ # select: as a module-scope identifier, the union-of-
265
265
  │ # selects read payload (a conditional select is not
266
- │ # privacy), Actions per table (Sep 18 2026)
266
+ │ # privacy), Actions per table (Sep 18 2026);
267
+ │ # block Visibility gates its endpoints (Oct 5 2026)
267
268
  ├── reading.md # useRecords, filtering, sorting, pagination,
268
269
  │ # metrics, charts, current user; no detail-page
269
270
  │ # auto-scoping, useRecords ignores enabled:false,
@@ -276,7 +277,8 @@ softr-vibe-coding/
276
277
  ├── softr-database.md # Native DB — field IDs, no rate limits
277
278
  ├── airtable.md # Column names, PAT vs OAuth, rate limits
278
279
  ├── google-sheets.md # Text formatting, 50-100 user cap
279
- ├── hubspot.md # CRM objects, Sensitive Data Scopes
280
+ ├── hubspot.md # 15 objects (listed ≠ usable), field model,
281
+ │ # association writes, write behaviour, row scoping (Oct 5 2026)
280
282
  ├── notion.md # Database pages only, Relation workarounds
281
283
  ├── coda.md # API token auth, limitations
282
284
  ├── monday.md # API token, Connected Boards
@@ -323,7 +325,7 @@ The skill enforces these automatically, but good to know (verified live against
323
325
  - Data hook options must be **inline object literals** — `useRecords(opts)` with a variable or wrapper fails to compile
324
326
  - Create payloads are **flat**; update payloads are `{ recordId, fields: {...} }` — asymmetric by design
325
327
  - `mutateAsync` is fully supported — it's the tool for sequential multi-row saves
326
- - SELECT fields write by option **label string**; linked records write as arrays of record-id strings
328
+ - SELECT fields write by option **label string**; linked records write as arrays of record-id strings. HubSpot differs (verified Oct 2026): SELECTs take the choice id, and an association update needs `[{ id }]` objects; in the one test it replaced the ticket's whole company list (see `datasources/hubspot.md`)
327
329
  - Every code recompile resets the block's auto-registered Actions to default permissions — tighten permissions after the last redeploy, and note there is **no cosmetic-edit exemption**: an edit that changes only a comment resets them too. **Always read the permissions back to confirm** — a create action defaults to the block's own visibility, so on a block everyone can see it comes back publicly writable, and the MCP call that re-tightens it can fail with no fallback
328
330
  - **Blocks cannot import each other**, so two blocks that must look alike will drift — each one looks correct in isolation while the set does not. Repeated page chrome (back button, title, primary action) must sit at the same offset on every page, and a loading skeleton must track the REST state of whatever it stands in for
329
331
  - No `import React from 'react'` — use named imports (`import { useState } from "react"`)
package/SKILL.md CHANGED
@@ -85,7 +85,7 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
85
85
  - Sub-components (FieldLabel, TextInput, ChipButton, SectionCard, etc.) defined at **module scope**, NOT inside `Block()` -- prevents inputs losing focus after one keystroke (each render creates a new component identity, React unmounts/remounts the `<input>`)
86
86
  - When a custom DESIGN.md is in use, brand `fontFamily` (and any non-inherited brand defaults) set as an **inline style on the block's outermost wrapper** `<div>`, not relied on from `custom-code-header.html` -- Vibe Coding blocks render inside a shadow DOM and `html, body` rules don't cross that boundary. Per-element overrides (e.g. Fraunces serif on h1) still set inline at the element.
87
87
  - `fetchNextPage` never called in the render body — only from an event handler (Load More `onClick` with `disabled={isFetching}`, the official pattern) or a guarded `useEffect` (auto-load-all)
88
- - Mutations use `recordId` (not `id`) and call `refetch()` in `onSuccess`
88
+ - Mutations use `recordId` (not `id`) and call `refetch()` in `onSuccess` — no read follows a write otherwise (measured 2026-10-05 on a HubSpot-backed block). When the source changes other fields itself (HubSpot stamps a closing deal's close date and recalculates its probability a few seconds later), also read again a few seconds later — see [writing.md → Fields the source changes after the write](datasources/writing.md#fields-the-source-changes-after-the-write)
89
89
  - `useRecordUpdate` payload is `{ recordId, fields: { ... } }` — nested. `useRecordCreate` payload is **flat** (no `fields` wrapper). The two shapes are asymmetric by design (verified live 2026-08-25)
90
90
  - Sequential multi-row saves use `await hook.mutateAsync(...)` per row, in order, with stop-on-failure + retry state — `mutateAsync` is fully supported on the current platform (verified 2026-08-25; the old ".mutate() only" Action-parser rule is gone — see [datasources/writing.md](datasources/writing.md)). Independent writes to **different tables** may run in parallel via `Promise.all`; drag/reassign UIs should be optimistic with an Undo toast — see [writing.md → Parallel writes across tables](datasources/writing.md#parallel-writes-across-tables-the-one-sanctioned-parallelism)
91
91
  - No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
@@ -425,7 +425,11 @@ var askAi = useNavigationSetting({
425
425
  - `OPEN_CHAT` — opens Softr's AI chat. **No `destination` or `openIn` needed** — it's the cheapest "Ask AI" button to wire up. **GOTCHA:** Softr's AI pulls context from the block that triggered the chat, NOT from the page. If the block has no data source connected, `chat/prepare` returns HTTP 500 ("Failed to prepare AI assistant") even though the chat panel opens. Fix: in Softr Studio, connect the block to whatever data source the AI should read from — even if the block doesn't read or write any records itself, the connection is what gives the AI context. Verified by direct experiment, May 2026: a button-only helper block with no data source caused this exact failure; connecting it to the same table as the main block fixed it without any code change.
426
426
  - `OPEN_URL` — opens an external URL. Needs `destination` (the URL) + `openIn` (`"SELF"` | `"TAB"`).
427
427
  - `OPEN_PAGE` — navigates to a Softr page in-app. Needs `destination` (page path) + `openIn` (`"SELF"` | `"TAB"` | `"MODAL"`).
428
- - `TRIGGER_CUSTOM_WORKFLOW` — runs a Softr workflow. The documented setting shape is `{ action: "TRIGGER_CUSTOM_WORKFLOW" }` with no `destination` (same shape as `OPEN_CHAT`); the builder picks the workflow in the block's settings panel. Not verified live whether a `destination` is also accepted — don't rely on one. The workflow-side receiving end is the Softr Apps trigger **"Run Custom Workflow action triggered"** in the workflow node catalog (name-based match, not wired live) — workflows themselves are buildable via MCP; see [references/softr-mcp.md](references/softr-mcp.md#workflows).
428
+ - `TRIGGER_CUSTOM_WORKFLOW` — runs a Softr workflow.
429
+ - **Setting shape:** the documented shape is `{ action: "TRIGGER_CUSTOM_WORKFLOW" }` with no `destination`, the same shape as `OPEN_CHAT`. The type also carries an optional `actionId`. The builder picks which workflow it points at. Whether a `destination` is also accepted is not verified live, so don't rely on one.
430
+ - **Calling it from code:** besides `<NavigationAction>`, the live developer guide documents `navigate(setting, { recordId, datasourceId })` ("Runs the workflow with recordId and datasourceId"). It is callable from a mutation's `onSuccess`, e.g. to run a workflow on a record `useRecordCreate` just made (documented 2026-10-05; not yet run end to end by us).
431
+ - **The receiving end** is the Softr Apps trigger **"Run custom workflow"** (`SOFTR_APPS_TRIGGER_WORKFLOW`) in the workflow node catalog. The vibe path documents only `recordId` and `datasourceId` as payload, and the trigger is browser-callable, so have the workflow re-read the record rather than trust its input.
432
+ - Workflows themselves are buildable via MCP; see [references/softr-mcp.md](references/softr-mcp.md#workflows).
429
433
 
430
434
  When the action navigates to a record-specific page, pass the runtime record id via the `recordId` prop on `<NavigationAction>` (not on the setting) so Softr can resolve dynamic URLs:
431
435
 
@@ -597,10 +601,13 @@ Non-negotiable rules. Most are enforced by the Softr platform (compiler, validat
597
601
  endpoint is per block + connection and returns the UNION of every field named by any READ
598
602
  `q.select` on that connection, to every viewer. A second or ternary select "only for admins"
599
603
  hides nothing. Put a private field on a **second connection of the same table** (allowed) read
600
- only by a hook non-privileged browsers never run, or in a group-gated block. Page VIEW permission
601
- is enforced on these endpoints, but on a page any logged-in user may view, every connected
602
- datasource is readable by any logged-in user who crafts the request -- Source conditions are the
603
- only server-side ROW gate. Verified live 2026-09-18. See
604
+ only by a hook non-privileged browsers never run, or in a group-gated block. Two all-or-nothing
605
+ gates are enforced on these endpoints (list and by-id): page VIEW permission and the **block's own
606
+ Visibility** -- a viewer outside the block's user group gets 403 (verified 2026-10-05). So a block
607
+ gated to a staff group may read unfiltered connections for a staff view. The gate is per block:
608
+ the same table connected to an ungated block, on a page any logged-in user may view, is readable
609
+ by any logged-in user who crafts the request. Source conditions remain the only server-side ROW
610
+ gate. Verified live 2026-09-18 and 2026-10-05. See
604
611
  [datasources/multi-datasource.md](datasources/multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects).
605
612
  24. **Multi-datasource: `select:` is a plain module-scope identifier** -- a ternary
606
613
  (`select: a ? X : Y`) cannot be attributed to a connection and the query returns `fields: {}`, no
@@ -1,51 +1,534 @@
1
1
  # HubSpot
2
2
 
3
+ *Rewritten 2026-10-05 from a read-only verification run: five investigations and four adversarial
4
+ fact-checks against a HubSpot-connected demo app on an EU portal, plus Softr's and HubSpot's live
5
+ docs. Labels used below: **verified live** = observed that day through the Softr or HubSpot MCP,
6
+ in the browser's network log, or in a test block's own output in the preview; **documented** =
7
+ Softr or HubSpot docs say so; **inferred** = reasoned, not observed; **unverified** = nobody has
8
+ tested it. That run made no writes. Write tests followed that day, all in Studio preview
9
+ ("Preview as": the builder's session acting as the chosen user, not a real login). In the
10
+ morning a test block created a ticket with company and contact links and then re-linked it. In
11
+ the afternoon the Accounts block changed the stage of that ticket and of one test deal, and a
12
+ client's replay of one of those requests was refused. On the write side, only what those tests
13
+ covered is verified live.*
14
+
3
15
  ## Overview
4
- CRM platform used as a data source for Softr. Requires a Business or Enterprise plan in Softr. Connects via OAuth.
16
+
17
+ CRM used as a Softr data source, connected by OAuth.
18
+
19
+ - **Softr plan:** Business or Enterprise (documented).
20
+ - **Connecting needs a HubSpot Super Admin** (documented).
21
+ - **HubSpot tier, for what the integration itself reads and writes:** contacts, companies, deals,
22
+ tasks and tickets work on HubSpot Free (documented, HubSpot's product catalog). Custom objects need
23
+ HubSpot Enterprise (documented). HubSpot's *own* workflows need Professional or higher, and
24
+ ticket-based workflows need Service Hub Professional or Enterprise (documented). A Softr workflow
25
+ does not need either ([Workflows](#workflows-softr-workflows-with-hubspot)).
5
26
 
6
27
  ## Connection Setup
7
- 1. In Softr admin, go to Data Sources and select HubSpot.
8
- 2. Authenticate via OAuth with your HubSpot account.
9
- 3. Select the HubSpot object type to connect (Contacts, Companies, Deals, etc.).
10
- 4. If accessing sensitive fields, ensure Sensitive Data Scopes are enabled in your HubSpot app settings.
11
-
12
- ## Vibe Coding Field IDs
13
- Use the Field Inspector block to determine exact field IDs for HubSpot properties. Default HubSpot properties use their internal API names (e.g., `"firstname"`, `"dealstage"`, `"company"`). Custom properties use the internal name assigned by HubSpot when created.
14
-
15
- ## Supported Fields
16
-
17
- **Supported HubSpot Objects:**
18
- - Contacts
19
- - Companies
20
- - Deals
21
- - Tickets
22
- - Notes
23
- - Tasks
24
- - Custom Objects
25
-
26
- | Field Type | Writable | Notes |
27
- |----------------------|-----------|-------|
28
- | Default Properties | Yes | All standard HubSpot properties for the connected object |
29
- | Custom Properties | Yes | Custom properties created in HubSpot |
30
- | Associations | Read-only | Relational data between objects. Displayed via Linked List Blocks in Softr. |
31
- | Calculation | Read-only | |
32
- | Rollup | Read-only | |
33
- | Count | Read-only | |
34
- | Formula | Read-only | |
35
-
36
- ## Rate Limits
37
- HubSpot enforces its own API rate limits based on your HubSpot plan tier. Softr requests count against your HubSpot API quota. Refer to HubSpot's documentation for current limits on your plan.
28
+
29
+ 1. In Softr Studio, go to Data Sources and select HubSpot.
30
+ 2. Authenticate via OAuth as a HubSpot Super Admin.
31
+ 3. Pick the HubSpot object to connect (see [Supported objects](#supported-objects)).
32
+ 4. Some HubSpot fields and objects need **Sensitive Data scopes** for API access (documented). If a
33
+ field you expect comes back empty or missing, check those scopes first.
34
+
35
+ An object appears only if HubSpot grants Softr access to it. Reconnecting restores one that is
36
+ missing (documented).
37
+
38
+ **Through the MCP** (verified live 2026-10-05): `integration_list_databases` on a HubSpot integration
39
+ returns the object ids. For HubSpot the `databaseId`, the `tableId` and the `tableName` are all that
40
+ same object id (`tickets`, `line_items`, …), so pass the object id three times to
41
+ `integration_list_table_fields` and `vibe_coding_block_connect_data_source`.
42
+
43
+ ## Supported objects
44
+
45
+ Softr's docs list **15 objects** since softr-public-documentation PR #127 (merged 2026-09-24; the
46
+ list had 7 before). The live integration returned **14 object ids**, i.e. all except Custom Objects,
47
+ which that portal did not have (verified live 2026-10-05).
48
+
49
+ **Listed does not mean usable, and usable does not mean writable.** Softr publishes no per-object
50
+ write matrix, and vibe-block writes have been tested on tickets and deals only (2026-10-05).
51
+
52
+ | Object | Id | Primary field | What to know |
53
+ |---|---|---|---|
54
+ | Contacts | `contacts` | `hs_full_name_or_email` | HubSpot Free. The natural users table. Has no `associatedcompanyid` in Softr (see [Field model](#field-model)) |
55
+ | Companies | `companies` | `name` | HubSpot Free |
56
+ | Deals | `deals` | `dealname` | HubSpot Free. Stage ids are portal-specific |
57
+ | Tickets | `tickets` | `subject` | HubSpot Free. A create needs `subject` and `hs_pipeline_stage` ([Writing](#writing)) |
58
+ | Tasks | `tasks` | `hs_task_subject` | HubSpot Free |
59
+ | Notes | `notes` | `hs_body_preview` | A create needs `hs_timestamp`. HubSpot says a note should be associated with at least one record. Links can be written on a ticket create ([Association writes](#association-writes)); a note create with links is untested |
60
+ | Leads, Listings, Appointments | `leads`, `listings`, `appointments` | — | Listed live; not examined in the 2026-10-05 run |
61
+ | Projects | `projects` | `hs_name` | **Must be activated in HubSpot by a Super Admin** (Data Management > Data Model; documented). On a portal where it apparently wasn't, Softr still listed the object, but its `hs_pipeline` / `hs_pipeline_stage` had **zero choices** (verified live). HubSpot requires both on create (documented), so no create can succeed there |
62
+ | Products | `products` | `name` | Catalog entries. Links go only to deals and to other products, none to companies or contacts |
63
+ | Line Items | `line_items` | `name` | **Need a parent object** (deal, quote, subscription, invoice or payment link; documented). No links to companies or contacts |
64
+ | Invoices | `invoices` | `hs_number` | **Need Commerce Hub** (documented). A draft needs only `hs_currency`; moving one to Open needs an associated contact and at least one line item (HubSpot API docs) |
65
+ | Subscriptions | `subscriptions` | `hs_name` | **Need Commerce Hub. Treat as read-only:** HubSpot's API needs HubSpot payments or Stripe for writes, and it cannot set associations at all (documented) |
66
+ | Custom Objects | — | — | **Need HubSpot Enterprise** (documented). Appear only when they exist in the portal |
67
+
68
+ Softr's Workflows HubSpot page says Add / Update / Delete work "across every HubSpot object". That is
69
+ a marketing line. HubSpot's own API rules above contradict it for subscriptions, and for projects
70
+ until activation. Don't quote it to a client as a write guarantee.
71
+
72
+ **Native blocks vs vibe blocks.** Softr's HubSpot page says "You can connect only one object type to
73
+ one block". That rule is for native blocks. A vibe block connects several objects with
74
+ `datasource.define` plus `from:` on every hook (see
75
+ [multi-datasource.md](multi-datasource.md)).
76
+
77
+ ## Field model
78
+
79
+ *Verified live 2026-10-05 via `integration_list_table_fields` and `vibe_coding_block_get_settings`,
80
+ unless marked otherwise.*
81
+
82
+ - **`fieldReferenceKey` is `"id"`.** `q.select()` uses HubSpot internal property names (`firstname`,
83
+ `dealstage`, `hs_pipeline_stage`). Custom properties use the internal name HubSpot gave them.
84
+ - **Associations are `LINKED_RECORD` fields named `associations.<x>`**, with
85
+ `options.linkedTableId` set to the target object. Examples:
86
+ - deals: `associations.company` ("Associated Company"), `associations.contact`,
87
+ `associations.deal_to_company` ("Associated Primary Company")
88
+ - tickets: `associations.company`, `associations.contact`, `associations.ticket_to_company`
89
+ ("Associated Primary Company"), `associations.deal`. Tickets have 13 in all.
90
+ - companies: `associations.company_to_deal`, `associations.company_to_ticket`,
91
+ `associations.company_to_contact` (the "with Primary Company" links), 20 in all.
92
+
93
+ Read them as you would any linked field: an association can arrive as a single `{ id, label }`
94
+ object as well as an array of them, so normalise before you `.map()` (see
95
+ [reading.md](reading.md#filtering-by-a-linked-record-server-side)). In a vibe hook (verified
96
+ live 2026-10-05 on tickets), `associations.company` and `associations.contact` read as arrays
97
+ of `{ id, label }`, usually with the record's name as the label (one re-read shortly after a
98
+ link update showed the company's id instead; see [Association writes](#association-writes)).
99
+ The primary-company link `associations.ticket_to_company` read as one `{ id, label }` object,
100
+ or `null` (seen once, in a re-read shortly after a link update, while a company was still
101
+ linked; HubSpot's primary label was not checked). A Softr workflow's Find record output showed
102
+ the single-object case too.
103
+ - **SELECT fields carry id→label choices** in `options.choices`, and HubSpot ids are not labels.
104
+ - **Deals:** stage ids are portal-specific. The live portal had `6183367908` = Initial Contact
105
+ … `closedwon` = Closed Won, in pipeline `default` ("Sales Pipeline").
106
+ - **Tickets:** they use `hs_pipeline_stage`, which held `1` New, `2` Waiting on contact,
107
+ `3` Waiting on us and `4` Closed, all in pipeline `0` ("Support Pipeline"). That is a fresh
108
+ portal's default, so read the choices from the schema rather than hardcoding them.
109
+ - **Owners:** `hubspot_owner_id` choices are numeric HubSpot owner ids, labelled
110
+ "Name (email)".
111
+ - **`hubspot_owner_email` and `hubspot_owner_name` are fields Softr derives, not HubSpot
112
+ properties.** HubSpot returns them as `propertiesNotFound`, yet they appear as SELECT fields on
113
+ every object. Filtering on them is untested, because HubSpot's search has no such property to
114
+ filter on. Filter and write ownership through **`hubspot_owner_id`**, which is the real property.
115
+ - **Softr exposes only a subset of HubSpot's properties.** Tickets showed 36 of about 122 HubSpot
116
+ properties, plus the 2 derived owner fields; invoices showed 26 of about 94. Missing on tickets
117
+ are `hs_all_associated_contact_emails`, `hs_v2_date_entered_*`, `hs_resolution`, `hs_ticket_id`,
118
+ `hs_tag_ids` and `hs_object_source_label`. Softr's docs say "All default HubSpot object
119
+ properties" are supported; the live schema contradicts that. **Custom properties are exposed:**
120
+ the portal's custom ticket properties (`issue_category`, `issue_severity`) were in the schema.
121
+ Before designing around a property, confirm it is in the field list.
122
+ - **Field metadata has no read-only flag.** Each field carries only `id`, `name`, `type` and
123
+ `options`. HubSpot-computed properties (`hs_object_id`, `createdate`, `hs_lastmodifieddate`,
124
+ `num_associated_*`) look exactly like writable ones.
125
+ - **Contacts have no `associatedcompanyid` in Softr**, and HubSpot also returned it as not found. A
126
+ contact reaches its company only through `associations.company` /
127
+ `associations.contact_to_company`, or through the free-text `company` property.
128
+ - `hs_file_upload` on tickets is an ATTACHMENT in Softr but a plain string property in HubSpot.
129
+ Writing it is untested.
130
+
131
+ ## Writing
132
+
133
+ | Field kind | Writable? | Evidence |
134
+ |---|---|---|
135
+ | Default properties | Yes | **Verified live** 2026-10-05: `dealstage` and `hs_pipeline_stage` with `useRecordUpdate`, and `subject`, `content`, `hs_pipeline` and `hs_pipeline_stage` on a ticket create with `useRecordCreate`; the rest documented (Softr's HubSpot page) |
136
+ | Custom properties | Yes | Documented; untested from a vibe block |
137
+ | Softr computed fields (Calculation, Rollup, Count, Formula) | Read-only | Documented. These are the **only** fields Softr's page lists as read-only |
138
+ | HubSpot-computed properties (`hs_object_id`, `createdate`, `hs_lastmodifieddate`, `num_associated_*`) | Presumably not | Inferred from HubSpot; nothing in Softr's metadata says so |
139
+ | Associations (`associations.*`) | Yes for a ticket's company and contact links; an update replaced the company list | **Verified live** 2026-10-05 on one ticket's `associations.company` and `associations.contact` ([below](#association-writes)); other links, including the primary company, untested |
140
+ | `hubspot_owner_email` / `hubspot_owner_name` | Don't | Softr-derived; write `hubspot_owner_id` instead (inferred) |
141
+
142
+ - **Ticket create:** HubSpot requires `subject` and `hs_pipeline_stage`. `hs_pipeline` is optional
143
+ when the portal has one ticket pipeline, because the default is used (documented, HubSpot API).
144
+ **Note create:** `hs_timestamp` is required (documented).
145
+ - **Write a SELECT by its choice id** (verified live 2026-10-05). `fields: { stage: "closedwon" }`
146
+ on `dealstage` and `fields: { status: "1" }` on `hs_pipeline_stage` both saved, and so did a
147
+ ticket create with `hs_pipeline: "0"` and `hs_pipeline_stage: "1"`. The id and the
148
+ label differ (`'1'` vs `'New'`, `'6183367908'` vs `'Initial Contact'`). Softr Database is the
149
+ other way round: there the **label** is written
150
+ ([writing.md](writing.md#dropdown--single-select-softr-database)). Whether HubSpot also accepts
151
+ a label is untested.
152
+ - **What goes over the wire** (verified live 2026-10-05, ticket stage write):
153
+ - The write is `PATCH …/blocks/<block>/datasources/<connection id>/records-trigger/<recordId>`
154
+ with the body `{"context":{…},"fields":{"hs_pipeline_stage":"1"}}`. Fields are keyed by
155
+ HubSpot property id, and the value is a plain string.
156
+ - The response holds only the written field, in its read shape:
157
+ `{"record":{"id":"…","fields":{"hs_pipeline_stage":{"id":"1","label":"New"}}},"triggerResponse":null}`.
158
+ - The connection id in the write URL is internal: the deals connection's id changed when the
159
+ block was recompiled. Reads use the alias (`…/datasources/tickets/records`). This only
160
+ matters when reading a network log.
161
+ - **The write endpoint enforces visibility** (verified live 2026-10-05). A client who replayed an
162
+ account manager's PATCH got 403. The block and its actions were both limited to account
163
+ managers, so the test did not show which of the two rules refused it. Every code push resets
164
+ action visibility (Hard Constraint 21 in SKILL.md), so re-lock it and read it back after each push.
165
+
166
+ ### What HubSpot changes after a write
167
+
168
+ *Verified live 2026-10-05 on one test ticket and one test deal. The writes came from a vibe
169
+ block in preview; the results were read back through the HubSpot MCP and the block's own list
170
+ reads.*
171
+
172
+ - **A deal's close date is overwritten when it closes, won or lost.** Entering `closedwon` or
173
+ `closedlost` sets `closedate` to the moment of the write: `closedwon` replaced a planned date
174
+ four months away, and `closedlost` later did the same. Reopening the deal, or undoing the
175
+ change, kept the new date; the old one had to be restored by hand. Say so in the confirm step
176
+ before a block closes a deal.
177
+ - **A ticket's close date is cleared when it reopens.** Entering the closed status set
178
+ `closed_date` and `time_to_close`. Moving the ticket back to an open status cleared both.
179
+ - **Probability and weighted amount lag behind the stage.** HubSpot recalculates
180
+ `hs_deal_stage_probability` and `hs_projected_amount` after the write (table below).
181
+ - **HubSpot modifies a ticket again about 10 s after a stage write.** `hs_lastmodifieddate`
182
+ moved again 9 to 11 s after each of the three stage writes where it was checked.
183
+ - **Stage writes leave associations alone.** They do leave a permanent stage history
184
+ (`hs_v2_date_entered_*` / `hs_v2_date_exited_*`), even when the value is put back.
185
+
186
+ What the block's own list reads returned for the deal (Closed Lost, then Undo 5 s later):
187
+
188
+ | Read of `deals` | `dealstage`, `closedate` | `hs_deal_stage_probability`, `hs_projected_amount` |
189
+ |---|---|---|
190
+ | +4 s after Closed Lost | `closedlost`, the new date | 0.1 and 1,980: the values from before the write |
191
+ | +4 s after the Undo | Initial Contact, the new date kept | 0 and 0: Closed Lost's values |
192
+ | +12 s after the Undo | Initial Contact, the new date kept | 0.1 and 1,980: correct |
193
+
194
+ So the stage and the close date were readable within 4 s, while the computed fields were still
195
+ one write behind at that point. No read follows a write unless the block asks. A block that
196
+ shows any of these fields should, on top of the `refetch()` in `onSuccess`, read the table again
197
+ about 4 s and 12 s after each successful save
198
+ ([pattern](writing.md#fields-the-source-changes-after-the-write)). A read right after the write
199
+ was not measured.
200
+
201
+ ### Association writes
202
+
203
+ *Verified live 2026-10-05 from a test block in Studio preview, "Preview as" Softr's built-in Test
204
+ User (the builder's session acting as that user, who had no HubSpot record): one ticket, one
205
+ company and one contact. HubSpot's MCP confirmed the links, the company count and the source;
206
+ the primary-company link and the link labels were seen only in the block's own reads. Not
207
+ tested: a create with `[{ id }]` objects, objects other than tickets, writing the primary-company
208
+ link directly, and a real client login on the published app.*
209
+
210
+ To Softr's action parser the association fields are ordinary fields. With `associations.company`
211
+ and `associations.contact` in the hooks' `fields:` selects, the derived ADD_RECORD and
212
+ UPDATE_RECORD actions listed both ("Associated Company", "Associated Contact"), and both hooks
213
+ were enabled.
214
+
215
+ **Create: link with an array of id strings.** This worked:
216
+
217
+ ```tsx
218
+ const ticketCreateFields = q.select({
219
+ subject: "subject",
220
+ pipeline: "hs_pipeline",
221
+ stage: "hs_pipeline_stage",
222
+ company: "associations.company",
223
+ contact: "associations.contact",
224
+ });
225
+
226
+ // Inside the block: const createTicket = useRecordCreate({ from: ds.tickets, fields: ticketCreateFields });
227
+ await createTicket.mutateAsync({
228
+ subject,
229
+ pipeline: "0", // choice ids, not labels
230
+ stage: "1",
231
+ company: [companyId], // an array of id strings, as on Softr Database
232
+ contact: [contactId],
233
+ });
234
+ ```
235
+
236
+ - **The ticket was linked to the written company and contact** (confirmed in HubSpot). In Softr's
237
+ read, the written company was also the primary company (`associations.ticket_to_company`);
238
+ whether Softr's write set that or HubSpot did is not known.
239
+ - **An extra company link appeared.** The ticket was also linked to the contact's other company,
240
+ which was also that contact's primary company. The block wrote only the one company, so HubSpot
241
+ most likely added it (inferred), perhaps through a portal setting (unchecked). Whether it adds
242
+ every company of the contact or only the primary one is open. Expect extra company links on a
243
+ ticket created with a contact (seen once).
244
+ - **Link labels are not always names.** The create's result labelled both links with their ids
245
+ (`{ "id": "450812074179", "label": "450812074179" }`), and the update's result did the same for
246
+ the company. A re-read after the create had the names. A re-read about 15 s after the update
247
+ still labelled the company with its id while the contact had its name; a later read had the
248
+ name. When a label equals its id, take the name from the companies or contacts the block
249
+ already reads, or show the id.
250
+ - **HubSpot records the source** as `INTEGRATION`, detail "Softr".
251
+
252
+ **Update: `[{ id }]` objects, and the update replaced the company list.**
253
+
254
+ - `useRecordUpdate` with `company` and `contact` both as `["<id>"]` failed with **500**
255
+ (`Failed to update record: 500`) and, in the block's re-read, changed nothing, although the
256
+ create had accepted that shape.
257
+ - `company: [{ id }]` and `contact: [{ id }]` worked, and the company list was **replaced**: the
258
+ extra company was removed (HubSpot's company count went from 2 to 1). In the block's re-read
259
+ about 15 s later, `associations.ticket_to_company` was `null` although the written company stayed
260
+ linked, so expect the primary-company link to be lost (not checked in HubSpot). The contact list
261
+ held only the written contact before and after, so whether contact links are replaced too could
262
+ not be seen (likely; inferred).
263
+ - So, with the two shapes tried, an update could not add one link on its own: `["<id>"]` failed
264
+ and `[{ id }]` replaced the list. A block can write the whole list back with the new id added
265
+ (untested; it may lose the primary label, as the test's update did), or leave it to the
266
+ workflow route below.
267
+ - An update whose `fields:` select held no association fields left the links alone: later stage
268
+ writes on this ticket came from another block whose update select held only the stage, and the
269
+ ticket kept its company. Whether leaving them out of the payload is enough when they are in the
270
+ select is untested, so keep association fields out of any update select that is not meant to
271
+ write them.
272
+
273
+ **Nothing checked the link ids.** The create linked the ticket to a company and a contact that had
274
+ nothing to do with the Test User who sent it. In this test the tickets connection had no Source
275
+ condition and the create action had no record condition. Whether a Source condition, an action's
276
+ record condition or a real client session checks the links on a create or an update is untested.
277
+ The request comes from the browser, so treat link ids as client input: a client could probably
278
+ link a new ticket to another company, and that company's portal would then show it (inferred).
279
+ For client-facing creates, set or check the links server-side with the workflow route below.
280
+
281
+ *History: this page called association writes "read-only" until 2026-10-05 (no source), then
282
+ "unverified" until the test above.*
283
+
284
+ **Workflow route (documented; not run in these tests): a Softr workflow calls HubSpot's
285
+ associations API.** Use it to add a link without replacing the list, to set the primary label, or
286
+ to set a client's links server-side.
287
+
288
+ - **The step:** a **Run custom code** step (`CUSTOM_CODE` v1.2.0) with the HubSpot integration
289
+ attached. `fetch()` calls to `api.hubapi.com` then carry that integration's credentials, with no
290
+ token in the code.
291
+ - **Plan and testing:** the step needs a paid Softr plan, and it is `REAL_ONLY`, so a test run
292
+ writes for real.
293
+ - **Limits:** about 2 minutes per run, and up to 20 fetches per second.
294
+ - **The HubSpot calls** (associations v4):
295
+ - Unlabeled: `PUT /crm/objects/2026-09/ticket/{ticketId}/associations/default/company/{companyId}`,
296
+ and the same pattern for `contact`.
297
+ - Labeled, e.g. primary company: `PUT /crm/objects/2026-09/ticket/{ticketId}/associations/company/{companyId}`
298
+ with body `[{ "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 26 }]`.
299
+ - Type ids: ticket→contact **16**, ticket→company **339**, ticket→primary company **26**.
300
+ - `DELETE` on the same path unlinks.
301
+ - HubSpot's ticket-create endpoint also accepts an `associations` array, so one step can create
302
+ the ticket already linked.
303
+ - **Starting it from the block:** call `navigate(setting, { recordId, datasourceId })` on a
304
+ `TRIGGER_CUSTOM_WORKFLOW` setting inside `useRecordCreate`'s `onSuccess` (see SKILL.md's
305
+ NavigationAction section). The vibe path documents only `recordId` and `datasourceId` as payload.
306
+ The trigger is a browser-callable endpoint, so the workflow should re-read the ticket from HubSpot
307
+ rather than trust what it receives.
308
+ - **No block wiring:** a `HUBSPOT_RECORD_CREATED` trigger on tickets plus the same custom-code step
309
+ can link each new ticket by a requester-email property. This also catches tickets created in
310
+ HubSpot itself. The cost is trigger latency (unknown, see below) and a run for every new ticket.
311
+ - **Why the block can't do it itself:** `useProxyFetch` is documented for REST API sources only.
312
+ Call API authenticates only REST_API integrations and needs Professional or higher, so it would
313
+ need a HubSpot private-app token stored as a REST integration (inferred).
314
+ - **HubSpot-side route:** a HubSpot workflow's "Create associations" action needs Pro or
315
+ Enterprise, and ticket-based workflows need Service Hub Pro or Enterprise (documented). It
316
+ matches records by exact, case-sensitive property value.
317
+
318
+ ## Row scoping — who sees which records
319
+
320
+ A connection's **Source conditions are the only server-side row gate**. A `where` in block code is a
321
+ request parameter the caller controls, not access control. See
322
+ [../references/softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints)
323
+ (verified 2026-09-18 on Softr Database; on HubSpot 2026-10-05, below).
324
+
325
+ The **block's Visibility** is enforced on the same endpoints, all or nothing (verified 2026-10-05 on
326
+ HubSpot). A block gated to an "Account managers" condition group returned every deal and ticket to
327
+ members from connections with no Source condition, and 403 on list and by-id to clients, a
328
+ Softr-only user and logged-out visitors. So a staff view can be a group-gated block with
329
+ unfiltered connections. The same tables on an ungated block are open to anyone who may view the
330
+ page ([details](multi-datasource.md#one-connection--one-read-payload-the-union-of-its-selects)).
331
+
332
+ - **Scope clients by company with the user's own field** (verified 2026-10-05). A Source
333
+ condition `associations.company IS_ONE_OF ["USER:::associations.company"]`, logical operator
334
+ AND, on the deals and the tickets connections gave each client only their own companies'
335
+ records: Dana 1 deal and 1 ticket, Tom 1 deal. The users table is the contacts table, so
336
+ `USER:::associations.company` is the logged-in contact's own company association. The token is
337
+ `USER:::<user field id>`, **no braces**, as the entire value. It was set in Studio's Source tab
338
+ and over MCP alike ([details](../references/softr-mcp.md#logged-in-user-values-in-source-conditions)).
339
+ - **It fails closed.** A synced contact with no company, a Softr-only user and account managers
340
+ without a company all got 0 rows. By-id reads of other companies' records returned 404.
341
+ - **So staff need their own block**, gated to their group, with unfiltered connections (above).
342
+ Widening the client condition for them would widen it for everyone.
343
+ - **Use AND.** With one rule OR and AND behave the same, but a second rule added under OR widens
344
+ access.
345
+ - **The email token is different: `{USER:::EMAIL}`, with braces**, as the entire value. It is
346
+ verified on Softr Database and untested on HubSpot. For any other user field, pick it once in the
347
+ block's Source tab and read `dataSources[].condition` back with `vibe_coding_block_get_settings`.
348
+ - **Community evidence for association scoping in native blocks.** In
349
+ [community.softr.io/t/hubspot-conditional-filter/10572](https://community.softr.io/t/hubspot-conditional-filter/10572)
350
+ (September 2024), a native filter "ticket's Associated Company ID = logged-in user's Associated
351
+ Company ID" worked after a Softr fix. It used an older field model; `associatedcompanyid` is no
352
+ longer on Softr's contacts. Softr's HubSpot page also says "You can also use associated objects
353
+ in Visibility Conditional Filters". The vibe-block version above is the verified one.
354
+ - **Fallback: put the user's email on the records**, for scoping that no user field can express. Add
355
+ a custom text property, e.g. "Portal requester email" on tickets or "Account manager email" on
356
+ companies and deals. Write it when the record is created, and compare it with `{USER:::EMAIL}`
357
+ using **IS**. If the property holds a list of emails and you use CONTAINS, mind the substring
358
+ trap: `bob@x.com` also matches `jbob@x.com` (verified 2026-09-18 on Softr Database). The value is
359
+ written by the browser, so a determined user could tamper with it on create. That is acceptable
360
+ for scoping a demo; production needs a server-side source of identity. The user's company
361
+ association above lives in HubSpot instead, out of reach of a block with no contact write action.
362
+ Association fields can be written like other fields ([Association writes](#association-writes),
363
+ verified on tickets), so a contacts write action a client can reach could let them rewrite their
364
+ own company links and widen their own scope (inferred; contact association writes untested).
365
+ Keep `associations.company` out of any such action.
366
+ - **Writes are a separate question.** Whether a Source condition limits what a create or an
367
+ update may write, such as the link ids on a new ticket, is untested. A create on a connection
368
+ without one, run in preview, accepted links to a company and a contact unrelated to the user
369
+ ([Association writes](#association-writes)).
370
+ - **Owner scoping.** `hubspot_owner_id` is the real property, but on a contact it names that
371
+ contact's owner, not the contact. Scoping "my deals" for an account manager would need a custom
372
+ contact property holding their own owner id, read through `USER:::<field id>` (untested). For a
373
+ team-wide staff view, a group-gated block with unfiltered connections needs no owner filter at
374
+ all. Owners are also HubSpot users, i.e. HubSpot seats.
375
+
376
+ **Filter limits** ([docs.softr.io/troubleshooting/troubleshooting-hubspot-errors](https://docs.softr.io/troubleshooting/troubleshooting-hubspot-errors)):
377
+
378
+ - **Softr's numbers:** 5 groups × 6 filters, 18 filters per block in total, and only **4 AND
379
+ filters on a block with an Edit button**.
380
+ - **What counts:** conditional filters, inline search and filter, and action-visibility filters
381
+ (documented). A code `where` presumably lands in the same HubSpot search request (inferred).
382
+ - **AND and OR are swapped on Softr's page.** It calls the groups "AND" and the filters inside them
383
+ "OR". HubSpot's search API is the other way round: filters inside a group are ANDed and groups are
384
+ ORed, with the same 5 / 6 / 18 caps (documented).
385
+ - **HubSpot search also caps:** a query returns at most 10,000 results (documented). Associations
386
+ are filtered through the `associations.{objectType}` pseudo-property, which does not cover
387
+ custom-object associations (documented).
388
+ - **The MCP's record-filter tool** takes one flat AND/OR list per connection, and each value is an
389
+ array of strings. ARRAY fields allow `IS`, `IS_ONE_OF`, `IS_NONE_OF`, `HAS_ALL_OF`, `IS_EMPTY` and
390
+ `IS_NOT_EMPTY`, with no `CONTAINS` (verified 2026-10-05 from the tool's description).
391
+
392
+ ## Rate limits
393
+
394
+ - **110 requests per 10 seconds per HubSpot account** for apps distributed through the HubSpot
395
+ Marketplace. The search API is counted separately, and HubSpot's API-limit add-on does not raise
396
+ this (documented). That Softr's connection counts as such an app is **inferred**: Softr has a
397
+ Marketplace listing.
398
+ - **The CRM search API allows 5 requests per second per account** (documented). How Softr turns
399
+ hook calls into HubSpot calls is not documented. If list reads go through search, several
400
+ HubSpot connections on one page, times concurrent users, can reach that limit (inferred).
401
+ - **New and updated records take "a few moments" to appear in search results** (documented). A
402
+ `refetch()` right after a mutation may therefore still return the old value (inferred, not
403
+ measured). A list read at +4 s had the written stage, while HubSpot's computed fields were
404
+ still stale at +4 s and correct by +12 s (verified live 2026-10-05,
405
+ [details](#what-hubspot-changes-after-a-write)). Show the written value optimistically and
406
+ read again later.
407
+ - Softr also caches data-source reads ("short-term" on one docs page, "24 hour" on another; scope
408
+ unspecified). See [overview.md](overview.md).
409
+
410
+ ## User sync
411
+
412
+ - **HubSpot supports 2-way user sync** (documented). Email is required and is the unique
413
+ identifier. Name, Magic link, Avatar, Created date and Last seen date map too. All other contact
414
+ properties remain usable in user groups, conditional filters and `useCurrentUser({ properties })`.
415
+ - **Filtered sync** ("Selected", with rules) is available (documented; listed under Business on
416
+ Softr's pricing page).
417
+ - **Deleting a Softr user deletes the HubSpot contact.** Deleting in HubSpot does not delete the
418
+ Softr user (documented). The filtered-sync option "Delete their user account" also removes the
419
+ data-source record (documented).
420
+ - **`createUserInDatasource` defaults to `true`** on `application_create_user_connection`, so users
421
+ added in Softr create HubSpot contacts. To keep people out of HubSpot (e.g. internal account
422
+ managers), use the "Let the user only exist in the Softr app" option (documented).
423
+ - **Sync is continuous only on a published app.** In Studio, or on an unpublished app, it runs on
424
+ login, on publish and when the Users tab is opened (documented).
425
+ - **Even on a published app the lag varies** (verified 2026-10-05). A new HubSpot contact appeared
426
+ as a Softr user after 33 s in the morning; two more, created later that day, took 24 min. Never
427
+ assume real time: confirm the user exists with `application_list_users` before testing as them.
428
+ - **An app has one users table** (documented). Account managers are therefore either contacts in the
429
+ same table or Softr-only users.
430
+ - **`useCurrentUser().id` exists only when user sync is on** (documented). Whether it equals the
431
+ HubSpot contact id is **unverified**.
432
+ - **User Caching** (App Settings > Advanced) keeps the **logged-in user's own record** for 2 minutes.
433
+ Softr recommends leaving it on, and suggests turning it off when user-group membership updates
434
+ slowly (documented). It affects group membership and logged-in-user values, not how fresh other
435
+ records are. A ticket-status lag comes from elsewhere: the search delay above, data-source
436
+ caching, or workflow trigger latency.
437
+ - **Condition-based user groups on a synced HubSpot property work** (verified 2026-10-05). For a
438
+ select contact property the rule is subject `USER:portal_role` (the property's internal name),
439
+ type `ARRAY`, operator `IS_ONE_OF`, value `[<choice id>]`: the choice id, not the label. That
440
+ syntax is for **user groups**. In a block's Source condition the subject form returned 400; there
441
+ the user field goes in the value, as `USER:::<field id>` (see above). Membership then lives in
442
+ HubSpot: anyone who can edit that property there can grant the group's access.
443
+ - **`application_list_users` is not a membership check.** It showed `userGroups: []` for every
444
+ user, including members of condition groups that demonstrably applied (verified 2026-10-05).
445
+ Test membership by what the user can reach, e.g. preview as them against a group-gated block.
446
+
447
+ ## Audit trail
448
+
449
+ - **HubSpot records a connected app's writes with change source "Integration"**, not the person who
450
+ made the edit. Only edits made in HubSpot's own UI show a user's name and email (documented). A
451
+ 2022 HubSpot developer changelog adds that the app's ID is recorded.
452
+ - **Integration writes cannot be restored with one click.** Property history offers Restore only for
453
+ CRM UI edits, imports and workflows (documented).
454
+ - What HubSpot puts in "Updated by user ID" for a Softr write is **unknown**. Records written by a
455
+ different connector carried an ID that belonged to no user in the portal (verified live; not a
456
+ Softr write).
457
+ - **Workaround:** write a "Last edited in Softr by" custom property from `useCurrentUser().email`
458
+ on every create and update. The value is self-reported, because the browser sends it. That is fine
459
+ for a trail, but it is not security.
460
+ - Emails sent by a Softr workflow's Send email step don't appear on the HubSpot record's timeline.
461
+ Logging them as a note would need an association write (inferred).
462
+
463
+ ## Workflows (Softr Workflows with HubSpot)
464
+
465
+ *Catalog and specs verified live 2026-10-05.*
466
+
467
+ - **Triggers: `HUBSPOT_RECORD_CREATED` and `HUBSPOT_RECORD_UPDATED`** (v1.0.0, both `REAL_ONLY`).
468
+ - Their only inputs are `integrationId`, `type` (the object id, e.g. `"tickets"`) and an optional
469
+ `objectTypeId`.
470
+ - There is **no property filter**. Softr Tables and Airtable "record updated" triggers have an
471
+ `updateField` option; these don't.
472
+ - Whether a data-source integration connected before triggers existed can run them is untested.
473
+ Zoho's docs say to reconnect in that case.
474
+ - **Latency is unknown.**
475
+ - Neither trigger appears on Softr's Trigger Types page, and the HubSpot Workflows page lists
476
+ actions only.
477
+ - The Trigger Types page says only Softr Tables, Calendly, Attio and Zoho CRM are instant and
478
+ "other services use background polling". That sentence is wrong for Stripe (Softr registers a
479
+ webhook) and for DocuSign (pushed by Docusign Connect), so it can't be relied on for HubSpot
480
+ either.
481
+ - Time a real run before promising anything.
482
+ - **Record updated fires on any change to any record of that object.** Expect it to fire on
483
+ HubSpot's internal recalculations and on Softr's own writes too (inferred). HubSpot does modify
484
+ a record again about 10 s after a stage write (measured on a ticket, 2026-10-05), so one stage
485
+ change from a block may fire it twice (inferred; the trigger was not run). Build two things in
486
+ from the start:
487
+ - **A stage filter.** Note that a filter on the *current* stage alone fires again on every later
488
+ edit to a ticket already in that stage.
489
+ - **A dedupe property**, e.g. `softr_last_notified_stage`: filter on `stage != it`, and set it
490
+ after acting. Softr exposes no `hs_v2_date_entered_*` property to tell a stage entry apart from
491
+ other edits.
492
+ - **Actions:** Add record (`MOCK_AND_REAL`), Update record (`REAL_ONLY`), Update multiple records,
493
+ Delete record, Delete multiple records, Find record and Find multiple records.
494
+ - **There is no associate action.**
495
+ - Add and Update take a `fields` input of type `DATA_SOURCE_FIELDS`. Whether `associations.*`
496
+ is offered there, or actually written, is unknown.
497
+ - **Find record output** is `{ id, fields }`. Associations arrive as lists of `{ id, label }`
498
+ (e.g. `fields["associations.contact"][*].id`), though some single-label associations come back
499
+ as one `{ id, label }` object. SELECT fields arrive as `{ id, label }`, so compare on `.id`. This
500
+ was seen on a companies sample from a mock test; the same shape on tickets is inferred.
501
+ - Workflow nodes call the workspace's HubSpot integration directly, with `type` set to the object
502
+ id, so an object need not be connected to any block for a workflow to use it.
38
503
 
39
504
  ## Gotchas
40
- - **Business or Enterprise Softr plan required.** HubSpot is not available on Free or Basic plans.
41
- - **Sensitive Data Scopes** must be explicitly enabled in HubSpot for certain fields (e.g., email content, certain contact properties). Without this, those fields return empty.
42
- - **Associations are read-only** in Vibe Coding blocks. Use Linked List Blocks to display related records (e.g., Deals associated with a Contact).
43
- - **Custom Objects** require that the object is properly configured in HubSpot with the necessary scopes granted during OAuth.
44
- - **Deal stages and pipeline data** use internal IDs, not display labels. Map these in your block logic if you need human-readable values.
505
+
506
+ - **Listed is not usable.** Projects needs activation, Subscriptions are read-only, Invoices and
507
+ Subscriptions need Commerce Hub, Line Items need a parent, Custom Objects need HubSpot Enterprise.
508
+ - **Association links: create with `["<id>"]`, update with `[{ id }]`, and an update replaced the
509
+ ticket's whole company list** (verified live 2026-10-05, one ticket); Softr then read no primary
510
+ company (not checked in HubSpot). A ticket created with a contact may also get another of that
511
+ contact's companies. Treat link ids as client input.
512
+ - **SELECT ids ≠ labels, and writes take the id** (verified live 2026-10-05; label untested).
513
+ Take display labels from the schema's choices (`useFieldOptions` should serve them, but that
514
+ is untested on HubSpot); don't hardcode stage ids across portals.
515
+ - **Closing a deal overwrites its close date, and reopening keeps the new one.** Reopening a
516
+ ticket clears its close date. See [What HubSpot changes after a write](#what-hubspot-changes-after-a-write).
517
+ - **After a save, read again at about 4 s and 12 s** on top of the usual `refetch()`. No read
518
+ follows a write on its own, and HubSpot's computed fields arrive late.
519
+ - **The owner email and name fields are Softr's, not HubSpot's.** Filter and write
520
+ `hubspot_owner_id`.
521
+ - **Only part of each object's properties is exposed.** Check a property is in the field list
522
+ before relying on it.
523
+ - **Mind the filter budget on blocks with an Edit button:** 4 AND filters, counting Source
524
+ conditions, inline search and action-visibility filters together.
525
+ - **Scope client rows with `USER:::associations.company`, no braces,** in a Source condition
526
+ joined by AND. It fails closed, so give staff their own group-gated block.
45
527
 
46
528
  ## Best For
529
+
530
+ - Client and partner portals on top of HubSpot-managed contacts and companies
531
+ - Support ticket portals
47
532
  - Sales portals and deal rooms
48
533
  - CRM dashboards for teams or clients
49
- - Support ticket portals
50
- - Partner portals with HubSpot-managed contacts
51
534
  - Any app where HubSpot is the system of record
@@ -91,7 +91,7 @@ it behaves as a **union** of both branches, not a choice between them. Which is
91
91
 
92
92
  ## One connection = one read payload (the union of its selects)
93
93
 
94
- *Verified live 2026-09-18.*
94
+ *Verified live 2026-09-18; the block-visibility gate 2026-10-05.*
95
95
 
96
96
  The records endpoint is per block + connection —
97
97
  `/blocks/<blockId>/datasources/<dataSourceId>/records` — and it returns the **UNION of every field
@@ -131,11 +131,20 @@ function AdminNotes({ recordId }) {
131
131
  Or put the private field in a separate block whose visibility is group-gated.
132
132
 
133
133
  Know what this buys you. Not *rendering* the hook keeps the field out of ordinary browsers, but
134
- the endpoint still exists: page VIEW permission is enforced on it (a viewer who cannot view the
135
- page gets a 403), yet on a page any logged-in user may view, **every connected datasource is
136
- readable by any logged-in user who crafts the request**. A connection's **Source conditions are
137
- the only server-side ROW gate**; the only server-side gate on the *field* is a page or block the
138
- viewer cannot see. See [../references/softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints).
134
+ the endpoint still exists. Two all-or-nothing gates are enforced on it, list and by-id: page VIEW
135
+ permission, and the **block's own Visibility** (`predefinedUserGroup` + `customUserGroupIds`). A
136
+ viewer outside either gets a 403 (block gate verified 2026-10-05, on HubSpot). Past those gates, on
137
+ an ungated block on a page any logged-in user may view, **every connected datasource is readable by
138
+ any logged-in user who crafts the request**. A connection's **Source conditions are the only
139
+ server-side ROW gate**; the only server-side gate on the *field* is a page or block the viewer
140
+ cannot see.
141
+
142
+ **A block gated to a user group may therefore read unfiltered connections** (verified 2026-10-05).
143
+ A staff dashboard gated to an "Account managers" group, with no Source condition on its deals and
144
+ tickets connections, gave both members all 41 deals and 16 tickets, and gave clients, a Softr-only
145
+ user and logged-out visitors 403 on list and by-id. The gate belongs to the block, not the table:
146
+ connect the same table to an ungated block and it is open again. See
147
+ [../references/softr-mcp.md](../references/softr-mcp.md#what-the-server-enforces-on-a-blocks-data-endpoints).
139
148
 
140
149
  Alias → field attribution is per connection, so two selects on different connections may reuse
141
150
  an alias name (`customer` on both) without colliding — including in `where` filters.
@@ -32,7 +32,7 @@
32
32
  - Data is connected to **dynamic blocks** (List, Grid, Table, Kanban, Chart, Form, etc.) in the Softr Studio.
33
33
  - **Multiple data sources** can coexist in a single Softr app, even on the same page.
34
34
  - Softr IP addresses to whitelist for secured databases: `3.120.79.212`, `3.123.159.186`, `52.58.246.121`
35
- - **AI-assisted schema discovery via MCP:** the official Softr MCP server (`https://mcp.softr.io/mcp`) reads schema and field references directly from the workspace -- Softr Databases in full, plus browsable field-level access to connected **Airtable, Google Sheets, Notion, and Supabase** integrations. Other external sources still need the manual workflows in [fields.md](fields.md). See [../references/softr-mcp.md](../references/softr-mcp.md).
35
+ - **AI-assisted schema discovery via MCP:** the official Softr MCP server (`https://mcp.softr.io/mcp`) reads schema and field references directly from the workspace -- Softr Databases in full, plus browsable field-level access to connected external integrations, among them **Airtable, Google Sheets, Notion, Supabase and HubSpot** (HubSpot browsed live 2026-10-05; the full type list is in softr-mcp.md). Sources the MCP cannot browse still need the manual workflows in [fields.md](fields.md). See [../references/softr-mcp.md](../references/softr-mcp.md).
36
36
 
37
37
  ## User Sync Availability
38
38
 
@@ -201,9 +201,10 @@ var statusOptions = useFieldOptions({
201
201
 
202
202
  // statusOptions → { options: [...], isLoading: bool }
203
203
  // statusOptions.options → [{ id: "sel...", label: "Active", color: "greenLight1" }, ...]
204
- // id — stable option id (use as a React key; NOT needed in mutate payloads — SELECT
205
- // fields write by LABEL string on the current platform, verified 2026-08-25)
206
- // label — display string AND the value to write in mutate payloads
204
+ // id — stable option id (use as a React key; NOT needed in mutate payloads on Softr
205
+ // Database — SELECT fields write by LABEL string there, verified 2026-08-25.
206
+ // HubSpot is the exception: write the id, verified 2026-10-05)
207
+ // label — display string AND the value to write in mutate payloads (not on HubSpot)
207
208
  // color — Airtable swatch color name (optional; handy for tinting chips)
208
209
  ```
209
210
 
@@ -219,7 +220,7 @@ same `select` object can be shared by both. Reuse one `select` for many fields a
219
220
 
220
221
  **When to use this vs. hardcoding:**
221
222
 
222
- - **Use `useFieldOptions`** when option labels could change post-deploy — selects with rapidly-evolving lists, user-editable choices, or any case where re-pasting blocks for an option rename is annoying. Since SELECT fields write by label (verified 2026-08-25), live options also keep write payloads rename-proof: render and write `option.label`. Hardcoded labels remain fine as a display-only loading fallback while the live options fetch. Cross-table case: to render a select field from table B inside a block bound to table A (e.g. an intake form bound to Jobs that needs the Wigs `Color` options), put the `useRecords` + `useFieldOptions` in a hidden helper block bound to table B and publish the options to a `window` global (see [helper-blocks.md](../references/helper-blocks.md)).
223
+ - **Use `useFieldOptions`** when option labels could change post-deploy — selects with rapidly-evolving lists, user-editable choices, or any case where re-pasting blocks for an option rename is annoying. Since SELECT fields write by label on Softr Database (verified 2026-08-25), live options also keep write payloads rename-proof: render and write `option.label`. HubSpot writes the choice id instead (verified 2026-10-05; see [hubspot.md](hubspot.md#writing)). Hardcoded labels remain fine as a display-only loading fallback while the live options fetch. Cross-table case: to render a select field from table B inside a block bound to table A (e.g. an intake form bound to Jobs that needs the Wigs `Color` options), put the `useRecords` + `useFieldOptions` in a hidden helper block bound to table B and publish the options to a `window` global (see [helper-blocks.md](../references/helper-blocks.md)).
223
224
  - **Hardcode** when the option set is stable and frequently referenced (e.g. a status enum that drives a state machine), so the label vocabulary lives in source and rename-safety is enforced by greppable constants. A robust middle ground: prefer the live options, fall back to a hardcoded list per field so the UI still renders if the helper hasn't published yet.
224
225
 
225
226
  `useFieldOptions` is the read-side equivalent of using `useLinkedRecords` for foreign records — it abstracts away the field's option store. Items are shaped `{ id, label, color }` (note: `label`, not `title` like `useLinkedRecords`).
@@ -308,6 +309,13 @@ var user = useCurrentUser();
308
309
  // Note: `id` is only present when user sync is enabled.
309
310
  ```
310
311
 
312
+ **`where: q.text("ownerEmail").is(user.email)` is not access control.** It shapes the UI, but the
313
+ caller controls that parameter. To restrict rows to the logged-in user on the server, put the
314
+ condition in the connection's Source conditions, as the whole value: `{USER:::EMAIL}` (with
315
+ braces) for the user's email, or `USER:::<user field id>` (no braces) for one of the user's own
316
+ fields, e.g. their company (verified 2026-10-05 on HubSpot; fails closed when the field is empty).
317
+ See [../references/softr-mcp.md](../references/softr-mcp.md#logged-in-user-values-in-source-conditions).
318
+
311
319
  **Custom user-record fields** are first-class: pass a `properties` map (aliased like a `select` query) and read them under `user.properties`:
312
320
 
313
321
  ```jsx
@@ -16,6 +16,13 @@ Record mutations, sequential write queues, file uploads, linked record format, a
16
16
 
17
17
  Every Vibe Coding block in Softr Studio has an **Actions tab** alongside Chat / Source / Content / Visibility. The Actions tab is a **read-only inspector** of the Create / Update / Delete operations the platform inferred from your code.
18
18
 
19
+ > **Docs discrepancy (noted 2026-10-05, unresolved).**
20
+ > - **What the docs say:** the live Vibe Coding page tells builders to "Review each action and configure permissions, conditions, or connected workflows as needed".
21
+ > - **Where we agree:** permissions are settable, on the Actions tab or with `vibe_coding_block_set_action_visibility`, so "read-only" is about the action *list*, which only the code changes.
22
+ > - **What nobody has observed:** an Actions-tab control for conditions or connected workflows on a vibe block, or whether a vibe block's `ADD_RECORD` fires the Softr Apps "Add record" workflow trigger.
23
+ >
24
+ > Until someone sees those controls in Studio, start workflows from code with `navigate()` on a `TRIGGER_CUSTOM_WORKFLOW` setting (SKILL.md, NavigationAction).
25
+
19
26
  Each Action's "FIELDS USED" list mirrors the aliases in your `q.select()` mapping. Actions are not a separately-managed system:
20
27
 
21
28
  - The platform parses your block's source on every save
@@ -121,6 +128,20 @@ updateRecord.mutate({
121
128
  });
122
129
  ```
123
130
 
131
+ **No read follows a write** (measured live 2026-10-05 on a HubSpot-backed block). The network
132
+ log showed the PATCH and then no read of the table until the block's own `refetch()`. The hook
133
+ resolves to `{ id, fields }`: the PATCH response's record mapped through the hook's `fields:`
134
+ select (seen in the compiled block bundle), and that response held only the written field.
135
+ Nothing HubSpot-specific was seen in the hook's client code, so expect the same on other
136
+ sources (inferred, untested elsewhere). So:
137
+
138
+ - Call `refetch()` on every query that shows the written table.
139
+ - If the source changes other fields itself with a delay (a HubSpot deal's probability, for
140
+ example), a `refetch()` in `onSuccess` can still return them old (inferred from a read at
141
+ +4 s; a read right after the write was not measured). Read the table again a few seconds
142
+ later as well; see
143
+ [Fields the source changes after the write](#fields-the-source-changes-after-the-write).
144
+
124
145
  #### CRITICAL: The `useRecordUpdate` payload shape (and the retired `.mutate()`-only rule)
125
146
 
126
147
  **Payload must be `{ recordId, fields: {...} }` — not flat.** Field values must be nested inside a `fields: {...}` object. The flat form (`mutate({ recordId, status: "active" })`) can succeed at runtime, but Softr's Action parser doesn't see field references inside it, so no Update Action is derived — `enabled` stays `false`, the UI gated on it silently does nothing, and Studio's Actions tab shows "No actions used in this block yet":
@@ -277,13 +298,62 @@ completes reads as broken. The pattern (verified live 2026-08-31 on a custom Kan
277
298
  3. **Revert on failure**: delete the override + `refetch()` → the card snaps back, with an
278
299
  error toast.
279
300
  4. **Clear on convergence**: an effect compares each override against fresh server data and
280
- deletes it once they match — never clear on a timer.
301
+ deletes it once they match — never clear on a timer. Don't wait for fresh data to arrive
302
+ on its own: no read followed a write in the network log ([useRecordUpdate](#userecordupdate)),
303
+ so without a `refetch()` the override may never converge.
281
304
  5. **Undo**: snapshot the *server* state before mutating (previous lead, each row's previous
282
305
  value — per-row, since a batch may have had mixed values), and offer
283
306
  `toast.success(msg, { duration: 8000, action: { label: "Undo", onClick: restore } })`.
284
307
  The restore is just another optimistic move driven by the snapshot. Snapshot before the
285
308
  write, not from the UI — the UI may already be showing an optimistic override.
286
309
 
310
+ ### Fields the source changes after the write
311
+
312
+ Some sources change other fields of a record when one is written. HubSpot stamps a deal's close
313
+ date when it closes and recalculates its probability and weighted amount a few seconds later. A
314
+ formula or rollup that depends on the written field recalculates on any source (inferred). No
315
+ read follows a write, and a source that applies its own changes with a delay can still serve the
316
+ old values to a `refetch()` in `onSuccess` (inferred). So read the written table again once the
317
+ source has settled (verified live 2026-10-05 on HubSpot deals):
318
+
319
+ ```tsx
320
+ // After a save the source changes some fields itself, a few seconds later, so the written
321
+ // table is read again twice. The timers are cleared if the block unmounts first.
322
+ const followUps = useRef<number[]>([]);
323
+ useEffect(() => () => followUps.current.forEach((t) => window.clearTimeout(t)), []);
324
+
325
+ async function saveStage(id: string, stage: string) {
326
+ if (!updateDeal.enabled) return;
327
+ try {
328
+ await updateDeal.mutateAsync({ recordId: id, fields: { stage } });
329
+ } catch {
330
+ toast.error("We couldn't save the change. Try again.");
331
+ return;
332
+ }
333
+ void dealsQ.refetch();
334
+ followUps.current = followUps.current.concat(
335
+ [4000, 12000].map((ms) => window.setTimeout(() => void dealsQ.refetch(), ms)),
336
+ );
337
+ }
338
+ ```
339
+
340
+ - **The delays depend on the source.** On HubSpot the read at +4 s already had the new stage
341
+ and close date, but the probability and weighted amount still held the previous write's
342
+ values. The read at +12 s had everything. HubSpot's own follow-up change landed 9 to 11 s
343
+ after a ticket write, so +12 s leaves about a second of margin: go later rather than earlier.
344
+ Measurements and the list of what HubSpot changes:
345
+ [hubspot.md](hubspot.md#what-hubspot-changes-after-a-write).
346
+ - **Keep the immediate `refetch()` too.** A read right after the write was not measured.
347
+ HubSpot documents that changes take "a few moments" to reach its search, but whether Softr's
348
+ list reads use that search is not documented. With an optimistic override the immediate read
349
+ may add little; it is cheap and keeps the `refetch()`-in-`onSuccess` rule whole.
350
+ - **Show the known outcome at once.** When you know what the source will set (today's date as
351
+ a closing deal's close date), show it in the override and let the re-read replace it.
352
+ - **Only where it's needed.** Add the delayed reads when the block shows a field the source
353
+ sets or computes. A plain field the user wrote needs nothing more than the usual `refetch()`.
354
+
355
+ ## File Uploads
356
+
287
357
  ```jsx
288
358
  import { useUpload } from "@/lib/datasource";
289
359
 
@@ -362,6 +432,12 @@ parentAccount: "RECORD_ID_1"
362
432
  string-array shape is the verified current form on Softr Database; if a linked-record write
363
433
  fails on an Airtable-backed block, try the `[{ id }]` object shape before deeper debugging.
364
434
 
435
+ **HubSpot associations: the string array worked on a create, but an update needs `[{ id }]`
436
+ objects** (verified live 2026-10-05, one ticket). A create with `company: ["<id>"]` linked the
437
+ ticket; a create with `[{ id }]` is untested. An update with string arrays failed with 500; with
438
+ `[{ id }]` it worked and **replaced** the ticket's company list. See
439
+ [hubspot.md](hubspot.md#association-writes).
440
+
365
441
  ### Linked-record write traps (verified live 2026-08-26)
366
442
 
367
443
  Four Softr Database behaviors proven by direct experiment on a live production build — a
@@ -431,6 +507,10 @@ keep vocabularies as greppable constants, or fetch them live with `useFieldOptio
431
507
  [reading.md](reading.md#usefieldoptions----fetch-singlemulti-select-choices)) and write
432
508
  `option.label`.
433
509
 
510
+ **On HubSpot, write the choice id** (`"closedwon"`, `"1"`; verified live 2026-10-05 on
511
+ `dealstage` and `hs_pipeline_stage`). Whether HubSpot also accepts the label is untested, so
512
+ don't rely on it; see [hubspot.md](hubspot.md#writing).
513
+
434
514
  **Legacy note.** Until mid-2026 this skill documented the opposite — write the option UUID,
435
515
  labels rejected (verified April 2026 on the then-current platform). If a label write is
436
516
  rejected on an old app, the UUID form is the thing to try; on the current platform it is not
@@ -438,7 +518,7 @@ needed.
438
518
 
439
519
  ### Linked Record
440
520
 
441
- Array of record-id **strings** (`["RECORD_ID"]`) on Softr Database (verified 2026-08-25); the legacy / Airtable fallback shape is `[{ id }]` objects. See "Linked Record Format for Mutations" above.
521
+ Array of record-id **strings** (`["RECORD_ID"]`) on Softr Database (verified 2026-08-25); the legacy / Airtable fallback shape is `[{ id }]` objects. HubSpot associations: the string array worked on create; an update needs `[{ id }]` and replaced the company list (verified 2026-10-05, one ticket; [hubspot.md](hubspot.md#association-writes)). See "Linked Record Format for Mutations" above.
442
522
 
443
523
  ### Multi-Select
444
524
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "2.12.0",
3
+ "version": "2.13.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"
@@ -34,10 +34,11 @@ Run through this catalog before delivering any block. Every row is a violation o
34
34
  | Assuming `mutation.enabled === false` always means a code bug | `enabled` is BOTH a parser signal AND a permissions signal. Per the official Softr docs, "`enabled` reflects user permissions." When code looks correct and the Actions tab shows the action listed, the cause is almost always permissions. Test by switching "Preview as" in Studio to an Owner / admin; if it then works, the issue is permissions. Three places to check, in priority order: (1) the block's **Visibility** tab (right panel), (2) **Studio → Users → Data Restrictions → Global data restrictions** — an app-wide layer that easily gets overlooked because it's hidden under Users (not on the block); it overlays every block in the app, and a single restriction on the target table will silently disable every mutation against that table for the affected user group, (3) the data-source PAT scope — if granted read-only, every write fails regardless of UI permissions. See [datasources/writing.md](../datasources/writing.md#how-actions-work-studios-actions-tab) |
35
35
  | `deleteRecord.mutate({ id: r.id })` | `deleteRecord.mutate(r.id)` -- just the string |
36
36
  | `var { mutateAsync } = useRecordUpdate({...})` -- destructuring the mutate function off the hook | `var updateRecord = useRecordUpdate({...})` -- keep the full object so `.enabled`, `.status`, `.reset()` stay reachable (using `.mutateAsync` itself is fine) |
37
- | Not calling `refetch()` after mutations | Always `refetch()` in `onSuccess` |
37
+ | Not calling `refetch()` after mutations | Always `refetch()` in `onSuccess`. The runtime issued no read after a write (measured live 2026-10-05 on a HubSpot-backed block), so without it the list is never re-read. If the source changes other fields itself (HubSpot stamps a closing deal's close date and recalculates its probability a few seconds later), read again a few seconds later too — see [writing.md](../datasources/writing.md#fields-the-source-changes-after-the-write) |
38
38
  | Including read-only fields (formula / rollup / aiText / lookup / createdTime / lastModifiedTime / autoNumber) in the `fields` q.select passed to `useRecordCreate` or `useRecordUpdate` | Softr's Action parser silently rejects the **entire** create/update Action — not just the bad alias. Same all-or-nothing failure mode as a renamed/missing column: Studio's Actions tab shows "No actions used in this block yet", `createRecord.enabled` / `updateRecord.enabled` stays `false`, `.mutate()` calls dispatch but resolve to "not yet ready", every OTHER writable field in the same q.select is also lost. Reads handle these field types fine — only the write q.select chokes. Fix: split into separate q.selects per the Three Mappings Pattern (`useRecord` / `useRecords` gets the full select with read-only fields; `useRecordCreate` / `useRecordUpdate` gets a writable-only subset). Diagnostic when the symptom shows up: same bisection procedure as the renamed-column case — strip the write q.select to a known-writable minimum, then add fields back in halves until the Action drops out. Verified 2026-05-22: `blocks/wig-details/wig-details-page.jsx` shared one `wigSelect` between `useRecord` and `useRecordUpdate`; the select included `Wig Tag ID` (formula), `Total client price` (rollup), `Total worker pay` (rollup), `Instrucciones` (aiText) — Update Action stayed disabled until those four read-only fields were lifted out into a separate write-only select. See [datasources/airtable.md](../datasources/airtable.md#maintainability-gotcha) and [datasources/writing.md](../datasources/writing.md). |
39
- | Linked record as bare string, or `[{ id }]` objects on Softr Database | Array of record-id **strings**: `familyLink: [familyId]` — verified live 2026-08-25 on Softr DB. (The `[{ id }]` object shape was the May 2026 verified form on Airtable-backed blocks; try it if a string-array write fails there.) See [datasources/writing.md](../datasources/writing.md#linked-record-format-for-mutations) |
40
- | Writing dropdown values as `{ id, label }` objects (the read shape) or hunting for option UUIDs | Write the option **LABEL string**, exactly matching a defined choice — e.g. `status: "Active"` (verified live 2026-08-25; supersedes the April 2026 UUID rule). Keep vocabularies as greppable constants or fetch live via `useFieldOptions` and write `option.label` |
39
+ | Linked record as bare string, or `[{ id }]` objects on Softr Database | Array of record-id **strings**: `familyLink: [familyId]` — verified live 2026-08-25 on Softr DB. (The `[{ id }]` object shape was the May 2026 verified form on Airtable-backed blocks; try it if a string-array write fails there.) HubSpot associations: the string array worked on create, but an update needs `[{ id }]` (the string array returned 500; verified live 2026-10-05). See [datasources/writing.md](../datasources/writing.md#linked-record-format-for-mutations) |
40
+ | Adding one HubSpot association with `useRecordUpdate`, e.g. `fields: { company: [{ id: newId }] }` | The update **replaced** the whole company list (verified live 2026-10-05, one ticket), and Softr's read afterwards showed no primary company (not checked in HubSpot). Writing the full list back with the new id is untested and would likely drop the primary label too (inferred); or add the link from a workflow that calls HubSpot's associations API — see [hubspot.md](../datasources/hubspot.md#association-writes) |
41
+ | Writing dropdown values as `{ id, label }` objects (the read shape) or hunting for option UUIDs | Write the option **LABEL string**, exactly matching a defined choice — e.g. `status: "Active"` (verified live 2026-08-25; supersedes the April 2026 UUID rule). Keep vocabularies as greppable constants or fetch live via `useFieldOptions` and write `option.label`. **Except on HubSpot**, which takes the choice **id** as a plain string (`"closedwon"`; verified live 2026-10-05, label untested; see [hubspot.md](../datasources/hubspot.md#writing)) |
41
42
  | Writing a formatted phone value (`(212) 555-0100`, `212-555-0100`) to a PHONE field | Sanitize to unformatted international format — `+` followed by digits only (`+12125550100`) — before `mutate()`; some datasources (Monday.com, per the official guide) reject formatted values outright. Official sanitizer: `const sanitizePhone = (raw) => raw.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");` See [datasources/writing.md](../datasources/writing.md#phone) |
42
43
  | Wrapping a `useRecordCreate` payload in `{ fields: {...} }` (copied from an update call) | Create payloads are **FLAT** — `createRecord.mutate({ name: "Jane" })`. Only update payloads nest: `{ recordId, fields: {...} }`. The asymmetry is by design (verified 2026-08-25) |
43
44
  | Passing a data hook's options through a variable or wrapper function: `useRecords(buildOpts())` | **Fails to compile** — the options object must be an inline literal at the call site (verified live 2026-08-25; hit in production, fixed by making the wrapper take the hook's *result* instead). Share `q.select` mappings between hooks, never whole options objects |
@@ -137,7 +137,7 @@ Value shapes per action type (full detail in SKILL.md's NavigationAction section
137
137
  - `OPEN_PAGE` — `destination` (page path) + `openIn` (`"SELF"` | `"TAB"` | `"MODAL"`)
138
138
  - `OPEN_URL` — `destination` (URL) + `openIn` (`"SELF"` | `"TAB"`)
139
139
  - `OPEN_CHAT` — no destination (mind the data-source-context gotcha in [anti-patterns.md](anti-patterns.md))
140
- - `TRIGGER_CUSTOM_WORKFLOW` — no destination; builder picks the workflow in Studio (workflow-side receiving end by name: the "Run Custom Workflow action triggered" trigger — name-based match, not wired live; see [softr-mcp.md](softr-mcp.md#workflows))
140
+ - `TRIGGER_CUSTOM_WORKFLOW` — no destination; builder picks the workflow in Studio. Code can also run it with `navigate(setting, { recordId, datasourceId })` (documented 2026-10-05). The workflow-side receiving end is the "Run custom workflow" trigger (`SOFTR_APPS_TRIGGER_WORKFLOW`); see [softr-mcp.md](softr-mcp.md#workflows)
141
141
 
142
142
  **[verified-undocumented] `action` is accepted as optional.** Softr's setting validator accepts an initialValue with no `action` key — `{ destination: "/", openIn: "SELF" }` alone — and Studio's own AI emits exactly that shape (verified 2026-08-31: a Studio-generated block with three action-less `useNavigationSetting` initialValues, plus three more action-less link values inside an array setting, saved and rendered its Settings pane). Corroborating in-repo evidence that not every key is mandatory: the validator's own error message for `openIn` ends in *"if provided"* (see [anti-patterns.md](anti-patterns.md#editable-settings)). Two consequences:
143
143
 
@@ -10,9 +10,9 @@ Cross-block communication via `window` globals, the invisible helper block patte
10
10
  - [Companion Field Helpers](#companion-field-helpers)
11
11
  - [Breadcrumb / Back Navigation](#breadcrumb--back-navigation)
12
12
  - [Multi-Table Access via Invisible Helper Blocks](#multi-table-access-via-invisible-helper-blocks)
13
- - [Publisher Template](#publisher-template)
14
- - [Consumer Pattern](#consumer-pattern)
15
- - [useWindowData Custom Hook](#usewindowdata-custom-hook)
13
+ - [Publisher Template](#publisher-template-the-invisible-helper-block)
14
+ - [Consumer Pattern](#consumer-pattern-inside-the-main-block)
15
+ - [useWindowData Custom Hook](#usewindowdata-custom-hook-cleaner-consumer)
16
16
  - [Triggering Actions in Other Blocks (Bi-Directional Events)](#triggering-actions-in-other-blocks-bi-directional-events)
17
17
  - [Advanced Patterns](#advanced-patterns)
18
18
  - [Anti-Patterns](#anti-patterns)
@@ -47,6 +47,13 @@ On 2026-10-01 Softr renamed the workspace-server tools outside Workflows to **ar
47
47
  (`application_update_pwa_settings`); the 28 Workflows tools and `get_workspace_integrations` kept their names. The
48
48
  map below was checked against the tool lists the server delivered on 2026-09-30 (old) and 2026-10-01 (new).
49
49
 
50
+ **The Workflows tools have since followed** (when exactly is not known; first seen 2026-10-05). The 2026-10-05 roster delivered all 28 as `workflow_*`:
51
+ `workflow_create`, `workflow_get`, `workflow_list`, `workflow_publish`, `workflow_update_node_inputs`,
52
+ `workflow_get_node_specifications`, `workflow_list_node_types`, `workflow_test_node` and the rest,
53
+ i.e. area first, then the old verb and object. [Workflows](#workflows) below still lists the
54
+ pre-rename names. Translate them that way. That roster had no `get_workspace_integrations`;
55
+ `integration_list` covers it.
56
+
50
57
  Most new names are the old words reordered. These are the ones you would not guess:
51
58
 
52
59
  | Old | New |
@@ -453,31 +460,78 @@ Both edit paths recompile, so both reset Action permissions either way (Hard Con
453
460
  ### What the server enforces on a block's data endpoints
454
461
 
455
462
  *Verified live 2026-09-18 (Softr Database; draft preview, "Preview as" different users, requests
456
- captured from the app iframe).* A block's data lives behind per-connection endpoints —
463
+ captured from the app iframe); the block-visibility row verified 2026-10-05 (HubSpot; preview link,
464
+ impersonated users, direct POSTs).* A block's data lives behind per-connection endpoints —
457
465
  `/blocks/<blockId>/datasources/<dataSourceId>/records` for lists, `/records/<id>` for one record —
458
466
  and these are the gates that actually exist on them:
459
467
 
460
468
  | Gate | Enforced server-side? |
461
469
  |---|---|
462
470
  | **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 |
471
+ | **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 |
463
472
  | **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** |
464
473
  | A `where` filter in the block's code | No — it is a request parameter the caller controls |
465
474
  | 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)) |
466
475
 
467
476
  The consequence to design around: **on a page any logged-in user may view, every datasource
468
- connected to its blocks is readable by any logged-in user who crafts the request** — all rows the
477
+ connected to its ungated blocks is readable by any logged-in user who crafts the request** — all rows the
469
478
  Source conditions allow, all fields the block's read selects name. A per-record access check in
470
479
  React ("is this viewer a party to this record?") shapes the UI; it is not access control. When rows
471
- must be private per user, put it in the Source conditions (e.g. a logged-in-user condition) or on a
480
+ must be private per user, put it in the Source conditions (e.g. a logged-in-user condition; see
481
+ [below](#logged-in-user-values-in-source-conditions) for the two forms known to work) or on a
472
482
  page only the right group can view. When a field must be private, a second connection of the table
473
483
  keeps it out of every ordinary browser's payload — but not away from a crafted request by someone
474
484
  who may view the page; for that it has to live on a page (or in a group-gated block) the viewer
475
- cannot see. (The verified 403 case was page VIEW; the message's "block/action visibility rules"
476
- wording suggests block visibility is checked the same way, which is inference.)
485
+ cannot see. Conversely, a block gated to a staff group may read unfiltered connections for a staff
486
+ view: on 2026-10-05 group members got all 41 deals and 16 tickets, and everyone else got 403, with
487
+ the page VIEW permission at `LOGGED_IN_USERS` throughout.
477
488
 
478
489
  This is also what makes the open-`ADD_RECORD` finding above severity-dependent on the page's VIEW
479
490
  permission rather than uniformly critical.
480
491
 
492
+ #### Logged-in-user values in Source conditions
493
+
494
+ *The email token was verified on Softr Database in a production project: set in Studio's Source
495
+ tab and read back with `vibe_coding_block_get_settings` on 2026-09-01, then set over MCP and checked
496
+ against the records endpoint's totals as different users on 2026-09-18. The user-field token was
497
+ verified on HubSpot on 2026-10-05, the same way.* There are two forms, and **their braces differ**:
498
+
499
+ - **The email: `{USER:::EMAIL}`, with braces, only as the ENTIRE value** of an expression, e.g.
500
+ `{ "subject": { "field": "<fieldId>", "type": "TEXT" }, "operator": "CONTAINS", "value": ["{USER:::EMAIL}"] }`.
501
+ Softr substitutes it as a whole-value token, not by string interpolation. The embedded form
502
+ `",{USER:::EMAIL},"` returned total 0 for every user, including users who should have matched.
503
+ - **A users-table field: `USER:::<user field id>`, with NO braces, as the entire value** (verified
504
+ 2026-10-05 on HubSpot). `associations.company IS_ONE_OF ["USER:::associations.company"]` on a
505
+ deals connection gave each user only their own companies' deals. Studio stores a picked user
506
+ field in this form (Leo set it in the Source tab, and it read back that way), and the same form
507
+ works when written with `vibe_coding_block_set_data_source_record_filters`.
508
+ **It fails closed:** a user whose field is empty, or who has no record in the users' data source,
509
+ gets 0 rows, and a by-id request for a record outside the condition returns 404.
510
+ - **Use AND between rules.** With one rule OR and AND behave the same, but a second rule added
511
+ under OR widens access (2026-10-05).
512
+ - **The braced user-field spellings fail.** On 2026-09-18, on Softr Database, eleven spellings were
513
+ tried, including `{USER:::<fieldId>}`, `{USER:<fieldId>}` and `{USER:::FIELD:<fieldId>}`. Each
514
+ silently matched nothing. All eleven had braces, so the braceless form is untested on Softr
515
+ Database, not disproved. A subject of `USER:<fieldId>`, the syntax user-group rules use, returned
516
+ HTTP 400 "Field not found": the user field goes in the value, never the subject. No token for a
517
+ user group has been found.
518
+ - Studio's conditional-filter UI offers the logged-in user's Email and Email-Domain, plus every
519
+ users-table field once users sync from a data source (documented). For a value not listed here,
520
+ pick it in a block's Source tab, save, and read `dataSources[].condition` back with
521
+ `vibe_coding_block_get_settings`. That is how the user-field form was found.
522
+ - **CONTAINS against a list of emails has a substring trap:** `bob@x.com` matches a field holding
523
+ `jbob@x.com`. Prefer IS against a single-email field. If a record must hold several emails, the
524
+ delimiter trick the embedded form was meant to provide does not work, so accept the trap or
525
+ split the data.
526
+ - When a row gate must follow something other than the user's email (a company, a team), compare
527
+ the record's field with the user's own field through `USER:::<user field id>`. Where that form is
528
+ untested (Softr Database so far), store an email on the record and compare it with
529
+ `{USER:::EMAIL}`. Staff who need every row get a group-gated block with unfiltered connections
530
+ ([above](#what-the-server-enforces-on-a-blocks-data-endpoints)), not a wider condition.
531
+
532
+ For HubSpot specifics (association-based scoping, owner fields), see
533
+ [../datasources/hubspot.md](../datasources/hubspot.md#row-scoping--who-sees-which-records).
534
+
481
535
  ## Adopting Studio-AI-generated code
482
536
 
483
537
  When you pull a Studio-AI-generated block via `vibe_coding_block_get_code` to adopt into a project repo as source of truth: its output renders fine but ships with predictable defects. **Functional patterns in Studio output are platform-support evidence** (it surfaces undocumented capabilities before the docs do — see SKILL.md's "Platform truth sources"); **its code hygiene is not a pattern to imitate.** Cleanup pass before committing:
@@ -556,6 +610,13 @@ passwords, no test accounts to create:
556
610
  [../datasources/](../datasources/) were established — read the wire, not the rendered UI.
557
611
  - Blocks render in shadow roots inside that iframe: read the DOM through
558
612
  `iframe.contentDocument` and each block host's `shadowRoot`, not `document.querySelector`.
613
+ - **Switch user without the switcher** (verified 2026-10-05): on the preview link's origin,
614
+ `fetch('/studio/impersonate/<softrUserId>')` makes the preview run as that user. The id is the
615
+ user's Softr id from `application_list_users`. It works from a fresh preview link, so it needs no
616
+ Studio session in the browser.
617
+ - **Press the preview's own `#refresh-button` after a push.** An open preview kept serving the old
618
+ block version until it was pressed (verified 2026-10-05). Minting a fresh link does too (see the
619
+ version note above).
559
620
 
560
621
  > **The preview is wired to the LIVE datasource.** Anything clicked there — a Save, a status
561
622
  > change, a form submit — writes real records, as the previewed user. Keep preview sessions to
@@ -669,9 +730,19 @@ Softr Workflows are automations built from trigger + action nodes, and the MCP c
669
730
  | Node management | `add_node`, `add_branch_node`, `create_branch`, `delete_node`, `duplicate_node`, `rename_node`, `reorder_node`, `reorder_multiple_nodes`, `replace_node`, `replace_trigger_node`, `update_node_inputs`, `update_node_note`, `update_node_continue_on_error`, `update_node_retry` |
670
731
  | Discovery / testing | `list_node_types`, `get_node_specifications`, `get_dynamic_input_options`, `test_node`, `get_node_output` |
671
732
 
733
+ These are the names as of 2026-10-01. By 2026-10-05 the server delivered them as `workflow_*`
734
+ (`create_workflow` → `workflow_create`, `update_node_inputs` → `workflow_update_node_inputs`); see
735
+ [the rename note](#tool-names--the-2026-10-01-rename).
736
+
672
737
  **The node catalog is huge** — live-enumerated 2026-08-31: **418 node types (58 triggers + 360 actions) across 56 applications.** The parts that matter most for this skill:
673
738
 
674
- - **Softr-native triggers:** one-time + recurring schedules, `WEBHOOK` (inbound webhook), `SOFTR_EMAIL_RECEIVED` (inbound email! — delivery mechanism not captured), Softr Databases record events (added / updated / deleted / meets-conditions / enters-view / "run custom workflow on selected records"), Softr Apps events (Add Record form submitted, Edit Record form submitted, form submitted, user added, comment added) — and **"Run Custom Workflow action triggered"**, which by its name is the receiving end of the vibe block's `TRIGGER_CUSTOM_WORKFLOW` NavigationAction (name-based inference; the pairing has not been wired live). See SKILL.md's NavigationAction action-types list.
739
+ - **Softr-native triggers:** one-time + recurring schedules, `WEBHOOK` (inbound webhook), `SOFTR_EMAIL_RECEIVED` (inbound email! — delivery mechanism not captured), Softr Databases record events (added / updated / deleted / meets-conditions / enters-view / "run custom workflow on selected records"), Softr Apps events (Add Record form submitted, Edit Record form submitted, form submitted, user added, comment added) — and **"Run custom workflow"** (`SOFTR_APPS_TRIGGER_WORKFLOW`; this file called it "Run Custom Workflow action triggered" until 2026-10-05, and the live label is "Run custom workflow"). This is the receiving end of the vibe block's `TRIGGER_CUSTOM_WORKFLOW` action.
740
+ - **From the block:** the live developer guide documents `navigate(setting, { recordId, datasourceId })` on a `TRIGGER_CUSTOM_WORKFLOW` setting, callable for example from `useRecordCreate`'s `onSuccess`, and says "the builder picks which action it points at" (documented, checked 2026-10-05). We have not yet run it end to end ourselves.
741
+ - **The trigger and its payload:** its only input is `corsAllowedOrigins`. Saved payloads look like `{ body: { ... }, query: {} }`, with `body` holding whatever the triggering action mapped. Native buttons can map form or record fields; the vibe path documents only `recordId` and `datasourceId`.
742
+ - **Who can call it:** the endpoint is browser-callable (per its spec, an empty `corsAllowedOrigins` allows any origin), so re-read the record server-side rather than trusting the body.
743
+ - **Wait screen:** `workflow_create` scaffolds the Show Wait Screen and End User Interactions steps for this trigger. Whether a vibe `navigate()` call shows the wait screen is undocumented; the docs describe the toggle only on native app actions.
744
+
745
+ See SKILL.md's NavigationAction action-types list.
675
746
  - **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).
676
747
  - **`CUSTOM_CODE`:** runs custom **JavaScript or Python** inside a workflow.
677
748
  - **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.
@@ -681,12 +752,15 @@ Softr Workflows are automations built from trigger + action nodes, and the MCP c
681
752
 
682
753
  - Node inputs can embed **references to another node's runtime output**, a loop's current item, or named date/time tokens.
683
754
  - **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.
684
- - **Workflows are workspace-level, not part of an app**: `application_preview` / `application_publish` do not apply. Link a workflow as `https://studio.softr.io/workflow/{workflowId}`.
755
+ - **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}`.
685
756
 
686
757
  **Build-loop findings (verified live 2026-09-01, first end-to-end production build — 10 workflows):**
687
758
 
688
759
  - **`create_workflow` instantiates an OLD version of the trigger node.** Immediately call `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 specific field changed) only exists at v1.2.0; the version `create_workflow` instantiates doesn't have it.
689
760
  - **FILTER node conditions are set via `update_node_inputs` with inputName `"condition"`** — the value is an `{operator, conditions: [...]}` object. The condition is stored on the FILTER node's **outgoing path**, the same way the Studio builder wires it.
761
+ - **The official MCP docs disagree:** "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).
762
+ - **What stands on each side:** our 2026-09-01 build did set them this way. The FILTER spec today declares `inputs: {}`, but live FILTER nodes still keep their condition on the outgoing path, so the empty spec does not refute the mechanism.
763
+ - **Until it's re-checked:** after setting a condition over MCP, read the workflow back (`workflow_get`) and confirm the path condition is there. If it isn't, finish the condition in the builder.
690
764
  - **`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).
691
765
  - **`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.
692
766
  - **Test-safety rules** (which `testRunMode` means what in practice):