Reconciling a Manifest of Signals Against the Workspace with the Clay CLI
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
clayCLI on PATH, authenticated viaclay login(an OAuth session, not a Public API key) jqand 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
clayresponse also carries a top-levelworkspace: { 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 updatevalidates 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."