---
name: crm-agent-dry-run-approvals
description: "Run AI agents that edit Salesforce or HubSpot in dry-run mode: log each change to a sheet, approve in bulk, apply with drift checks, and keep an undo log. Use when an AI agent that writes straight to Salesforce or HubSpot can change thousands of records before anyone notices a bad rule, and each change can trigger syncs, workflows, and sequences that are hard to trace and harder to reverse."
---

# Run CRM-Writing AI Agents in Dry-Run Mode with Bulk Approvals

An AI agent that writes straight to Salesforce or HubSpot can change thousands of records before anyone notices a bad rule, and each change can trigger syncs, workflows, and sequences that are hard to trace and harder to reverse.

## When to use this skill

- The user wants to set up or run this workflow: "Run CRM-Writing AI Agents in Dry-Run Mode with Bulk Approvals".
- The user describes this problem: An AI agent that writes straight to Salesforce or HubSpot can change thousands of records before anyone notices a bad rule, and each change can trigger syncs, workflows, and sequences that are hard to trace and harder to reverse.

## At a glance

- Category: RevOps
- Difficulty: Advanced
- Time saved: 2 hours per week. This figure is an estimate, not a measured result.

## Tools

Options separated by "or" are alternatives; use whichever the user already has.

- HubSpot or Salesforce
- Clay
- Google Sheets
- Slack

## Steps

1. Switch the agent to dry-run: wherever it would write to the CRM (a Clay CRM update column, an automation step, or a custom agent), have it add one row per field change to a review sheet instead, with change_id, run_id, rule, object, record_id, record_url, field (API name), old_value, new_value, reason, and confidence; in Clay, replace the CRM update column with the Google Sheets "Add row" action
2. Map the side effects of each field the rule touches: the HubSpot and Salesforce connector field mapping and its sync rule (a two-way field changed in Salesforce will reach HubSpot on the next sync), HubSpot workflows, Salesforce flows, and assignment rules that trigger on the field, enrollment and unenrollment criteria in your sequencer, and Clay tables that import from the CRM with auto-run on; write them in a side_effects column for the rule
3. Review in bulk: post one Slack message per run to a review channel with the run id, counts by rule and object, five sample rows, and a link to the sheet filtered to that run; reviewers filter by rule, spot-check, and set decision to approve or reject for whole groups, adding a reason to every rejection so the rule gets fixed
4. Apply only approved rows: export rows where decision is approve and apply_status is empty, read each record's current value, write the new value only if the field still equals old_value (otherwise mark skipped_drift), and record apply_status, applied_at, and value_before_apply on the same row
5. Respect API limits and sync timing: apply in batches sized to your HubSpot API limits (they depend on your subscription and app type) or your Salesforce org's rolling 24-hour API allocation, remembering that Clay, the connector, and other integrations share them; run large batches outside business hours and wait one connector sync cycle before checking downstream systems
6. Keep an undo path: the applied rows are the undo log, so reverting a batch means uploading those rows with old_value and new_value swapped through the same drift-checked apply; turn on Salesforce field history tracking or check HubSpot property history for the fields agents touch
7. Graduate one rule at a time to auto-apply only after it meets a bar you write down, for example a minimum number of reviewed changes and several consecutive runs with no rejections and no downstream issues; auto-applied rules still log to the sheet and keep the drift check, drop back to review after any rejection, and owner changes, merges, deletes, lifecycle or stage changes, and opt-out fields always stay in review

## Template outline

Field names, thresholds, and message text are starting points to adapt.

~~~text
Review Sheet (one row per proposed field change):
change_id | run_id | rule | object | record_id | record_url | field | old_value | new_value | reason | confidence | side_effects | decision | reviewer | decided_at | apply_status | applied_at | value_before_apply

Dry-Run (agent side):
- Every CRM write becomes Google Sheets "Add row" with the columns above
- No CRM write actions in the agent while the rule is in review

Side Effects Checklist (per field):
- HubSpot and Salesforce connector: is the field mapped, and which sync rule applies?
- HubSpot workflows, Salesforce flows, assignment rules triggered by the field
- Sequencer enrollment or unenrollment criteria using the field
- Clay tables importing from the CRM with auto-run on

Slack Review Message (per run):
"Run {{run_id}}: {{total}} proposed changes. {{count_by_rule}}. Samples: {{5_rows}}. Review: {{sheet_link_filtered}}"

Apply (Clay Workflow, CSV of approved rows, one run per change):
1. CRM lookup by record_id: current_value
2. Only run if current_value = old_value: update field to new_value
3. Google Sheets "Update rows" by change_id: apply_status (applied or skipped_drift), applied_at, value_before_apply

Undo: re-upload applied rows with old_value and new_value swapped
Limits: batches sized to HubSpot API limits or the Salesforce 24-hour allocation; large batches off-hours
Graduation (per rule): your bar for reviewed volume and clean runs; never auto-apply owner, merge, delete, lifecycle or stage, opt-out
Output: every agent write is reviewed, traceable, and reversible, and only proven rules run unattended
~~~

## Optional: build it with the Clay CLI

This builds the apply half of the dry-run process as a Clay workflow with a CSV trigger. Your agent proposes changes into a review sheet, people approve or reject them there, and you export the approved rows. Each approved change becomes one run: read the current value from the CRM, write the new value only if the field still holds the old value the agent saw, and record the result in the sheet so every write has an undo row. The Clay CLI cannot create tables, so this build uses a workflow.

Written against Clay CLI v1.3.1. Run these commands only in the user's own Clay workspace, logged in with their own account, after they confirm. Items marked VERIFY are config shapes the CLI help does not document; check them in the workspace before relying on them.

### Prerequisites

- Install the Clay CLI: Use version 1.3.1 or later, installed from Clay's own instructions. This page does not include an install command because the CLI reference it was written from does not list one. You also need jq, which the script uses to read the JSON the CLI prints.
- Log in to your workspace: Browser login is the default. Use the device flow on a headless machine. A static key in the CLAY_API_KEY environment variable also works. Workflow commands need the terracotta:cli scope, so an auth error that names it means your login cannot manage workflows.
  - Log in with a browser: `clay login`
  - Log in on a headless machine: `clay login --device`
  - Confirm who you are logged in as: `clay whoami`
- Pick the workspace to build in: The workflow is created in your active workspace. Switch first if you belong to more than one.
  - List workspaces: `clay workspaces list`
  - Show the active workspace: `clay workspaces current`
  - Switch workspace: `clay workspaces switch <workspaceId>`
- Find your action keys: Action package ids and keys are specific to your workspace. Save the catalog, search it for your CRM lookup action, your CRM update action, and the Google Sheets "Update rows" action, then paste their ids into the top of the script. Skip "clay workflows actions test": it spends credits.
  - Save the action catalog: `clay workflows actions list > catalog.json`
  - Search the catalog: `grep -Eio '"[^"]*(hubspot|salesforce|lookup|update|google sheets|update rows)[^"]*"' catalog.json | sort -u`
  - See an action's inputs and outputs: `clay workflows actions schema <packageId> <actionKey>`
- Set up the review sheet: One row per proposed change with these columns: change_id, run_id, rule, object, record_id, record_url, field, old_value, new_value, reason, confidence, decision, reviewer, decided_at, apply_status, applied_at, value_before_apply. The agent fills everything up to confidence; reviewers fill decision with approve or reject.
- Test against a sandbox first: Connect a Salesforce sandbox or a HubSpot test account in Clay and run the first batches there. Clay connects a sandbox as its own Salesforce account, so swap the account on the CRM steps when you move to production.

### Workflow: Apply approved CRM changes

Trigger (csv_upload): You export the approved rows from the review sheet (filter decision = approve and apply_status empty) and upload them. Each header becomes a string input, and each row becomes one run once the workflow is published. VERIFY: The trigger type string "csv_upload" is inferred. The CLI help does not show how to create a CSV trigger.

Inputs (CSV headers):

- change_id: Unique id for the proposed change (example: run42-0007)
- object: CRM object (example: contact)
- record_id: CRM record id (example: 1234567890)
- field: API name of the field (example: jobtitle)
- old_value: Value the agent saw when it proposed the change (example: VP Sales)
- new_value: Approved new value (example: VP of Sales)
- sheet_url: Review sheet URL (example: https://docs.google.com/spreadsheets/d/your-sheet-id)

1. Read the current value (action): Looks up the record by id and returns the field as it is right now.
   - Inputs: object, record_id, field
   - Outputs: current_value
2. Write the approved value (action): Updates the one field to new_value. Runs only when current_value still equals old_value, so a change someone made after review is never overwritten.
   - Inputs: object, record_id, field, new_value
   - Outputs: CRM record
   - VERIFY: The drift check is a run condition (current_value equals old_value), which the CLI help does not document. Set it in the Clay UI. Compare values after trimming whitespace.
3. Record the result (action): Updates the sheet row by change_id with apply_status (applied or skipped_drift), applied_at, and value_before_apply. Runs for every row, which makes the sheet the undo log.
   - Inputs: sheet_url, change_id, current_value
   - Outputs: Sheet row

### Script: build-apply-approved-changes.sh

Creates the workflow, the CSV trigger, and three steps, then pauses so you can map step inputs and the drift condition. It validates the graph, offers one test run, and asks before publishing.

~~~bash
#!/usr/bin/env bash
# Builds the "Apply approved CRM changes" Clay workflow
# in YOUR Clay workspace, using YOUR Clay CLI login.
# Written against Clay CLI v1.3.1. Not run or tested by GTM Playbooks.
# Lines tagged VERIFY use a shape the CLI help does not document.
set -euo pipefail

# Copy these from catalog.json (see "Find your action keys").
LOOKUP_PACKAGE_ID="" # HubSpot or Salesforce: lookup record by id
LOOKUP_ACTION_KEY=""
CRM_PACKAGE_ID=""    # HubSpot or Salesforce: update record
CRM_ACTION_KEY=""
SHEET_PACKAGE_ID=""  # Google Sheets: update rows
SHEET_ACTION_KEY=""

# One input to test before real traffic. Use yourself or a teammate.
TEST_ROW='{"change_id":"","object":"","record_id":"","field":"","old_value":"","new_value":"","sheet_url":""}'

for v in LOOKUP_PACKAGE_ID LOOKUP_ACTION_KEY CRM_PACKAGE_ID CRM_ACTION_KEY SHEET_PACKAGE_ID SHEET_ACTION_KEY; do
  [[ -n "${!v}" ]] || { echo "Set $v at the top of this script." >&2; exit 1; }
done
command -v clay > /dev/null || { echo "Clay CLI not found on PATH." >&2; exit 1; }
command -v jq > /dev/null || { echo "jq is required to read CLI output." >&2; exit 1; }
clay whoami > /dev/null || { echo "Not logged in. Run: clay login" >&2; exit 1; }

# VERIFY: only "workflows create" documents an "id" field in its output.
read_id() {
  local id
  id=$(jq -r '.id // empty' "$1")
  [[ -n "$id" ]] || { echo "No id found in $1. Open it and adjust read_id." >&2; exit 1; }
  echo "$id"
}

mkdir -p clay-apply-approved-changes
cd clay-apply-approved-changes

echo "1/7 Create the workflow"
clay workflows create --name "Apply approved CRM changes" > workflow.json
WF_ID=$(read_id workflow.json)
WF_URL=$(jq -r '.url // empty' workflow.json)

echo "2/7 Add the CSV upload trigger"
# VERIFY: triggerType "csv_upload" is inferred; the help does not show it.
clay workflows triggers create "$WF_ID" --input '{"triggerType":"csv_upload"}' > trigger.json
TRG_ID=$(read_id trigger.json)

echo "3/7 Step 1: read the current value"
# VERIFY: help shows nodeType and name only; actionPackageId and actionKey are a guess.
STEP1=$(jq -nc --arg p "$LOOKUP_PACKAGE_ID" --arg k "$LOOKUP_ACTION_KEY" '{nodeType: "tool", name: "Read the current value", actionPackageId: $p, actionKey: $k}')
clay workflows nodes create "$WF_ID" --input "$STEP1" > step1.json
STEP1_ID=$(read_id step1.json)

echo "4/7 Step 2: write the approved value"
# VERIFY: action fields inside "step" are inferred from the documented utility example.
STEP2=$(jq -nc --arg n "$STEP1_ID" --arg p "$CRM_PACKAGE_ID" --arg k "$CRM_ACTION_KEY" '{target: {type: "after-node", nodeId: $n}, step: {kind: "action", actionPackageId: $p, actionKey: $k}}')
clay workflows nodes insert "$WF_ID" --input "$STEP2" > step2.json
STEP2_ID=$(read_id step2.json)

echo "5/7 Step 3: record the result"
# VERIFY: same inferred insert shape as step 2.
STEP3=$(jq -nc --arg n "$STEP2_ID" --arg p "$SHEET_PACKAGE_ID" --arg k "$SHEET_ACTION_KEY" '{target: {type: "after-node", nodeId: $n}, step: {kind: "action", actionPackageId: $p, actionKey: $k}}')
clay workflows nodes insert "$WF_ID" --input "$STEP3" > step3.json
clay workflows graph get "$WF_ID" --mode full > graph.json

echo "6/7 Map step inputs"
cat <<EOF

Step inputs are specific to your workspace. Map them before validating:
  Step 1 lookup: object, record_id; read the field named in field
  Step 2 update: object, record_id, field = new_value;
                 run condition: current_value equals old_value
  Step 3 sheet:  sheet_url, lookup column change_id; write apply_status,
                 applied_at, value_before_apply = current_value
Map them in the Clay UI${WF_URL:+ at $WF_URL}, or per node with:
  clay workflows nodes get $WF_ID <nodeId>
  clay workflows nodes update $WF_ID <nodeId> --input node.json
graph.json in this folder shows the current nodes and edges.

EOF
read -r -p "Press Enter when inputs are mapped. "

echo "7/7 Validate, test once, publish"
clay workflows graph validate "$WF_ID"
clay workflows diagram "$WF_ID"

if jq -e 'all(.[]; . != "")' <<< "$TEST_ROW" > /dev/null; then
  read -r -p "Run TEST_ROW once? It writes to your CRM and your review sheet. Use a sandbox or test record. [y/N] " answer
  if [[ "$answer" == [yY] ]]; then
    clay workflows runs test "$WF_ID" --inputs "$TEST_ROW" > test-run.json
    echo "Saved test-run.json. Check each step with: clay workflows runs steps $WF_ID <runId>"
  fi
else
  echo "TEST_ROW is empty, skipping the test run."
fi

read -r -p "Publish the workflow now? [y/N] " answer
if [[ "$answer" == [yY] ]]; then
  clay workflows publish "$WF_ID" --name v1
fi

echo "Workflow $WF_ID, CSV upload trigger $TRG_ID. All responses are saved in $(pwd)."
~~~

### Approved changes CSV: approved-changes.csv

~~~csv
change_id,object,record_id,field,old_value,new_value,sheet_url
run42-0007,contact,1234567890,jobtitle,VP Sales,VP of Sales,https://docs.google.com/spreadsheets/d/your-sheet-id
~~~

### Apply an approved batch

After publishing, upload the approved CSV and start with a few rows. Each row is one run, and each run is at least one CRM read and one write against your API limits.

- Load the saved ids (run inside clay-apply-approved-changes/): `WF_ID=$(jq -r '.id' workflow.json); TRG_ID=$(jq -r '.id' trigger.json)`
- Upload the CSV (this does not start runs): `clay workflows triggers csv upload "$TRG_ID" --file approved-changes.csv`
- Run the first 5 rows (needs a published workflow, one run per row): `clay workflows triggers csv run "$TRG_ID" --limit 5`
- List runs: `clay workflows runs list "$WF_ID"`
- Inspect one run: `clay workflows runs get "$WF_ID" <runId>`
- See each step's output for that run: `clay workflows runs steps "$WF_ID" <runId>`

### Verify in the workspace

- The CSV trigger type "csv_upload" is inferred. If "triggers create" exits with code 2 (validation), check which trigger types your workspace accepts.
- The help documents "nodes create" with nodeType and name only. If it rejects actionPackageId and actionKey, create the node with a name only and pick the action in the Clay UI.
- The reference says action steps take actionPackageId and actionKey but shows only a utility example for "nodes insert", so the placement of those fields inside "step" is inferred.
- Only "workflows create" documents an id field in its response. The script reads .id from trigger and node responses too and stops if it is missing.
- Package ids and action keys differ by workspace and depend on which integrations you have connected. Copy them from your catalog; this page does not guess them.
- Step input mapping is dynamic per workspace. The CLI path is "nodes get" then "nodes update", where incomingEdges is a full replacement. The argument order for "nodes get" is inferred from "nodes update".
- The help does not say whether step 1 connects to the trigger automatically. "clay workflows graph validate" reports a disconnected graph.
- Field names must be API names (for example jobtitle in HubSpot or Title in Salesforce), and picklist values must match the CRM exactly or the update fails.
- "runs test" without --live is assumed to run the draft. The reference lists --live without describing it.
- Workflow runs can spend Clay credits and write to connected tools. Exit code 3 means re-run clay login, and exit code 4 means you are rate limited (wait for details.retryAfter).

## Guardrails

- Work only in the user's own accounts and workspaces, with their credentials.
- Confirm with the user before any step that writes to a CRM, sends email or messages, spends credits, or moves money.
- Treat every hours, cost, and threshold figure as a starting assumption and replace it with the user's numbers.
- Do not invent data, prices, or results. If a vendor price matters, check the vendor's current pricing page.

## Source

https://gtmplaybooks.io/playbooks/crm-agent-dry-run-approvals/
