# Install Workflow A — "Submit site scan"

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

---

## Prerequisites

1. Bootstrap done: `siteScan` custom object, all scalar fields, and relations (`company`, `triggeredBy`) exist.
2. Company cache fields exist on the `company` object.
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"')
   ```
   If missing, run:
   ```sql
   INSERT INTO core."keyValuePair" (key, type, value)
   VALUES ('LOGIC_FUNCTION_TYPE', 'CONFIG_VARIABLE', '"LOCAL"')
   ON CONFLICT (key) DO UPDATE SET value = '"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 (v3 ACTIVE — 2026-05-18 bug fix)

| Item | UUID |
|---|---|
| `siteScan` objectMetadataId | `bcb051da-9972-47c3-8b98-378efdd718ea` |
| `company` objectMetadataId | `dba9fdb8-084a-4b0a-b1b4-c298ee33192c` |
| workflowId | `194386be-b786-4542-bff2-c16652e58b34` |
| workflowVersionId v1 (INACTIVE) | `8d0283a4-2f45-4686-a424-8606bc0494a7` |
| workflowVersionId v2 (INACTIVE) | `12bd7918-9d12-488f-8b66-61623290bf7a` |
| workflowVersionId v3 (ACTIVE) | `153f33e8-5212-4032-8ad2-be54a7079eeb` |
| Step 1 — FIND_RECORDS (findStepId) | `67c84fff-4992-4dfa-bcb1-bc77ca4df92f` |
| Step 2 — CODE guard (guardStepId) | `35d45afd-1348-4231-a3e4-b1f176ac8936` |
| Step 2 — guard logicFunctionId (v3) | `6162f781-6fe4-446d-90e9-926644f04468` |
| Step 3 — CODE submit (submitStepId) | `1c56bc75-23f3-44d6-b77f-9b11b2951240` |
| Step 3 — submit logicFunctionId (v3) | `6dd1ea3c-e782-4f14-9c18-295afac1cd37` |
| Step 4 — CREATE_RECORD (createStepId) | `7f94a70a-ec76-4770-b6c0-2909df75231c` |

> **Bug fix history:**
> - v1 → v2 (2026-05-17): Wrong URL field — `record.domainNamePrimaryLinkUrl` → `record.domainName.primaryLinkUrl`
> - v2 → v3 (2026-05-18): Two critical fixes:
>   1. `logicFunctionInput` was `{}` on both CODE steps — runtime received empty `params`, causing
>      "Company has no website URL" even when the URL was set. Fixed by injecting trigger data
>      explicitly via `logicFunctionInput` templates.
>   2. FIND_RECORDS filter used `{{trigger.record.id}}` — MANUAL trigger output is **flat** (no `.record`
>      wrapper). Fixed to `{{trigger.id}}`. Verified empirically from `stepInfos.trigger.result` keys.
>   3. CODE step sources updated to read `params.url`, `params.companyId`, `params.triggeredById`
>      directly (injected via logicFunctionInput) instead of walking `params.trigger.record.*`.

---

## 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 CODE steps)

**Tool:** `create_complete_workflow`

> CRITICAL: Do NOT include CODE steps here. `create_complete_workflow` cannot create the
> underlying logic function for CODE steps — those must be added via `create_workflow_version_step`.

**Important deviation discovered during install:**
- Do NOT supply an `edges` array — it causes `"Target step 'undefined' not found"` errors.
- Instead, wire steps via `nextStepIds` on the trigger and step objects; the tool builds edges automatically.
- The `stepPositions` array is also optional and can be omitted.

**CRITICAL: MANUAL trigger output is flat — no `.record` wrapper.**
All trigger field references must use `{{trigger.fieldName}}`, NOT `{{trigger.record.fieldName}}`.
Empirically verified from `stepInfos.trigger.result` keys: `id, name, address, domainName, employees, ...`

```json
{
  "name": "sitespeed-submit",
  "description": "Triggered manually from a Company record. Checks for in-flight scans, submits a sitespeed scan, and creates a SiteScan record.",
  "trigger": {
    "type": "MANUAL",
    "name": "Scan website",
    "position": { "x": 0, "y": 0 },
    "nextStepIds": ["<findStepId — pre-generate a UUID>"],
    "settings": {
      "objectType": "company",
      "outputSchema": {},
      "icon": "IconGauge",
      "isPinned": true,
      "availability": { "type": "SINGLE_RECORD", "objectNameSingular": "company" }
    }
  },
  "steps": [
    {
      "id": "<findStepId>",
      "name": "Find in-flight scans",
      "type": "FIND_RECORDS",
      "valid": true,
      "nextStepIds": null,
      "position": { "x": 0, "y": 200 },
      "settings": {
        "input": {
          "objectName": "siteScan",
          "limit": 1,
          "filter": {
            "gqlOperationFilter": {
              "and": [
                { "companyId": { "eq": "{{trigger.id}}" } },
                { "status":    { "eq": "SCANNING" } }
              ]
            }
          }
        },
        "outputSchema": {},
        "errorHandlingOptions": {
          "retryOnFailure":    { "value": false },
          "continueOnFailure": { "value": false }
        }
      }
    }
  ],
  "activate": false
}
```

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

**Filter shape note:** The FIND_RECORDS `gqlOperationFilter` uses Twenty's GraphQL filter format:
- Relation FK fields: `companyId` (flat, not `company.id`)
- Operator: `eq` (lowercase)
- SELECT enum values: UPPER_CASE (`"SCANNING"`, not `"scanning"`)

---

## Step 3: Add CODE "guardInFlight" step

**Tool:** `create_workflow_version_step`

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

**Capture from response:** the new step's `id` (guardStepId) and `settings.input.logicFunctionId` (guardLogicFunctionId).

The step is created with a placeholder function. Proceed to Step 5 to upload the real source.

---

## Step 4: Add CODE "submitToSitespeed" step

**Tool:** `create_workflow_version_step`

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

**Capture from response:** the new step's `id` (submitStepId) and `settings.input.logicFunctionId` (submitLogicFunctionId).

---

## Step 5: Add CREATE_RECORD step

**Important deviation discovered during install:**
`create_workflow_version_step` ignores `defaultSettings` for non-CODE step types.
The step is created with `objectName: "note"` and empty `objectRecord`.
You MUST call `update_workflow_version_step` immediately after to set the correct settings.

**Tool:** `create_workflow_version_step` (creates the step, then update below)

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

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

**Then immediately update it with `update_workflow_version_step`:**

```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<createStepId>",
    "name": "Create SiteScan record",
    "type": "CREATE_RECORD",
    "valid": true,
    "nextStepIds": null,
    "position": { "x": 0, "y": 800 },
    "settings": {
      "input": {
        "objectName": "siteScan",
        "objectRecord": {
          "testId":        "{{<submitStepId>.result.testId}}",
          "url":           "{{<submitStepId>.result.url}}",
          "device":        "MOBILE",
          "status":        "SCANNING",
          "companyId":     "{{<submitStepId>.result.companyId}}",
          "triggeredById": "{{<submitStepId>.result.triggeredById}}"
        }
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure":    { "value": false },
        "continueOnFailure": { "value": false }
      }
    }
  }
}
```

**Relation field note:** MANY_TO_ONE relations in CREATE_RECORD use flat FK fields:
- `companyId` (not `company: { id: "..." }`)
- `triggeredById` (not `triggeredBy: { id: "..." }`)

---

## Step 6: Rename + configure CODE steps (set logicFunctionInput)

Use `update_workflow_version_step` on each CODE step to set a human-readable name
AND the critical `logicFunctionInput` mapping.

**CRITICAL: `logicFunctionInput` must NOT be empty `{}`.**
CODE steps receive an empty `params` object at runtime if `logicFunctionInput` is `{}`.
Trigger data and upstream step outputs must be explicitly injected as template strings.

Guard step:
```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<guardStepId>",
    "name": "Guard: no in-flight scan",
    "type": "CODE",
    "valid": false,
    "nextStepIds": ["<submitStepId>"],
    "position": { "x": 0, "y": 400 },
    "settings": {
      "input": {
        "logicFunctionId": "<guardLogicFunctionId>",
        "logicFunctionInput": {
          "records": "{{<findStepId>.all}}"
        }
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure": { "value": false },
        "continueOnFailure": { "value": false }
      }
    }
  }
}
```

Submit step:
```json
{
  "workflowVersionId": "<workflowVersionId>",
  "step": {
    "id": "<submitStepId>",
    "name": "Submit scan to sitespeed",
    "type": "CODE",
    "valid": false,
    "nextStepIds": ["<createStepId>"],
    "position": { "x": 0, "y": 600 },
    "settings": {
      "input": {
        "logicFunctionId": "<submitLogicFunctionId>",
        "logicFunctionInput": {
          "url":           "{{trigger.domainName.primaryLinkUrl}}",
          "companyId":     "{{trigger.id}}",
          "triggeredById": "{{trigger.createdBy.workspaceMemberId}}"
        }
      },
      "outputSchema": {},
      "errorHandlingOptions": {
        "retryOnFailure": { "value": false },
        "continueOnFailure": { "value": false }
      }
    }
  }
}
```

**Template path notes (empirically verified):**
- `{{trigger.id}}` — Company UUID. MANUAL trigger output is flat; `{{trigger.record.id}}` does NOT resolve.
- `{{trigger.domainName.primaryLinkUrl}}` — Company URL (nested LINKS field).
- `{{trigger.createdBy.workspaceMemberId}}` — Workspace member who clicked the button. Shape: `{source, workspaceMemberId, name, context}`. If this doesn't resolve, `triggeredById` is nullable on SiteScan — the CODE source defaults it to `null`.
- `{{<findStepId>.all}}` — FIND_RECORDS result array for the guard.

---

## Step 7: Upload guard logic function source

**Tool:** `update_logic_function_source`

```json
{
  "logicFunctionId": "<guardLogicFunctionId>",
  "code": "<output of buildGuardCodeBody()>"
}
```

Generate the source:

```bash
cd integration
node --input-type=module << 'EOF'
import { buildGuardCodeBody } from './workflows/code-step-submit.js';
process.stdout.write(buildGuardCodeBody());
EOF
```

Pass the output verbatim as the `code` argument.

Guard TypeScript source (for reference):

```typescript
// AUTO-GENERATED by integration/workflows/code-step-submit.js
// Workflow A — Step: Guard: no in-flight scan
// DO NOT hand-edit; re-run the installer to regenerate.

export const main = async (params: Record<string, unknown>) => {
  const records = (params.records as any[]) ?? [];
  if (records.length > 0) {
    throw new Error("A site scan is already in progress for this Company. Please wait for it to finish before launching another.");
  }
  return { ok: true };
};
```

`params.records` is populated at runtime from `logicFunctionInput: { "records": "{{<findStepId>.all}}" }`.

---

## Step 8: Upload submit logic function source

**Tool:** `update_logic_function_source`

```json
{
  "logicFunctionId": "<submitLogicFunctionId>",
  "code": "<output of buildSubmitCodeBody({ sitespeedUrl, sitespeedKey })>"
}
```

Generate the source:

```bash
cd integration
node --input-type=module << 'EOF'
import 'dotenv/config';
const { buildSubmitCodeBody } = await import('./workflows/code-step-submit.js');
const src = buildSubmitCodeBody({
  sitespeedUrl: process.env.SITESPEED_API_URL,
  sitespeedKey: process.env.SITESPEED_API_KEY,
});
process.stdout.write(src);
EOF
```

Pass the output verbatim as the `code` argument.

The generated source reads `params.url`, `params.companyId`, `params.triggeredById` directly —
these are injected by the workflow definition's `logicFunctionInput` templates.
It does NOT walk `params.trigger.record.*` (that path never resolves at runtime).

---

## Step 9: Activate

**Tool:** `activate_workflow_version`

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

Expected response: `true`

---

## Step 10: Verify

**Tool:** `get_workflow_current_version`

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

Expected response shape (verify all fields):
- `status: "ACTIVE"`
- 4 steps in order: `FIND_RECORDS` → `CODE` → `CODE` → `CREATE_RECORD`
- FIND_RECORDS filter uses `{{trigger.id}}` (NOT `{{trigger.record.id}}`)
- Guard CODE step: `logicFunctionInput.records` = `"{{<findStepId>.all}}"` (NOT empty `{}`)
- Submit CODE step: `logicFunctionInput.url`, `.companyId`, `.triggeredById` all set (NOT empty `{}`)
- Each step's `nextStepIds` points to the correct following step
- Final step has `nextStepIds: null`

---

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

```json
{
  "workflowVersion": {
    "id": "153f33e8-5212-4032-8ad2-be54a7079eeb",
    "name": "v3",
    "status": "ACTIVE",
    "trigger": {
      "name": "Scan website",
      "type": "MANUAL",
      "settings": {
        "icon": "IconGauge",
        "isPinned": true,
        "objectType": "company",
        "availability": { "type": "SINGLE_RECORD", "objectNameSingular": "company" }
      },
      "nextStepIds": ["67c84fff-4992-4dfa-bcb1-bc77ca4df92f"]
    },
    "steps": [
      {
        "id": "67c84fff-4992-4dfa-bcb1-bc77ca4df92f",
        "name": "Find in-flight scans",
        "type": "FIND_RECORDS",
        "valid": true,
        "nextStepIds": ["35d45afd-1348-4231-a3e4-b1f176ac8936"],
        "settings": {
          "input": {
            "objectName": "siteScan",
            "limit": 1,
            "filter": {
              "gqlOperationFilter": {
                "and": [
                  { "companyId": { "eq": "{{trigger.id}}" } },
                  { "status": { "eq": "SCANNING" } }
                ]
              }
            }
          }
        }
      },
      {
        "id": "35d45afd-1348-4231-a3e4-b1f176ac8936",
        "name": "Guard: no in-flight scan",
        "type": "CODE",
        "valid": false,
        "nextStepIds": ["1c56bc75-23f3-44d6-b77f-9b11b2951240"],
        "settings": {
          "input": {
            "logicFunctionId": "6162f781-6fe4-446d-90e9-926644f04468",
            "logicFunctionInput": {
              "records": "{{67c84fff-4992-4dfa-bcb1-bc77ca4df92f.all}}"
            }
          }
        }
      },
      {
        "id": "1c56bc75-23f3-44d6-b77f-9b11b2951240",
        "name": "Submit scan to sitespeed",
        "type": "CODE",
        "valid": false,
        "nextStepIds": ["7f94a70a-ec76-4770-b6c0-2909df75231c"],
        "settings": {
          "input": {
            "logicFunctionId": "6dd1ea3c-e782-4f14-9c18-295afac1cd37",
            "logicFunctionInput": {
              "url": "{{trigger.domainName.primaryLinkUrl}}",
              "companyId": "{{trigger.id}}",
              "triggeredById": "{{trigger.createdBy.workspaceMemberId}}"
            }
          }
        }
      },
      {
        "id": "7f94a70a-ec76-4770-b6c0-2909df75231c",
        "name": "Create SiteScan record",
        "type": "CREATE_RECORD",
        "valid": true,
        "nextStepIds": null,
        "settings": {
          "input": {
            "objectName": "siteScan",
            "objectRecord": {
              "testId":        "{{1c56bc75-23f3-44d6-b77f-9b11b2951240.result.testId}}",
              "url":           "{{1c56bc75-23f3-44d6-b77f-9b11b2951240.result.url}}",
              "device":        "MOBILE",
              "status":        "SCANNING",
              "companyId":     "{{1c56bc75-23f3-44d6-b77f-9b11b2951240.result.companyId}}",
              "triggeredById": "{{1c56bc75-23f3-44d6-b77f-9b11b2951240.result.triggeredById}}"
            }
          }
        }
      }
    ]
  }
}
```

> NOTE: CODE steps show `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 will execute correctly at runtime.

---

## Known deviations from original spec

| Spec assumption | Reality |
|---|---|
| `create_workflow_version_step` `defaultSettings` is applied for all step types | Only applied for CODE steps. For FIND_RECORDS/CREATE_RECORD, `defaultSettings` is silently ignored. Must use `update_workflow_version_step` after. |
| `edges` array in `create_complete_workflow` wires steps | Supplying `edges` causes `"Target step 'undefined' not found"` error. Use `nextStepIds` on trigger and step objects instead. |
| `update_logic_function_source` takes `source` param | Actual param name is `code`. |
| FIND_RECORDS filter: `{ field, operator, value }` shape | Uses GQL filter format: `{ "companyId": { "eq": "..." } }` (flat FK, not `company.id`). |
| `availability: { type: "GLOBAL", locations: ["RECORD_PAGE"] }` | Schema also accepts `{ type: "SINGLE_RECORD", objectNameSingular: "company" }` — used this instead for tighter scoping. |
| `logicFunctionInput: {}` is valid default | WRONG. Empty `logicFunctionInput` means CODE step runtime receives `params = {}`. All trigger data and upstream step outputs must be explicitly templated. |
| MANUAL trigger exposes `{{trigger.record.id}}` | WRONG. MANUAL trigger output is flat: `{{trigger.id}}`. No `.record` wrapper. |

---

## Re-running the install

1. Delete the existing workflow in Twenty UI (Automation section) or via GraphQL `destroyWorkflow` mutation.
2. Follow Steps 1–10 above.
3. Step 7's source is generated from `buildGuardCodeBody()` in `code-step-submit.js`.
4. Step 8's source is regenerated from `buildSubmitCodeBody()` at install time — secrets are baked in fresh.
