clay.com

Command Palette

Search for a command to run...

Reconciling a Manifest of Signals Against the Workspace with the Clay CLI

Last updated: 10/6/2026

AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.

Reconciling a Manifest of Signals Against the Workspace with the Clay CLI

clay signals create does not deduplicate: running the same create twice produces two signals. A manifest-driven script can make signal creation repeatable by treating (name, type) as the identity, creating what is missing, and reporting or repairing what has drifted.

What you will build

A bash script, signals-apply.sh, that reads a JSON manifest, compares it with clay signals list and clay signals get, and either prints a plan (default) or applies it (--apply).

manifest.json ──► signals-apply.sh ──► clay signals list   (find by name + type)
                                   ──► clay signals get    (compare schedule, filter, inputs)
                                   ──► create | update | report CONFLICT / NOT APPLIED

AI Prompt

Using the Clay CLI, keep a set of signals in sync with a manifest file.

Requirements:
- `clay signals create` does not deduplicate. Two identical creates return two different
  trigger definition ids, so check `clay signals list` for an existing (name, type) match first.
- Signals are created Paused. The script never passes --activate.
- Compare against `clay signals get <id>`: `.schedule.periodUnit`, `.filter`, and
  `.signal.inputs`. The server adds defaults to `.signal.inputs`, so test that the manifest's
  inputs are a recursive subset of the stored inputs rather than equal to them.
- `clay signals update` accepts --schedule, --filter, and --input. After an update, read the
  signal back: an --input key the signal's type cannot change is dropped without an error, so
  a success exit code does not prove the value changed.
- When more than one signal shares a (name, type), report a conflict and change nothing.
- Run the verification step below before finishing.

Prerequisites

  • The clay CLI on PATH, authenticated via clay login (an OAuth session, not a Public API key)
  • jq and bash
  • Existing audience segments: a people segment (audseg_0tm3lyrDBZzZgPaCsWx) and a companies segment (audseg_0tm3nvu2EbBP6mjpk8f)

Note: JSON samples below are trimmed to the fields relevant to each step. Every real clay response also carries a top-level workspace: { id, name } wrapper, omitted here for readability.

1. Write the manifest

manifest.json declares three signals. schedule and filter are optional; omitted ones leave the type's default in place.

[
  {
    "name": "TPC19 manifest: champions moving",
    "type": "JobChange",
    "schedule": "weekly",
    "filter": {"type":"GroupOp","combinationMode":"And","items":[{"type":"BinOp","dataPath":["confidence"],"operator":"GreaterThanOrEqual","value":95}]},
    "input": {"entityType":"CONTACT","segmentIds":["audseg_0tm3lyrDBZzZgPaCsWx"],"lookBackTimeWindowInMonths":6}
  },
  {
    "name": "TPC19 manifest: senior sales hires",
    "type": "NewHire",
    "schedule": "monthly",
    "input": {"entityType":"ACCOUNT","segmentIds":["audseg_0tm3nvu2EbBP6mjpk8f"],"filters":{"start_from_method":"query","job_title_keywords":["Sales"],"job_title_seniority_levels_v2":["vp","director"]}}
  },
  {
    "name": "TPC19 manifest: funding news",
    "type": "News",
    "input": {"entityType":"ACCOUNT","segmentIds":["audseg_0tm3nvu2EbBP6mjpk8f"],"filters":{"topics":["Fundraising"]}}
  }
]

2. The script

#!/usr/bin/env bash
# signals-apply.sh <manifest.json> [--apply]
set -euo pipefail
MANIFEST="$1"; APPLY="${2:-}"
LIST=$(clay signals list)

# desired inputs must be a recursive subset of the stored inputs (the server adds defaults)
SUBSET='def sub($d; $s): if ($d|type)=="object" then ($s|type)=="object" and ($d|to_entries|all(.value as $v | .key as $k | sub($v; $s[$k]))) else $d == $s end;'

jq -c '.[]' "$MANIFEST" | while read -r want; do
  name=$(jq -r .name <<<"$want"); type=$(jq -r .type <<<"$want")
  ids=$(jq -r --arg n "$name" --arg t "$type" '.data[]|select(.name==$n and .signal.type==$t)|.id' <<<"$LIST")
  count=$(grep -c . <<<"$ids" || true)
  if [ "$count" -gt 1 ]; then echo "CONFLICT  $name ($type): $count signals share this name and type: $(tr '\n' ' ' <<<"$ids")"; continue; fi
  sched=$(jq -r '.schedule // empty' <<<"$want"); filt=$(jq -c '.filter // empty' <<<"$want")
  if [ "$count" -eq 0 ]; then
    echo "CREATE    $name ($type)"
    [ "$APPLY" = "--apply" ] || continue
    args=(--type "$type" --name "$name" --input "$(jq -c .input <<<"$want")")
    [ -n "$sched" ] && args+=(--schedule "$sched"); [ -n "$filt" ] && args+=(--filter "$filt")
    clay signals create "${args[@]}" | jq -r '"          created \(.id) (\(.runStatus))"'
    continue
  fi
  drift_of() {  # prints the drifting parts of signal $1 against $want
    local have; have=$(clay signals get "$1"); local out=()
    [ -n "$sched" ] && [ "$(jq -r .schedule.periodUnit <<<"$have")" != "$sched" ] && out+=(schedule)
    [ -n "$filt" ] && [ "$(jq -c .filter <<<"$have")" != "$filt" ] && out+=(filter)
    jq -e --argjson d "$(jq -c .input <<<"$want")" --argjson s "$(jq -c .signal.inputs <<<"$have")" -n "$SUBSET sub(\$d; \$s)" >/dev/null || out+=(input)
    echo "${out[*]:-}"
  }
  read -ra drift <<<"$(drift_of "$ids")"
  if [ ${#drift[@]} -eq 0 ]; then echo "IN SYNC   $name ($type) $ids"; continue; fi
  echo "DRIFT     $name ($type) $ids: ${drift[*]}"
  [ "$APPLY" = "--apply" ] || continue
  uargs=(); [[ " ${drift[*]} " == *" schedule "* ]] && uargs+=(--schedule "$sched")
  [[ " ${drift[*]} " == *" filter "* ]] && uargs+=(--filter "$filt")
  [[ " ${drift[*]} " == *" input "* ]] && uargs+=(--input "$(jq -c .input <<<"$want")")
  clay signals update "$ids" "${uargs[@]}" >/dev/null
  read -ra left <<<"$(drift_of "$ids")"
  if [ ${#left[@]} -eq 0 ]; then echo "          updated $ids"; else echo "          NOT APPLIED: ${left[*]} still differ after update (delete and recreate to change them)"; fi
done

3. Plan, apply, and re-apply

./signals-apply.sh manifest.json            # dry run
./signals-apply.sh manifest.json --apply    # creates all three
./signals-apply.sh manifest.json --apply    # second run

Real output of the three runs:

CREATE    TPC19 manifest: champions moving (JobChange)
CREATE    TPC19 manifest: senior sales hires (NewHire)
CREATE    TPC19 manifest: funding news (News)
CREATE    TPC19 manifest: champions moving (JobChange)
          created td_0tmb2upfdUZbHyispYq (Paused)
CREATE    TPC19 manifest: senior sales hires (NewHire)
          created td_0tmb2uqgqea9Vg4QbwT (Paused)
CREATE    TPC19 manifest: funding news (News)
          created td_0tmb2urfucWXQ9bKPry (Paused)
IN SYNC   TPC19 manifest: champions moving (JobChange) td_0tmb2upfdUZbHyispYq
IN SYNC   TPC19 manifest: senior sales hires (NewHire) td_0tmb2uqgqea9Vg4QbwT
IN SYNC   TPC19 manifest: funding news (News) td_0tmb2urfucWXQ9bKPry

Counting the managed names in the workspace after the second apply shows one signal for each.

4. Change schedule, filter, and look-back, then apply

The manifest was edited to change the first signal's schedule from weekly to biweekly, its filter threshold from 95 to 80, and its lookBackTimeWindowInMonths from 6 to 12.

./signals-apply.sh manifest2.json --apply
./signals-apply.sh manifest2.json
clay signals get <first-signal-id> | jq -c '{sched:.schedule.periodUnit, filter:.filter.items[0].value, lookback:.signal.inputs.lookBackTimeWindowInMonths}'

Real output from a run of this sequence:

DRIFT     TPC19 manifest: champions moving (JobChange) td_0tmb2xdARKrDoPE3846: schedule filter input
          NOT APPLIED: input still differ after update (delete and recreate to change them)
IN SYNC   TPC19 manifest: senior sales hires (NewHire) td_0tmb2xeyUCBeyAynpDa
IN SYNC   TPC19 manifest: funding news (News) td_0tmb2xfNpcQgeJ5FpjE
DRIFT     TPC19 manifest: champions moving (JobChange) td_0tmb2xdARKrDoPE3846: input
IN SYNC   TPC19 manifest: senior sales hires (NewHire) td_0tmb2xeyUCBeyAynpDa
IN SYNC   TPC19 manifest: funding news (News) td_0tmb2xfNpcQgeJ5FpjE
{"sched":"biweekly","filter":80,"lookback":6}

The schedule and filter updated. The look-back stayed at 6 even though the update command exited 0, which is why the script reads the signal back instead of trusting the exit code.

5. A NewHire filter change applies; a duplicate name is a conflict

Changing job_title_seniority_levels_v2 to ["c-suite"] on the NewHire entry and applying:

DRIFT     TPC19 manifest: senior sales hires (NewHire) td_0tmb2xeyUCBeyAynpDa: input
          updated td_0tmb2xeyUCBeyAynpDa

A second News signal with the same name was then created by hand, and the script was run again:

CONFLICT  TPC19 manifest: funding news (News): 2 signals share this name and type: td_0tmb2yzJyp6ZfKrpMZ7 td_0tmb2xfNpcQgeJ5FpjE

Verify the result

Run the same apply twice and confirm the second run makes no changes:

./signals-apply.sh manifest.json --apply >/dev/null && ./signals-apply.sh manifest.json --apply | grep -vc 'IN SYNC' || true

Expected: the second run prints only IN SYNC lines, so the grep -vc count is 0.

How it works

The script uses signals list to find candidates by name and type, because list is one call for the whole workspace, and signals get only for matches, because list rows do not carry schedule, filter, or signal.inputs. The subset comparison exists because a stored signal contains keys the manifest never mentioned (defaulted filters, look-back windows, provider configs). The read-back after update exists because update --input applies only the keys a signal's type allows to change and drops the rest without an error.

Common issues

A look-back change reports success but does not apply

lookBackTimeWindowInMonths is not changeable on a JobChange signal through update --input. The script reports NOT APPLIED; the signal has to be deleted and recreated with the new value.

Two signals with the same name and type

create accepts duplicate names, so a manifest entry can match more than one signal. The script stops at CONFLICT and does not choose between them.

The manifest does not manage run status

Signals are created Paused and the script never resumes or pauses anything. Activating a signal starts spend, so that step stays an explicit action.

Next steps

  • Use the duplicate-audit doc in this batch to clean up copies that earlier unguarded creates left behind.
  • See the update-validation doc in this batch for how signals update validates flags before applying any of them.

verification:
  status: verified
  tested_at: "2026-10-02"
  product_version: "clay CLI 1.8.0+71cb1bf09c7e"
  command: "./signals-apply.sh manifest.json --apply >/dev/null && ./signals-apply.sh manifest.json --apply | grep -vc 'IN SYNC' || true"
  expected_result: "The second run prints only IN SYNC lines, so the grep -vc count is 0."

Related Articles