# Spike Results — 2026-05-17

Pins the branch decisions for the implementation tasks downstream. Each section answers one prerequisite from spec §9 (or supplements it).

> **TL;DR for the impatient implementer:** Use the Twenty MCP for workflow + metadata installation (not REST), use native step types (`HTTP_REQUEST`, `FIND_RECORDS`, `UPDATE_RECORD`, `ITERATOR`, `IF_ELSE`) wherever possible, keep CODE steps small and pure-parse, hardcode the sitespeed Bearer in the CODE step source (don't rely on env), and remember Logic Function execution is **disabled by default in production** — must be flipped via `core.keyValuePair` DB row (or env vars if `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`).

---

## §9.0 (NEW) — Logic Function execution gate

**Question:** Are CODE steps runnable at all on the user's Twenty install?

**Answer:** Only after explicit opt-in. In production (`NODE_ENV=production`, which is the default for the official `twentycrm/twenty:v2.5.1` Docker image), Logic Functions are DISABLED unless `LOGIC_FUNCTION_TYPE` is set to `LOCAL` or `LAMBDA`.

**Where it lives:** The user runs with `IS_CONFIG_VARIABLES_IN_DB_ENABLED=true` (DB-backed config). The actual gate is stored as a row in `core.keyValuePair`:

```sql
SELECT key, type, value FROM core."keyValuePair"
  WHERE key = 'LOGIC_FUNCTION_TYPE';
-- expected: ('LOGIC_FUNCTION_TYPE', 'CONFIG_VARIABLE', '"LOCAL"'::jsonb)
```

If `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false` is set in BOTH `server` and `worker` containers, Twenty reads from env vars instead. Either path works.

**Decision (operational):**
- The bootstrap script's README must call out: before any workflow runs, verify the gate is open. Add a `bootstrap.js --doctor` mode (or doc warning) that queries `core.keyValuePair` and warns if `LOGIC_FUNCTION_TYPE` ≠ `LOCAL`/`LAMBDA`.
- For self-hosted dev: `LOCAL` is fine (no sandbox, but the code is ours).
- Bonus discovery: an alias `SERVERLESS_TYPE=LOCAL` is also accepted (older variable name). Setting both does no harm.

**Reference:** https://docs.twenty.com/developers/self-host/capabilities/setup

---

## §9.1 — Secret store mechanism in CODE steps

**Answer:** *Empirically unverified at spike time* (could not fire a successful CODE step without manual UI click). However, the docs and Twenty's architecture strongly imply: CODE step Node sandbox inherits the container's `process.env` — so values that reach the container reach the CODE step.

**Caveats this introduces:**
1. With `IS_CONFIG_VARIABLES_IN_DB_ENABLED=true`, env vars are largely overridden by `core.keyValuePair` for Twenty's own config — but `process.env` itself still reflects what the container started with. Custom vars like `SITESPEED_API_KEY` that Twenty doesn't recognize are simply passed through to the runtime untouched.
2. To make a custom var (e.g. `SITESPEED_API_KEY`) available to a CODE step, it must be added to the `worker` container's `environment:` block in docker-compose.yml, because **CODE steps execute on the worker, not the server**. This was the bug that caused 5 failed spike runs.

**Decision:** Hardcode the sitespeed Bearer + URL into each CODE step's TypeScript source at install time. Rationale:
- No external dependency on docker-compose maintenance.
- Workflow A and B are installed via MCP, so the install scripts have the values from `integration/.env` and bake them into the source via `update_logic_function_source`.
- Rotation = re-run the installer.

The plan's §8 `buildSubmitCodeBody({ sitespeedUrl, sitespeedKey, useTwentyApi })` already does this — keep `sitespeedKey` as a string baked in, don't read from `process.env`.

---

## §9.2 — Native FOR_EACH / LOOP step

**Answer:** ✅ **`ITERATOR` exists** as a native step type — confirmed in the `create_workflow_version_step` `stepType` enum:

> `["CODE","SEND_EMAIL","DRAFT_EMAIL","CREATE_RECORD","UPDATE_RECORD","DELETE_RECORD","UPSERT_RECORD","FIND_RECORDS","FORM","FILTER","IF_ELSE","HTTP_REQUEST","AI_AGENT","ITERATOR","EMPTY","DELAY"]`

**Decision:** Workflow B will be **decomposed** per Gemini's critique — no more "God CODE step". Architecture becomes:

```
CRON 60s
├─ FIND_RECORDS (siteScans where status=scanning, limit=50, include=[company])
└─ ITERATOR over the result
    ├─ HTTP_REQUEST GET {{SITESPEED_URL}}/api/test/{{item.testId}}
    ├─ IF_ELSE on HTTP status
    │   ├─ status=404 → UPDATE_RECORD (siteScan.status=failed, error="expired")
    │   └─ default →
    │       ├─ CODE (small: parse HTTP response → patch object)
    │       │   No twentyApi needed. Just data shaping.
    │       ├─ UPDATE_RECORD (apply patch to SiteScan)
    │       └─ IF_ELSE (patch.status=completed?)
    │           ├─ yes → UPDATE_RECORD (denormalize cache to Company)
    │           │      → HTTP_REQUEST DELETE {{SITESPEED_URL}}/api/test/{{item.testId}}
    │           └─ no → END
```

CODE step shrinks from ~80 lines to ~15. Per-record failure is observable in Workflow Run UI. The God step is gone. Gemini's #6/#7.2 critique is now addressed.

---

## §9.3 — In-CODE Twenty client (`twentyApi`)

**Answer:** ❌ **Not exposed.** No `twentyApi` global in the CODE runtime. Twenty's CODE step runtime is a vanilla Node sandbox with `fetch` and standard library only — no auto-injected DB client.

**Decision:** Don't use `twentyApi.*` calls in any CODE step. Use Twenty's native workflow steps for data operations:
- "find scanning SiteScans" → `FIND_RECORDS` step (settings: `objectName: 'siteScan'`, `filter: { status: { eq: 'scanning' } }`)
- "update SiteScan/Company" → `UPDATE_RECORD` step
- "POST sitespeed" → `HTTP_REQUEST` step (settings: `url`, `method: 'POST'`, `headers`, `body`)
- "DELETE sitespeed" → `HTTP_REQUEST` step

CODE step is reserved for pure data transformation (sitespeed JSON → Twenty-shaped patch object).

This is **also cleaner from a security standpoint** — `LOCAL` Logic Functions have no sandbox, so the less code we run there, the smaller the attack surface.

---

## §9.4 — MANUAL trigger surfaces as button on Company record

**Answer:** ✅ Confirmed empirically. The user successfully clicked the spike workflow's bug-icon button from a Company record and triggered runs (even though the runs failed on the §9.0 gate — the trigger UX itself works).

**Decision:** Workflow A keeps `trigger.type = "MANUAL"` with `settings.availability = { type: "GLOBAL", locations: ["RECORD_PAGE"] }` and `settings.isPinned = true` to ensure it's visible on the Company record page. `settings.objectType = "company"` scopes it to Company records.

---

## §9.5 — `create_field_metadata` SELECT + relation schemas

**Answer (SELECT):** From `learn_tools` introspection:

```json
{
  "objectMetadataId": "<uuid>",
  "type": "SELECT",
  "name": "status",            // camelCase internal
  "label": "Status",
  "description": "...",
  "options": [
    { "value": "scanning",  "label": "Scanning",  "color": "yellow", "position": 0 },
    { "value": "completed", "label": "Completed", "color": "green",  "position": 1 },
    { "value": "failed",    "label": "Failed",    "color": "red",    "position": 2 }
  ],
  "defaultValue": "'scanning'",  // singled-quoted! Twenty enum default convention
  "icon": "IconStatusChange"
}
```

⚠️ The `defaultValue` for SELECT must be a single-quoted string literal (`"'SCANNING'"`) per Twenty's convention. **Verified empirically during Task 2.3 bootstrap.**

⚠️ **CRITICAL — UPPER_CASE for SELECT option `value`**: Twenty's field validator REJECTS lowercase SELECT option values with `INVALID_FIELD_INPUT`. The actual values stored on records are `MOBILE` / `DESKTOP` for `device`, and `SCANNING` / `COMPLETED` / `FAILED` for `status`. **All downstream workflow code (FIND_RECORDS filters, IF_ELSE branches, UPDATE_RECORD patches, CREATE_RECORD on SiteScan, CODE-step return values) MUST use these UPPER_CASE values.** The user-facing `label` field stays Title Case.

**Answer (relations):** Use the dedicated `create_many_relation_fields` MCP tool (NOT `create_field_metadata` with type=RELATION). Schema:

```json
{
  "relations": [
    {
      "objectMetadataId": "<siteScan-id>",
      "name": "company",
      "label": "Company",
      "description": "Owning Company",
      "type": "MANY_TO_ONE",
      "targetObjectMetadataId": "<company-id>",
      "targetFieldLabel": "Site Scans",   // displayed name of inverse field
      "targetFieldIcon": "IconGauge"      // required
    }
  ]
}
```

Notes:
- `targetFieldName` is NOT a field — Twenty auto-derives the inverse field name from `targetFieldLabel` (camelCases it).
- Required fields: `objectMetadataId, name, label, type, targetObjectMetadataId, targetFieldLabel, targetFieldIcon`.
- Type is one of `MANY_TO_ONE` or `ONE_TO_MANY` only. (MORPH_RELATION is a separate field type in `create_field_metadata`.)

**Decision (bootstrap):** Use `create_field_metadata` for the 18 scalar SiteScan fields + 13 Company cache fields, and `create_many_relation_fields` (single call, batch of 2) for the `company` + `triggeredBy` relations.

---

## §9.6 — Workflow installation surface

**Answer (REVISED — bootstrap can use plain GraphQL, not MCP):**

- **Metadata creation (objects, fields, relations)** — `POST /metadata` with GraphQL `createOneObject` + `createOneField` mutations works perfectly. Even relations work via `createOneField` with `type: "RELATION"` and a `relationCreationPayload` JSON blob. **Bootstrap.js is a plain Node script** that POSTs to `/metadata` — no MCP runtime required. ✅ Verified empirically in Task 2.3.

- **Workflow installation (create workflow + add CODE step + define source)** — **MCP-only**. The MCP tools `create_complete_workflow`, `create_workflow_version_step`, `update_logic_function_source`, `activate_workflow_version` are the canonical path. There's no public REST/GraphQL surface for these.

- **Firing a MANUAL workflow run** — Not exposed. `POST /rest/workflowRuns` returns `403 FORBIDDEN` (`"Method not allowed"`), `createWorkflowRun` GraphQL mutation returns `FORBIDDEN`, no `/runWorkflowVersion` mutation exists, no `/api/workflow/{id}/run` endpoint. **Workflow A and B execute themselves once installed** (Workflow A via the user clicking the MANUAL trigger button on a Company; Workflow B via its CRON schedule). For end-to-end automated tests, the only path is UI automation (Playwright / Chrome MCP).

**Decision:** Only `install-workflow-a.js` and `install-workflow-b.js` need the MCP path. `bootstrap.js` uses plain GraphQL. Workflow install scripts are **runbooks** that the operator (or Claude in an MCP-enabled session) executes against the Twenty MCP. Concretely:

For each workflow:
1. Build the workflow definition JSON (trigger + non-CODE steps) via plain JS.
2. Call `mcp__twenty__execute_tool(toolName='create_complete_workflow', arguments=...)` — produces `workflowId` + `workflowVersionId`.
3. For each CODE step in the workflow:
   - Call `execute_tool('create_workflow_version_step', { workflowVersionId, parentStepId, stepType: 'CODE' })` — returns `logicFunctionId` in `settings.input`.
   - Call `execute_tool('update_logic_function_source', { logicFunctionId, source: '<TypeScript code with hardcoded secrets>' })`.
4. For workflow B specifically (CRON), add the trigger via `update_workflow_version_trigger`.
5. Call `execute_tool('activate_workflow_version', { workflowVersionId })`.

The "script" file becomes either:
- **(Option A — recommended)** A `.js` module exporting `buildWorkflowDefinition()` + `buildCodeStepSource()`, with a `runbook.md` neighbor describing the MCP calls in order.
- **(Option B)** A pure `.md` runbook with embedded code blocks (TypeScript for the CODE step source, JSON for the workflow definition) and step-by-step instructions for Claude to execute.

Pick (A) — it keeps the data + logic in versioned JS and the imperative orchestration in a thin runbook.

⚠️ The plan's earlier sketch in §3.2 Step 1A (POST `/rest/workflows`) is **wrong** and must be removed. There is no such REST endpoint.

---

## §9.6b (NEW — Task 3.2 discoveries) — MCP workflow install shape deviations

Discovered empirically while installing Workflow A. These are the FINAL correct shapes; the speculative ones in earlier sections are wrong. **Task 4.2 (Workflow B install) MUST use these.**

1. **`create_complete_workflow` — no `edges`**. Supplying an `edges` array causes `"Target step 'undefined' not found"`. Instead, put `nextStepIds: ["<step-uuid>"]` on the trigger and on each step. Twenty builds edges automatically from those references.

2. **`create_workflow_version_step` — `defaultSettings` is silently ignored for non-CODE step types**. For `FIND_RECORDS`, `CREATE_RECORD`, `UPDATE_RECORD`, `HTTP_REQUEST`, `IF_ELSE`, `ITERATOR`, etc., the step is created with empty settings even if you pass `defaultSettings`. Workaround: create the step first (with no settings), then immediately call `update_workflow_version_step` with the desired settings.

3. **`update_logic_function_source` argument name is `code`, not `source`**. (The MCP tool description was misleading.)

4. **`FIND_RECORDS` filter shape**: GraphQL-style flat-FK with lowercase operator, NOT `{ field, operator, value }`. Example for "siteScans whose Company is X and status is SCANNING":
   ```json
   {
     "input": {
       "objectName": "siteScan",
       "filter": {
         "and": [
           { "companyId": { "eq": "{{trigger.record.id}}" } },
           { "status":    { "eq": "SCANNING" } }
         ]
       },
       "limit": 1
     }
   }
   ```
   Use `companyId` (flat FK on the child object) NOT `company.id` (dotted) when filtering.

5. **`CREATE_RECORD` settings shape**: `input.objectName` + `input.objectRecord` with literal Twenty variable templates as values (`"{{step.result.field}}"`). Relations as `{ "id": "{{...}}" }`.

6. **Variable reference: use the step's UUID, not its name**. `{{1c56bc75-….result.testId}}` is correct; `{{submitToSitespeed.result.testId}}` is wrong.

## §9.6c (NEW — Task 4.2 discoveries) — Additional MCP workflow install shape deviations

Discovered empirically while installing Workflow B. These supplement §9.6b.

7. **`create_workflow_version_step` always auto-assigns a UUID — pre-generated UUIDs accumulate as phantoms.** When you pre-generate a UUID and include it in the *parent's* `nextStepIds` (e.g. via `create_complete_workflow`), and then call `create_workflow_version_step` for that parent, the new step gets a freshly generated UUID. The pre-generated UUID stays in the parent's `nextStepIds` as a phantom dangling reference. **Fix:** after all steps are created, audit `nextStepIds` on every step via `get_workflow_current_version` and use `update_workflow_version_step` to remove phantoms.

8. **ITERATOR variable reference syntax is `{{<iteratorStepId>.currentItem.fieldName}}`**, NOT `{{item.fieldName}}`. The iterator UUID prefix is mandatory. This is confirmed by the ITERATOR's own outputSchema which exposes `currentItem` as the key.

9. **ITERATOR `settings.input.items` is a string template** (e.g. `"{{<findStepId>.result.records}}"`), not an array literal. Key name is `items`, not `iterableArray`.

10. **`parentStepConnectionOptions: { type, conditionGroupIndex }` does NOT auto-wire IF_ELSE branches.** It only hints the UI about position. You must patch the IF_ELSE step's `branches[].nextStepIds` via `update_workflow_version_step` after creating each branch's child step.

11. **IF_ELSE false (else) branch `nextStepIds` must be `[]` (empty array), not `null`.** Using null may cause serialization issues. To end the iteration branch, set `nextStepIds: []`.

12. **HTTP_REQUEST output shape when downstream CODE step accesses it**: top-level keys `params.body` (parsed JSON), `params.status` (HTTP status number), `params.headers`. NOT nested under `params.httpResponse`. Defensive access: `(params.body as any) ?? (params.httpResponse as any)?.body ?? params`.

13. **IF_ELSE auto-creates EMPTY placeholder steps** for each branch when first created via `create_workflow_version_step`. These become orphan steps (not reachable from trigger chain) once real branch steps are wired. They are harmless — Twenty's runtime skips unreachable EMPTY steps.

14. **An ACTIVE workflow version cannot be modified.** `create_workflow_version_step` on an ACTIVE version returns `"Workflow version is not in draft status"`. To add steps post-activation: call `create_draft_from_workflow_version` (requires `workflowId` + `workflowVersionIdToCopy`) → this creates a DRAFT copy with all steps preserved (but new `logicFunctionId` values for each CODE step — must re-upload source to the new IDs) → add new steps to the draft → `activate_workflow_version` on the new draft (auto-deactivates the old version).

---

## §9.7 (NEW) — TypeScript CODE step contract

**Question:** What's the exact shape Twenty expects for a CODE step's source?

**Answer:** From the `update_logic_function_source` docs:

```typescript
export const main = async (params: Record<string, unknown>) => {
  // ... your code ...
  return { /* JSON-serializable object */ };
};
```

Constraints:
- **Export `main` as `const`** (arrow function preferred per Twenty's example), not `function` declaration.
- **TypeScript syntax** is parsed and executed.
- **Only native APIs** — no `npm` packages. `fetch` is global. `process.env` should be available (LOCAL driver = Node host process).
- Return value must be JSON-serializable (will be passed to next step or stored in `state.stepInfos[stepId].result`).
- The `params` argument's shape is **not yet empirically verified** (could not run a successful spike workflow). For a MANUAL trigger run on a Company record, the expected shape is approximately `{ trigger: { record: { id, name, ... } } }` based on docs (`{{trigger.record.fieldName}}` variable syntax). The first CODE step in Workflow A should log `Object.keys(params)` on first run as a sanity check, then we lock the contract.

---

## §9.8 (NEW) — Variable reference syntax in workflow definitions

From `create_complete_workflow` docs:

- `{{trigger.fieldName}}` — for DATABASE_EVENT triggers
- `{{trigger.record.fieldName}}` — for MANUAL triggers when run on a record
- `{{<step-uuid>.result.fieldName}}` — for outputs of upstream steps. **Use the step UUID, NOT its `name`.**
- `{{now}}` and `{{currentWorkspaceMember.id}}` are likely available (to be verified at install time).

**Decision:** Workflow A's CREATE_RECORD step references `{{<guardAndSubmit-uuid>.result.testId}}`. The install script must capture each step's UUID after `create_workflow_version_step` returns and string-interpolate into the next step's `objectRecord` template.

---

## Summary of decisions encoded into downstream tasks

| Decision | Affects | Action |
|---|---|---|
| **SELECT values are UPPER_CASE** | All workflow tasks (3.x + 4.x) | Use `"MOBILE"`/`"SCANNING"`/`"COMPLETED"`/`"FAILED"` in workflow CODE bodies, FIND_RECORDS filters, IF_ELSE conditions, UPDATE_RECORD patches. NEVER lowercase. |
| Hardcode sitespeed Bearer in CODE source | Task 3.1 + 4.1 (templates) | `useTwentyApi` flag → drop. `sitespeedKey` always interpolated as string literal. |
| Decompose Workflow B with native steps | Task 4.1 + 4.2 (install) | Rewrite plan: FIND_RECORDS → ITERATOR → HTTP_REQUEST → CODE (parse) → UPDATE_RECORD ×2 + IF_ELSE |
| MCP-only install, not REST | Task 3.2 + 4.2 | Rewrite as MCP runbooks (.md) + buildDefinition() JS modules |
| Bootstrap uses MCP (not REST) for metadata too | Task 2.1 + 2.3 | `lib/twenty-client.js` becomes thin wrapper around MCP `execute_tool` calls (not REST) |
| Pre-flight check for §9.0 gate | Task 5.1 (README) | Add `doctor` instructions to detect disabled Logic Functions before user is confused |
| Bootstrap CONFIG_VARIABLE for sitespeed URL/KEY | Task 2.3 (optional follow-up) | If we later want to avoid hardcoding, write rows to `core.keyValuePair` |

**Cleanup:** The spike workflow `spike-hello (DELETE ME after spike)` (id `59ad998a-fa4f-4641-a82b-1615530d9dc8`) and its draft version `3f6f10da-4b29-4016-9d47-da5c91a6e193` should be deleted at end of Phase 1. Do via MCP `delete_workflow_version_step` + `destroyWorkflow` GraphQL mutation, OR via Twenty UI.

The spike scripts `01-probe-twenty-api.js` and `02-spike-workflow.md` can stay for archival reference (small files) but are no longer functional code.

---

## Outstanding empirical verification (do during Task 3.x)

1. **CODE `main(params)` shape on MANUAL trigger** — log `Object.keys(params)` on first Workflow A run; lock the contract in `code-step-submit.js`.
2. **SELECT `defaultValue` quoting convention** — if `'scanning'` (single-quoted string) is rejected by `create_field_metadata`, try plain `"scanning"`.
3. **`{{now}}` / `{{currentWorkspaceMember.id}}` availability** — try in Workflow A's CREATE_RECORD step; if not supported, set those fields in the CODE step's return value instead.

None of these are blocking. Each is a 1-line fix discoverable in the first install attempt.
