# Install Workflow B — "Reconcile site scans" (CRON)

Re-runnable operator runbook for installing Workflow B into Twenty via MCP.
All UUIDs in the **Captured IDs** section are from the 2026-05-18 install run.
To re-install from scratch, delete the existing workflow in Twenty UI, then follow all steps below.

---

## Prerequisites

1. Workflow A ("sitespeed-submit") is installed and active.
2. Bootstrap done: `siteScan` and `company` objects with all fields and cache fields exist.
3. Logic function execution is enabled. Verify:
   ```sql
   SELECT key, type, value FROM core."keyValuePair"
     WHERE key = 'LOGIC_FUNCTION_TYPE';
   -- Must return: ('LOGIC_FUNCTION_TYPE', 'CONFIG_VARIABLE', '"LOCAL"')
   ```
4. `integration/.env` is populated with `SITESPEED_API_URL` and `SITESPEED_API_KEY`.
5. An MCP-enabled agent session is open against the Twenty instance.

---

## Captured IDs (2026-05-18 install — v2 ACTIVE)

| Item | UUID |
|---|---|
| `siteScan` objectMetadataId | `bcb051da-9972-47c3-8b98-378efdd718ea` |
| `company` objectMetadataId | `dba9fdb8-084a-4b0a-b1b4-c298ee33192c` |
| workflowId | `d2cde5fb-4bc7-4f48-a265-27b861558a96` |
| workflowVersionId v1 (INACTIVE) | `d6da3322-d3f6-4199-a270-4545715798ba` |
| workflowVersionId v2 (ACTIVE) | `8a97cf51-00ae-4089-bf2a-9476336477bd` |
| Step 1 — FIND_RECORDS (findStepId) | `c4f40029-4495-48b2-829e-be21db1e6d30` |
| Step 2 — ITERATOR (iteratorStepId) | `978f7621-6670-4a06-88fe-e85b4ee566dc` |
| Step 3 — HTTP_REQUEST GET (httpGetStepId) | `8f4f60b6-14e1-4f90-b4a4-a28045e3c7e9` |
| Step 4 — CODE parse (parseCodeStepId) | `9cd43c70-c7f9-43ee-89e0-9f660b83fa53` |
| Step 4 — parse logicFunctionId (v2) | `294bc33b-811e-46f9-b872-f53a54ea5e5f` |
| Step 4b — IF_ELSE outer terminal guard (terminalIfStepId) | `2e80e33b-205b-48af-b360-393b1ddc727d` |
| outer IF_ELSE filter group ID | `d918b32f-dc63-4d9f-bf52-6e50e1894020` |
| outer IF_ELSE filter ID | `99e0d45c-e2c7-4e29-9ad5-f8ba5814bfc7` |
| outer IF_ELSE true branch ID | `061fc9a5-aca2-4d62-bf94-f324f95468d2` |
| outer IF_ELSE false branch ID | `1c3282aa-3b81-498c-966e-91eb5c3f873e` |
| Step 5 — UPDATE_RECORD SiteScan (updateSiteScanStepId) | `ceb5dd58-0e71-4564-92d7-614740c5946f` |
| Step 6 — IF_ELSE inner (ifCompletedStepId) | `6d04569e-5190-4e5e-a6a1-0e6795eb4c4b` |
| inner IF_ELSE filter group ID | `e66e7a2a-4c08-4516-8928-012f14468102` |
| inner IF_ELSE filter ID | `b0e94525-bb98-4f37-9e3f-4f3a8f296cac` |
| inner IF_ELSE true branch ID | `8ee97254-488c-49d2-9a13-6c6a51abd994` |
| inner IF_ELSE false branch ID | `3e305378-5737-4b69-8249-52a5e022a6af` |
| Step 7 — UPDATE_RECORD Company (updateCompanyStepId) | `3d533f63-7118-4697-af2b-bae51271809f` |
| Step 8 — HTTP_REQUEST DELETE (deleteStepId) | `8ce85015-016c-4f29-a6e5-a0ffc46bb385` |

---

## Final workflow graph (v2 — ACTIVE)

```
CRON (every 1 minute)
└─ c4f40029  FIND_RECORDS    Find scanning SiteScans
└─ 978f7621  ITERATOR        Iterator over scanning SiteScans
    └─ 8f4f60b6  HTTP_REQUEST    GET sitespeed test result     [continueOnFailure]
    └─ 9cd43c70  CODE            Parse sitespeed response      → {siteScanPatch, companyPatch, terminal}
    └─ 2e80e33b  IF_ELSE         terminal == true?
        ├─ true:  ceb5dd58  UPDATE_RECORD   Update SiteScan record
        │         └─ 6d04569e  IF_ELSE         companyPatch IS_NOT_NULL?
        │             ├─ true:  3d533f63  UPDATE_RECORD   Update Company cache fields
        │             │         └─ 8ce85015  HTTP_REQUEST    DELETE sitespeed result  [continueOnFailure]
        │             └─ false: (end — FAILED scan, no company update needed)
        └─ false: (end — scan still queued/running, skip all writes)
```

Orphan EMPTY steps auto-created by Twenty (`79dacaed`, `af50bf2c`, `369108b3`, `7c2298c3`, `f3f2dd92`) are
harmless — they are not reachable from the trigger chain.

---

## Step 1: Resolve object IDs

Call `get_object_metadata` (no args) and find:
- `siteScan` → `nameSingular: "siteScan"`, note its `id`
- `company` → `nameSingular: "company"`, note its `id`

These are stable per workspace. The IDs above are correct for workspace `e5d78559-47ec-4557-98ae-02d2b9da0067`.

---

## Step 2: Create the workflow (trigger + FIND_RECORDS, no other steps)

**Tool:** `create_complete_workflow`

> CRITICAL: Do NOT include CODE, ITERATOR, or IF_ELSE steps here — use individual
> `create_workflow_version_step` calls after creation.

**Important deviations confirmed from install-workflow-a.md:**
- Do NOT supply `edges` — causes `"Target step 'undefined' not found"`.
- Wire via `nextStepIds` on trigger and step objects.
- Pre-generate a UUID for the FIND_RECORDS step and include it in the trigger's `nextStepIds`.

```json
{
  "name": "sitespeed-reconcile",
  "description": "CRON every 60s: polls in-flight SiteScans against sitespeed-api, updates with results, denormalizes to Company cache.",
  "trigger": {
    "type": "CRON",
    "name": "Every 60 seconds",
    "position": { "x": 0, "y": 0 },
    "nextStepIds": ["<findStepId — pre-generate a UUID>"],
    "settings": {
      "type": "MINUTES",
      "schedule": { "minute": 1 },
      "outputSchema": {}
    }
  },
  "steps": [
    {
      "id": "<findStepId>",
      "name": "Find scanning SiteScans",
      "type": "FIND_RECORDS",
      "valid": true,
      "nextStepIds": ["<iteratorStepId — pre-generate another UUID>"],
      "position": { "x": 0, "y": 200 },
      "settings": {
        "input": {
          "objectName": "siteScan",
          "filter": {
            "gqlOperationFilter": {
              "and": [
                { "status": { "eq": "SCANNING" } }
              ]
            }
          },
          "limit": 50
        },
        "outputSchema": {},
        "errorHandlingOptions": {
          "retryOnFailure":    { "value": false },
          "continueOnFailure": { "value": false }
        }
      }
    }
  ],
  "activate": false
}
```

**Capture from response:** `workflowId`, `workflowVersionId`.

**CRON settings shape:**
- `type: "MINUTES"` with `schedule: { "minute": 1 }` = "every 1 minute"
- The `minute` key value is the interval, not a specific minute (1–60 range)

---

## Step 3: Add ITERATOR step

**Tool:** `create_workflow_version_step`

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "parentStepId": "<findStepId>",
  "stepType": "ITERATOR",
  "position": { "x": 0, "y": 400 }
}
```

**CRITICAL deviation discovered during install:**
`create_workflow_version_step` for ITERATOR auto-assigns its own UUID (ignores any pre-generated UUID in
`nextStepIds` of the parent). **Capture the actual UUID from the response** — do not use the pre-generated one.

The ITERATOR creates an EMPTY placeholder child step (`initialLoopStepIds`) automatically. This placeholder
will be replaced when the first real child (HTTP_REQUEST) is added.

**Capture from response:** the new step's `id` (iteratorStepId).

**Then immediately update it with `update_workflow_version_step`** to set the items source
and the actual first loop step ID (which you won't know until Step 4). You can pre-set
items now and fix `initialLoopStepIds` after Step 4:

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<iteratorStepId>",
    "name": "Iterator over scanning SiteScans",
    "type": "ITERATOR",
    "valid": true,
    "nextStepIds": null,
    "position": { "x": 0, "y": 400 },
    "settings": {
      "input": {
        "items": "{{<findStepId>.result.records}}",
        "initialLoopStepIds": ["<httpGetStepId — set after Step 4>"],
        "shouldContinueOnIterationFailure": true
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure":    { "value": false },
        "continueOnFailure": { "value": false }
      }
    }
  }
}
```

**ITERATOR schema notes (empirically verified):**
- `settings.input.items`: string template reference to the array — `"{{<findStepId>.result.records}}"`
- `settings.input.initialLoopStepIds`: array of the first step's UUID(s) inside the loop
- `settings.input.shouldContinueOnIterationFailure`: set to `true` so one bad record doesn't halt all others
- `nextStepIds: null` — the ITERATOR has no outer next step; it is terminal in the outer graph
- Within the loop, variables are accessed as `{{<iteratorStepId>.currentItem.fieldName}}`
  (NOT `{{item.fieldName}}` — the iterator UUID prefix is required)
- The ITERATOR's outputSchema auto-populates with the full SiteScan field list once `items` is set

---

## Step 4: Add HTTP_REQUEST GET step (inside iterator)

**Tool:** `create_workflow_version_step`

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "parentStepId": "<iteratorStepId>",
  "stepType": "HTTP_REQUEST",
  "position": { "x": 0, "y": 600 }
}
```

**Capture from response:** `id` (httpGetStepId).

**Then update ITERATOR** to set `initialLoopStepIds: ["<httpGetStepId>"]` (see Step 3 above).

**Then update HTTP_REQUEST:**

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<httpGetStepId>",
    "name": "GET sitespeed test result",
    "type": "HTTP_REQUEST",
    "valid": true,
    "nextStepIds": ["<parseCodeStepId — set after Step 5>"],
    "position": { "x": 0, "y": 600 },
    "settings": {
      "input": {
        "url": "https://speed.elevaseo.app/api/test/{{<iteratorStepId>.currentItem.testId}}",
        "method": "GET",
        "headers": {
          "Authorization": "Bearer <SITESPEED_API_KEY>"
        }
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure":    { "value": false },
        "continueOnFailure": { "value": true }
      }
    }
  }
}
```

**CRITICAL:** `continueOnFailure: true` is required. Without it, a 404 (expired test) would abort the
entire CRON run for that scan item. With it, the flow continues to the CODE step, which handles 404
by emitting a FAILED patch.

**HTTP_REQUEST output shape (empirically confirmed via outputSchema):**
- `params.body` — parsed JSON response body
- `params.status` — HTTP status code (number)
- `params.headers` — response headers
- In subsequent steps: `{{<httpGetStepId>.result.body}}`, `{{<httpGetStepId>.result.status}}`

---

## Step 5: Add CODE "parseReconcile" step

**Tool:** `create_workflow_version_step`

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "parentStepId": "<httpGetStepId>",
  "stepType": "CODE",
  "position": { "x": 0, "y": 800 }
}
```

**Capture from response:** `id` (parseCodeStepId), `settings.input.logicFunctionId` (parseLogicFunctionId).

**Rename the step:**

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<parseCodeStepId>",
    "name": "Parse sitespeed response",
    "type": "CODE",
    "valid": false,
    "nextStepIds": ["<updateSiteScanStepId — set after Step 6>"],
    "position": { "x": 0, "y": 800 },
    "settings": {
      "input": {
        "logicFunctionId": "<parseLogicFunctionId>",
        "logicFunctionInput": {
          "testId": "{{<iteratorStepId>.currentItem.testId}}"
        }
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure":    { "value": false },
        "continueOnFailure": { "value": false }
      }
    }
  }
}
```

**Upload CODE source:**

Generate the source:

```bash
cd integration
node --input-type=module << 'EOF'
import dotenv from 'dotenv';
dotenv.config();
const { buildReconcileCodeBody } = await import('./workflows/code-step-reconcile.js');
const src = buildReconcileCodeBody({
  sitespeedUrl: process.env.SITESPEED_API_URL,
  sitespeedKey: process.env.SITESPEED_API_KEY,   // required — CODE step does its own fetch (post v5 consolidation)
});
process.stdout.write(src);
EOF
```

Then call `update_logic_function_source`:

```json
{
  "logicFunctionId": "<parseLogicFunctionId>",
  "code": "<output of the above command>"
}
```

**CODE step output shape:**
- `siteScanPatch` — object with all SiteScan fields to update, OR `null` if still queued/running
- `companyPatch` — object with Company cache fields, OR `null` if not COMPLETED
- `terminal` — boolean: `true` if the scan reached a final state (COMPLETED or FAILED)

**404 handling (option B — in CODE):**
The CODE step detects `params.status >= 400` and emits a FAILED patch with
`error: "test result expired (sitespeed 2h cleanup)"`. The HTTP_REQUEST step's
`continueOnFailure: true` is what allows 404 to reach the CODE step at all.

---

## Step 5b: Add outer IF_ELSE terminal guard

**Tool:** `create_workflow_version_step` with `parentStepId: <parseCodeStepId>` and
`nextStepId: <updateSiteScanStepId>`, then `update_workflow_version_step`.

This guard prevents null writes to SiteScan for scans still queued/running (where CODE returns
`terminal: false`). Only terminal states (COMPLETED or FAILED) proceed to the update chain.

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "parentStepId": "<parseCodeStepId>",
  "nextStepId": "<updateSiteScanStepId>",
  "stepType": "IF_ELSE",
  "position": { "x": 0, "y": 900 }
}
```

**Capture from response:** `id` (terminalIfStepId), and from the auto-created structure:
- `stepFilterGroups[0].id` → outerFilterGroupId
- `stepFilters[0].id` → outerFilterId
- `branches[0].id` → outerTrueBranchId
- `branches[1].id` → outerFalseBranchId

**Update the IF_ELSE** to condition on `terminal IS true`:

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<terminalIfStepId>",
    "name": "terminal? Update records",
    "type": "IF_ELSE",
    "valid": true,
    "nextStepIds": null,
    "settings": {
      "input": {
        "stepFilterGroups": [
          { "id": "<outerFilterGroupId>", "logicalOperator": "AND" }
        ],
        "stepFilters": [
          {
            "id": "<outerFilterId>",
            "type": "boolean",
            "stepOutputKey": "{{<parseCodeStepId>.result.terminal}}",
            "operand": "IS",
            "value": "true",
            "stepFilterGroupId": "<outerFilterGroupId>",
            "positionInStepFilterGroup": 0
          }
        ],
        "branches": [
          {
            "id": "<outerTrueBranchId>",
            "filterGroupId": "<outerFilterGroupId>",
            "nextStepIds": ["<updateSiteScanStepId>"]
          },
          {
            "id": "<outerFalseBranchId>",
            "nextStepIds": []
          }
        ]
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure":    { "value": false },
        "continueOnFailure": { "value": false }
      }
    }
  }
}
```

**Note:** When adding with `nextStepId`, Twenty auto-wires `CODE.nextStepIds → [terminalIfStepId]`
and `terminalIfStepId.nextStepIds → [updateSiteScanStepId]`. Verify with `get_workflow_current_version`.

---

## Step 6: Add UPDATE_RECORD for SiteScan

**Tool:** `create_workflow_version_step`, then `update_workflow_version_step`

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "parentStepId": "<parseCodeStepId>",
  "stepType": "UPDATE_RECORD",
  "position": { "x": 0, "y": 1000 }
}
```

**Capture:** `id` (updateSiteScanStepId). Then update:

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<updateSiteScanStepId>",
    "name": "Update SiteScan record",
    "type": "UPDATE_RECORD",
    "valid": true,
    "nextStepIds": ["<ifTerminalStepId — set after Step 7>"],
    "position": { "x": 0, "y": 1000 },
    "settings": {
      "input": {
        "objectName": "siteScan",
        "objectRecordId": "{{<iteratorStepId>.currentItem.id}}",
        "objectRecord": {
          "status":                "{{<parseCodeStepId>.result.siteScanPatch.status}}",
          "error":                 "{{<parseCodeStepId>.result.siteScanPatch.error}}",
          "completedAt":           "{{<parseCodeStepId>.result.siteScanPatch.completedAt}}",
          "perfScore":             "{{<parseCodeStepId>.result.siteScanPatch.perfScore}}",
          "seoScore":              "{{<parseCodeStepId>.result.siteScanPatch.seoScore}}",
          "accessibilityScore":    "{{<parseCodeStepId>.result.siteScanPatch.accessibilityScore}}",
          "bestPracticesScore":    "{{<parseCodeStepId>.result.siteScanPatch.bestPracticesScore}}",
          "lcpMs":                 "{{<parseCodeStepId>.result.siteScanPatch.lcpMs}}",
          "clsScore":              "{{<parseCodeStepId>.result.siteScanPatch.clsScore}}",
          "ttfbMs":                "{{<parseCodeStepId>.result.siteScanPatch.ttfbMs}}",
          "totalRequests":         "{{<parseCodeStepId>.result.siteScanPatch.totalRequests}}",
          "transferSizeKb":        "{{<parseCodeStepId>.result.siteScanPatch.transferSizeKb}}",
          "topOpportunity":        "{{<parseCodeStepId>.result.siteScanPatch.topOpportunity}}",
          "topOpportunitySavingsMs":"{{<parseCodeStepId>.result.siteScanPatch.topOpportunitySavingsMs}}",
          "reportUrl":             "{{<parseCodeStepId>.result.siteScanPatch.reportUrl}}"
        },
        "fieldsToUpdate": [
          "status", "error", "completedAt", "perfScore", "seoScore",
          "accessibilityScore", "bestPracticesScore", "lcpMs", "clsScore",
          "ttfbMs", "totalRequests", "transferSizeKb", "topOpportunity",
          "topOpportunitySavingsMs", "reportUrl"
        ]
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure":    { "value": false },
        "continueOnFailure": { "value": false }
      }
    }
  }
}
```

**Note:** This UPDATE_RECORD is only reached when the outer IF_ELSE (`terminal == true`) passes.
When `terminal == false` (scan still queued/running), the entire update chain is skipped — no null
writes occur. The outer IF_ELSE guard (`2e80e33b`) is inserted between CODE and this step in v2.

---

## Step 7: Add IF_ELSE step

**Tool:** `create_workflow_version_step`, then `update_workflow_version_step`

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "parentStepId": "<updateSiteScanStepId>",
  "stepType": "IF_ELSE",
  "position": { "x": 0, "y": 1200 }
}
```

**Capture:** `id` (ifTerminalStepId). Also capture from the auto-created structure:
- `stepFilterGroups[0].id` → filterGroupId
- `stepFilters[0].id` → filterId
- `branches[0].id` → trueBranchId (matched condition)
- `branches[1].id` → falseBranchId (else)

The IF_ELSE auto-creates two EMPTY placeholder steps for true and false branches. These placeholders
stay in the step list as orphans after you wire real steps — this is normal Twenty behavior.

**Update the IF_ELSE** to condition on `companyPatch IS_NOT_NULL`:

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<ifTerminalStepId>",
    "name": "COMPLETED? Update Company + cleanup",
    "type": "IF_ELSE",
    "valid": true,
    "nextStepIds": null,
    "position": { "x": 0, "y": 1200 },
    "settings": {
      "input": {
        "stepFilterGroups": [
          { "id": "<filterGroupId>", "logicalOperator": "AND" }
        ],
        "stepFilters": [
          {
            "id": "<filterId>",
            "type": "boolean",
            "stepOutputKey": "{{<parseCodeStepId>.result.companyPatch}}",
            "operand": "IS_NOT_NULL",
            "value": "",
            "stepFilterGroupId": "<filterGroupId>",
            "positionInStepFilterGroup": 0
          }
        ],
        "branches": [
          {
            "id": "<trueBranchId>",
            "filterGroupId": "<filterGroupId>",
            "nextStepIds": ["<updateCompanyStepId — set after Step 8>"]
          },
          {
            "id": "<falseBranchId>",
            "nextStepIds": []
          }
        ]
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure":    { "value": false },
        "continueOnFailure": { "value": false }
      }
    }
  }
}
```

**IF_ELSE schema notes (empirically verified):**
- `stepFilterGroups`: array with at least one group; each has `id` and `logicalOperator`
- `stepFilters`: array of filter conditions; `stepOutputKey` is the variable reference (full template syntax)
- `operand` accepts both camelCase (`isNotNull`) and SCREAMING_SNAKE (`IS_NOT_NULL`) — both appear valid in schema
- `branches`: first element = matched (true) branch; second = else branch
- False/else branch: `nextStepIds: []` (empty array, not `null`) to end the iteration
- `nextStepIds: null` on the IF_ELSE step itself (it is terminal in the outer chain)
- Auto-created EMPTY placeholder steps for each branch are harmless orphans

---

## Step 8: Add UPDATE_RECORD for Company

**Tool:** `create_workflow_version_step` with `parentStepId: <ifTerminalStepId>` and
`parentStepConnectionOptions: { "type": "IF_ELSE", "conditionGroupIndex": 0 }`,
then `update_workflow_version_step`.

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "parentStepId": "<ifTerminalStepId>",
  "stepType": "UPDATE_RECORD",
  "parentStepConnectionOptions": { "type": "IF_ELSE", "conditionGroupIndex": 0 },
  "position": { "x": -200, "y": 1400 }
}
```

**Capture:** `id` (updateCompanyStepId). **Then update IF_ELSE** true branch to point to this UUID
(see Step 7 — you need to patch `branches[0].nextStepIds`).

**Then update the Company UPDATE_RECORD:**

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<updateCompanyStepId>",
    "name": "Update Company cache fields",
    "type": "UPDATE_RECORD",
    "valid": true,
    "nextStepIds": ["<deleteStepId — set after Step 9>"],
    "position": { "x": -200, "y": 1400 },
    "settings": {
      "input": {
        "objectName": "company",
        "objectRecordId": "{{<iteratorStepId>.currentItem.companyId}}",
        "objectRecord": {
          "lastSiteScanAt":        "{{<parseCodeStepId>.result.companyPatch.lastSiteScanAt}}",
          "perfScore":             "{{<parseCodeStepId>.result.companyPatch.perfScore}}",
          "seoScore":              "{{<parseCodeStepId>.result.companyPatch.seoScore}}",
          "accessibilityScore":    "{{<parseCodeStepId>.result.companyPatch.accessibilityScore}}",
          "bestPracticesScore":    "{{<parseCodeStepId>.result.companyPatch.bestPracticesScore}}",
          "lcpMs":                 "{{<parseCodeStepId>.result.companyPatch.lcpMs}}",
          "clsScore":              "{{<parseCodeStepId>.result.companyPatch.clsScore}}",
          "ttfbMs":                "{{<parseCodeStepId>.result.companyPatch.ttfbMs}}",
          "totalRequests":         "{{<parseCodeStepId>.result.companyPatch.totalRequests}}",
          "transferSizeKb":        "{{<parseCodeStepId>.result.companyPatch.transferSizeKb}}",
          "topOpportunity":        "{{<parseCodeStepId>.result.companyPatch.topOpportunity}}",
          "topOpportunitySavingsMs":"{{<parseCodeStepId>.result.companyPatch.topOpportunitySavingsMs}}",
          "siteScanReportUrl":     "{{<parseCodeStepId>.result.companyPatch.siteScanReportUrl}}"
        },
        "fieldsToUpdate": [
          "lastSiteScanAt", "perfScore", "seoScore", "accessibilityScore",
          "bestPracticesScore", "lcpMs", "clsScore", "ttfbMs", "totalRequests",
          "transferSizeKb", "topOpportunity", "topOpportunitySavingsMs", "siteScanReportUrl"
        ]
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure":    { "value": false },
        "continueOnFailure": { "value": false }
      }
    }
  }
}
```

---

## Step 9: Add HTTP_REQUEST DELETE step (cleanup)

**Tool:** `create_workflow_version_step` with `parentStepId: <updateCompanyStepId>`,
then `update_workflow_version_step`.

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "parentStepId": "<updateCompanyStepId>",
  "stepType": "HTTP_REQUEST",
  "position": { "x": -200, "y": 1600 }
}
```

**Capture:** `id` (deleteStepId). **Then update Company UPDATE_RECORD** `nextStepIds` to include this UUID.

**Then update DELETE HTTP_REQUEST:**

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<deleteStepId>",
    "name": "DELETE sitespeed test result (cleanup)",
    "type": "HTTP_REQUEST",
    "valid": true,
    "nextStepIds": null,
    "position": { "x": -200, "y": 1600 },
    "settings": {
      "input": {
        "url": "https://speed.elevaseo.app/api/test/{{<iteratorStepId>.currentItem.testId}}",
        "method": "DELETE",
        "headers": {
          "Authorization": "Bearer <SITESPEED_API_KEY>"
        }
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure":    { "value": false },
        "continueOnFailure": { "value": true }
      }
    }
  }
}
```

**`continueOnFailure: true`** — best-effort cleanup; a 404 here (already expired or already deleted) must
not fail the iteration.

---

## Step 10: Fix wiring — remove phantom nextStepIds

Because steps are added sequentially and `create_workflow_version_step` auto-wires the parent's
`nextStepIds`, you will end up with phantom UUIDs in several steps' `nextStepIds` arrays. After all
steps are created, run `get_workflow_current_version` and identify any step whose `nextStepIds` array
has more than one entry (or an entry pointing to a non-existent step). Fix each via `update_workflow_version_step`
to set `nextStepIds` to a single-element array with the correct UUID.

**Typical phantoms to fix:**
- FIND_RECORDS: remove pre-generated ITERATOR UUID (use actual one from step creation)
- HTTP_REQUEST GET: remove any phantom from pre-wiring (set to `[<parseCodeStepId>]`)
- CODE parse: remove any phantom (set to `[<updateSiteScanStepId>]`)
- UPDATE_RECORD SiteScan: remove any phantom (set to `[<ifTerminalStepId>]`)
- Company UPDATE_RECORD: remove any phantom (set to `[<deleteStepId>]`)
- ITERATOR: set `nextStepIds: null`
- IF_ELSE: set `nextStepIds: null`
- DELETE HTTP_REQUEST: set `nextStepIds: null`

---

## Step 11: Activate

**Tool:** `activate_workflow_version`

```json
{ "workflowVersionId": "<workflowVersionId>" }
```

Expected response: `true`

---

## Step 12: Verify

**Tool:** `get_workflow_current_version`

```json
{ "workflowId": "<workflowId>" }
```

Check:
- `status: "ACTIVE"`
- Trigger type: `CRON`, settings: `{ "type": "MINUTES", "schedule": { "minute": 1 } }`
- Main chain: FIND_RECORDS → ITERATOR → (HTTP_REQUEST → CODE → UPDATE_RECORD_siteScan → IF_ELSE → UPDATE_RECORD_company → DELETE)
- ITERATOR `nextStepIds: null`, `initialLoopStepIds: [<httpGetStepId>]`
- IF_ELSE true branch → Company UPDATE_RECORD, false branch → `[]`
- HTTP GET `continueOnFailure: true`, DELETE `continueOnFailure: true`

---

## Final verification output (2026-05-18 install — v2 ACTIVE)

```
status: ACTIVE (version v2 — 8a97cf51)
trigger: CRON, MINUTES, minute: 1
steps (main chain):
  c4f40029  FIND_RECORDS     Find scanning SiteScans             → [978f7621]
  978f7621  ITERATOR         Iterator over scanning SiteScans    → null
                             initialLoopStepIds: [8f4f60b6]
  8f4f60b6  HTTP_REQUEST     GET sitespeed test result           → [9cd43c70]   continueOnFailure
  9cd43c70  CODE             Parse sitespeed response            → [2e80e33b]
  2e80e33b  IF_ELSE          terminal == true?                   → null
              filter: {{9cd43c70...result.terminal}} IS true
              true  → [ceb5dd58]
              false → []
  ceb5dd58  UPDATE_RECORD    Update SiteScan record              → [6d04569e]
  6d04569e  IF_ELSE          companyPatch IS_NOT_NULL?           → null
              filter: {{9cd43c70...result.companyPatch}} IS_NOT_NULL
              true  → [3d533f63]
              false → []
  3d533f63  UPDATE_RECORD    Update Company cache fields         → [8ce85015]
  8ce85015  HTTP_REQUEST     DELETE sitespeed result (cleanup)   → null         continueOnFailure

orphans (harmless EMPTY steps auto-created by Twenty):
  79dacaed  EMPTY  (ITERATOR placeholder)
  af50bf2c  EMPTY  (inner IF_ELSE true placeholder)
  369108b3  EMPTY  (inner IF_ELSE false placeholder)
  7c2298c3  EMPTY  (outer IF_ELSE true placeholder)
  f3f2dd92  EMPTY  (outer IF_ELSE false placeholder)
```

> NOTE: CODE step shows `valid: false` — this is normal. Twenty marks CODE steps invalid
> until they have been manually tested/saved in the workflow UI. The logic function source
> is uploaded and executes correctly at runtime.
>
> v1 (d6da3322) was created without the outer terminal guard — it is INACTIVE. v2 adds
> the `terminal == true` IF_ELSE to prevent null writes for queued/running scans.

---

## Known deviations from original spec / new discoveries

| Spec assumption | Reality |
|---|---|
| `create_workflow_version_step` assigns the UUID you want | UUIDs are always auto-assigned; pre-generated UUIDs end up as phantom `nextStepIds` entries that must be cleaned up |
| `parentStepConnectionOptions: { type, conditionGroupIndex }` wires IF_ELSE branches | Does NOT wire the IF_ELSE branches automatically. Must patch IF_ELSE `branches[].nextStepIds` via `update_workflow_version_step` explicitly |
| ITERATOR child steps referenced as `{{item.fieldName}}` | Actual reference syntax is `{{<iteratorStepId>.currentItem.fieldName}}` — the iterator UUID is required as prefix |
| ITERATOR `settings.input.iterableArray` | Key is `settings.input.items` (string template, not `iterableArray`) |
| IF_ELSE false branch needs `nextStepIds: null` | Use `nextStepIds: []` (empty array) to end iteration — `null` may cause serialization issues |
| `create_workflow_version_step` for non-CODE steps with `defaultSettings` applies settings | Confirmed again: settings silently ignored. Always follow with `update_workflow_version_step`. |
| HTTP_REQUEST 404 causes workflow run failure | With `continueOnFailure: true`, 404 flows to next step normally. The CODE step receives the HTTP status and handles it. |
| `params.httpResponse.body` is the CODE step access path | Actual paths are `params.body` (body) and `params.status` (status code) — direct top-level keys, not nested under `httpResponse` |

---

## Re-running the install

1. Delete the existing workflow in Twenty UI (Automation section) or via GraphQL `destroyWorkflow` mutation.
2. Follow Steps 1–12 above.
3. Step 5's source is regenerated from `code-step-reconcile.js` at install time.
