name: Price Alert description: Fire when the tracked token does something — new ATH, sharp 1h move, or operator-set target crossed. Silent on normal days. var: "" tags: [crypto]
${var} — Optional. Pass one or more
target_pricelevels (comma-separated USD numbers, scientific notation allowed) to fire a one-time alert when the price crosses any of them. Empty = only ATH and sharp-move gates run. Passdry-runto skip notify (state still updates).
Today is ${today}. token-report produces a daily verdict at a fixed hour. repo-pulse reports star/fork deltas once a day. Neither tells the operator "the price just hit a new high" or "the token moved 28% in the last hour" — both are events that warrant attention the moment they happen, not 14 hours later in the daily digest. This skill closes that window.
Why this exists
The daily token-report is a calm summary. Real moves need real-time signal. Three classes of move are worth a same-day ping:
- New all-time high. The single most narrative-shaping price event a token can have. Worth marking on the timeline regardless of size.
- Sharp 1h moves (±20%). Either a buyer wave or a liquidation cascade — both change what the operator does next (post about it, watch for follow-through, check the chart).
- Operator-set target crossings. The operator may want to know when price clears $X (often a personal stretch goal or a level tied to a tweet/launch). One alert per target per direction.
Everything else is noise the daily report handles.
Config
Reads:
memory/MEMORY.md"Tracked Token" section — same contract/chain the token-report skill uses. If absent, exit silently.memory/topics/price-alert-state.json— last-known ATH, last-alert timestamps per event type, target-crossing history. Created with defaults on first run.
Writes:
memory/topics/price-alert-state.json— updated state every run.memory/logs/${today}.md— one log block per run, even onOK.- Notification via
./notify— only when a gate fires.
No new secrets. Uses keyless DexScreener; falls back to WebFetch when curl is sandbox-blocked.
State schema
{
"contract": "0xbf8e8f0e8866a7052f948c16508644347c57aba3",
"chain": "base",
"ath": {
"price_usd": 0.000003757,
"observed_at": "2026-05-10T19:00:00Z",
"announced_at": "2026-05-10T19:30:00Z"
},
"last_alerts": {
"ath": "2026-05-10T19:30:00Z",
"sharp_move": null,
"target_hit": null
},
"targets": {
"0.000005": {
"side": "above",
"first_seen_below_at": "2026-05-11T08:00:00Z",
"hit_at": null,
"announced_at": null
}
}
}
Key invariants:
ath.price_usdonly ever monotonically increases. If a run sees a lower price than the stored ATH, leave the ATH alone.last_alerts.*powers the 4h dedup window. Each gate has its own clock.targets.${price}.sideisaboveifcurrent_price < targetwhen the target was first observed (operator is waiting for the price to climb to it), andbelowotherwise. Set once, never flipped.targets.${price}.hit_atis set the run the cross happens.announced_atis set the run the notification fires. They differ only if the run lands inside a dedup window — but target alerts never re-fire, sohit_at == announced_atin practice.
Steps
1. Parse var
- If
${var}matches^dry-run→MODE=dry-run. Strip the prefix; remainder (if any) is treated as targets. - Otherwise
MODE=execute. - Split the remainder on
,(commas) and strip whitespace. - For each token: if it parses as a positive float (scientific notation OK, e.g.
5e-6), include it. Reject zero / negative / non-numeric tokens and logPRICE_ALERT_BAD_TARGET: ${token}— continue with the surviving targets. - If after filtering the remainder was non-empty but yielded zero valid targets → log
PRICE_ALERT_BAD_VAR: ${var}and exit (no notify). - If the remainder was empty,
TARGETS=()is fine — ATH and sharp-move gates still run.
2. Resolve tracked token
mkdir -p memory/topics
[ -f memory/topics/price-alert-state.json ] || cat > memory/topics/price-alert-state.json <<'EOF'
{"contract":null,"chain":null,"ath":null,"last_alerts":{"ath":null,"sharp_move":null,"target_hit":null},"targets":{}}
EOF
Parse the "Tracked Token" table in memory/MEMORY.md. Pull CONTRACT (column 2 of the first data row) and CHAIN (column 3, lowercased). If the section is missing, the table is empty, or the contract field doesn't match ^0x[0-9a-fA-F]{40}$ → log PRICE_ALERT_NO_TOKEN and exit (no notify, no state write).
If the state file's contract is set and differs from the resolved contract → log PRICE_ALERT_TOKEN_CHANGED and reset the state file to defaults with the new contract/chain. ATH starts over with the new token.
3. Fetch current price (DexScreener primary)
RESP=$(curl -fsS "https://api.dexscreener.com/latest/dex/tokens/${CONTRACT}" 2>/dev/null || echo "")
If the response is empty, falsy, or jq can't parse it → fall back to WebFetch on the same URL with prompt "Return the raw JSON body verbatim." and try again. If both paths fail → log PRICE_ALERT_FETCH_FAIL and exit with status ERROR (no notify, no state mutation beyond touching last_run_at).
From the parsed JSON:
- Filter
.pairs[]to entries where.chainId == "${CHAIN}". - Of those, pick the entry with the highest
.liquidity.usd— call this the deepest pool. If no pair matches the chain, fall back to the highest-liquidity pair across all chains and markchain=fallbackin the log. - Extract:
CURRENT_PRICE=.priceUsd(float),H1_CHANGE_PCT=.priceChange.h1(float, may be missing → treat as 0),H24_CHANGE_PCT=.priceChange.h24(float, may be missing → treat as 0),POOL_URL=.url.
If CURRENT_PRICE is missing, zero, or non-numeric → log PRICE_ALERT_BAD_PRICE and exit ERROR.
4. Evaluate ATH gate
prior_ath = state.ath.price_usd (null on first run)
new_ath = (prior_ath is null) OR (CURRENT_PRICE > prior_ath)
If new_ath:
- Always update
state.ath.price_usd = CURRENT_PRICE,state.ath.observed_at = NOW. - Notify only if the last
athalert was >4h ago (or never). On the first run after token initialisation, suppress the notification (we don't know if this is genuinely an ATH or just the starting baseline) — setstate.ath.announced_at = NOWso subsequent strict-higher prices alert correctly. - If suppressed because
prior_ath is null(first run), record verdictATH_BASELINEfor the log; no notify. - Otherwise verdict for this gate is
ATH.
5. Evaluate sharp-move gate
sharp = abs(H1_CHANGE_PCT) >= 20.0
If sharp:
- If
state.last_alerts.sharp_moveis within the last 4h → verdictSHARP_MOVE_DEDUPED, no notify. - Otherwise verdict
SHARP_MOVE, notify, setstate.last_alerts.sharp_move = NOW.
Sign of H1_CHANGE_PCT decides the direction word in the message (up for ≥0, down for <0).
6. Evaluate target-crossing gate
For each target in TARGETS:
- If the target isn't yet in
state.targets: this is the first time the operator has set it. Recordside(aboveifCURRENT_PRICE < target, elsebelow),first_seen_below_at=NOW,hit_at=null,announced_at=null. Do not notify on first observation. This avoids "the price was already there when you set the target" noise. - If the target is in
state.targetsandhit_atis already set → skip; target-hit alerts are one-shot per side. - Otherwise check the cross:
side=above&CURRENT_PRICE >= target→ crossed.side=below&CURRENT_PRICE <= target→ crossed.
- On cross: set
hit_at=NOW,announced_at=NOW, verdictTARGET_HITfor this target, notify (subject to 4h dedup onlast_alerts.target_hit).
Targets that crossed in prior runs stay in the state file with hit_at set so they don't refire if the price wobbles back.
7. Combine verdicts and notify
Run-level verdict precedence (used for the log status line and the notification ordering):
ATH— strict new high after baseline.TARGET_HIT— operator-set level crossed.SHARP_MOVE— ±20% in 1h.OK— no gate fired.
If multiple gates fired in the same run, send one notification per gate. Each gate independently respects its own dedup clock — an ATH and a TARGET_HIT in the same run produce two messages; an ATH and a SHARP_MOVE_DEDUPED produce one.
Notification templates
ATH:
*$TOKEN — New ATH — ${today}*
$TOKEN just printed a new all-time high at $X.XXXXe-N.
Previous ATH: $Y.YYYYe-N (set ${prior_ath_age} ago).
24h move: ±Z.Z% · 1h move: ±W.W%
Chart: ${POOL_URL}
SHARP_MOVE:
*$TOKEN — Sharp 1h Move — ${today}*
$TOKEN ${up|down} ${abs(h1):.1f}% in the last hour — now $X.XXXXe-N.
24h: ±Z.Z%
${one of: "Buyer wave — watch for follow-through." | "Selling pressure — watch the next hour for stabilisation." }
Chart: ${POOL_URL}
(Direction phrase is hard-coded by sign: positive = buyer wave; negative = selling pressure. No freelance commentary.)
TARGET_HIT:
*$TOKEN — Target Hit — ${today}*
$TOKEN just crossed $${target} (now $X.XXXXe-N).
Direction: ${above|below}
24h: ±Z.Z% · 1h: ±W.W%
Chart: ${POOL_URL}
If MODE == dry-run: build the messages, log the planned notifications, but skip ./notify. State still updates so dedup clocks advance correctly.
Cap each message at ~2500 chars; price-alert messages are short by nature and shouldn't approach this.
8. Persist state
Rewrite memory/topics/price-alert-state.json atomically:
TMP=$(mktemp)
jq --arg ts "$(date -u +%FT%TZ)" '
.last_run_at = $ts |
.contract = $contract |
.chain = $chain |
.ath = $ath_obj |
.last_alerts = $last_alerts_obj |
.targets = $targets_obj
' memory/topics/price-alert-state.json > "$TMP"
mv "$TMP" memory/topics/price-alert-state.json
Validate with jq empty memory/topics/price-alert-state.json after writing; if it fails, restore from a .bak copy and log PRICE_ALERT_STATE_CORRUPT. Keep one .bak rolling.
Cap state.targets to 20 most-recent entries (LRU by first_seen_below_at) so a long-running fork doesn't accumulate stale operator targets.
9. Log
Append to memory/logs/${today}.md:
## Price Threshold Alert
- **Skill**: price-alert
- **Token**: ${SYMBOL} (${CONTRACT})
- **Current**: $X.XXXXe-N | 1h: ±W.W% | 24h: ±Z.Z%
- **ATH**: $Y.YYYYe-N (set ${YYYY-MM-DD HH:MM} UTC) [${UNCHANGED|NEW}]
- **Sharp-move gate**: ${FIRED|QUIET|DEDUPED}
- **Targets evaluated**: ${comma-list of target prices} → ${comma-list of per-target verdicts}
- **Verdicts fired**: ${comma-list, or NONE}
- **Notifications sent**: ${N}
- **Status**: ${PRICE_ALERT_OK | PRICE_ALERT_ATH | PRICE_ALERT_SHARP_MOVE | PRICE_ALERT_TARGET_HIT | PRICE_ALERT_DRY_RUN | PRICE_ALERT_NO_TOKEN | PRICE_ALERT_TOKEN_CHANGED | PRICE_ALERT_FETCH_FAIL | PRICE_ALERT_BAD_PRICE | PRICE_ALERT_BAD_VAR | PRICE_ALERT_STATE_CORRUPT}
The status field carries the highest-priority gate fired this run, or the most relevant error. OK means the run completed cleanly and no gate fired.
Exit taxonomy
| Status | Meaning | Notify? |
|---|---|---|
PRICE_ALERT_OK |
Run completed, no gate fired | No |
PRICE_ALERT_ATH |
New strict all-time high (post-baseline) | Yes |
PRICE_ALERT_SHARP_MOVE |
±20% in 1h, outside dedup window | Yes |
PRICE_ALERT_TARGET_HIT |
Operator target crossed for the first time | Yes |
PRICE_ALERT_DRY_RUN |
var=dry-run mode |
No (state still updates) |
PRICE_ALERT_NO_TOKEN |
No tracked token configured in MEMORY.md | No |
PRICE_ALERT_TOKEN_CHANGED |
Tracked contract changed since last run; state reset | No |
PRICE_ALERT_FETCH_FAIL |
Both curl and WebFetch failed | No |
PRICE_ALERT_BAD_PRICE |
API returned malformed/zero price | No |
PRICE_ALERT_BAD_VAR |
${var} had non-empty, non-dry-run text but yielded zero valid targets |
No |
PRICE_ALERT_STATE_CORRUPT |
jq validation failed after write; restored from .bak |
No |
Sandbox note
DexScreener is keyless and public — curl works in unrestricted runners. The sandbox may block outbound curl on GitHub Actions; in that case the WebFetch fallback kicks in (built-in Claude tool, sandbox-safe, prompt: "Return the raw JSON body verbatim."). No prefetch script needed: there's no env-var-in-headers, and the URL doesn't change between runs. Notify uses the postprocess-notify pattern already wired up via ./notify.
Constraints
- One alert per event per 4h window. The state file's
last_alerts.*map is the only authority on whether to suppress. - Target alerts are one-shot per side. Once
hit_atis set, the target never re-fires — even if the price wobbles back through the level. Operators add new targets if they want continued signal. - No baseline alert. The first run after a fresh state file or token change must NOT send an ATH notification — the stored price is the baseline, not a "new high." Subsequent strict-higher prices alert.
- No freelance interpretation. The sharp-move direction phrase is hard-coded by sign. Do not embellish with TA opinions ("looks like distribution", "could test support"). The verdict tells the operator what happened; the operator decides what it means.
- Liquid-pool selection only. The deepest-liquidity pair on the configured chain wins. Don't compute prices from blended pool averages — the deepest pool's
priceUsdis the canonical mark. - State writes are atomic + validated. Every state write goes through a tmpfile +
jq emptyvalidation step. Corrupt writes restore from.bak. - Read-only across
memory/logs/. This skill never modifies past log files. It only appends to today's. - Targets are absolute USD, not percentages. This avoids ambiguity ("20% from where?"). If operators want move-from-now alerts they have token-report and the sharp-move gate.
- Idempotent under same-minute reruns. Same-minute reruns with identical price input produce identical state and zero new notifications (every gate dedup-suppressed).