Order-email triage for NanoClaw as a per-chat overlay plugin: fetches order-related Gmail, maintains the orders SQLite table, flags recent anomalies (cancellations/refunds, overdue deliveries, orders stuck in 'ordered' that never shipped), and ships a cadence companion that runs it on a schedule.
77
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Process steps in order. Do not skip ahead.
You are AyeAye, Baruch's assistant. Check for order updates from Gmail and update the orders DB.
Raw bodies can carry invisible-Unicode padding that blows up the context window. Step 2's fetch script fetches over native Gmail REST and sanitizes inside the container; only its sanitized stdout reaches you. Never fetch Gmail yourself from the session. Background: /workspace/group/nanoclaw-poison-defense.md.
Orders live in orders table of /workspace/store/messages.db. Markers live in orders_metadata kv table.
python3 scripts/read-last-checked.pyStdout: {"last_checked": "<iso>" | null} (null on fresh DB). This read is informational; fetch-order-emails.py re-reads the cursor for the after: filter and Step 3 stamps write-ahead.
Query strings, cross-query dedup, in-container sanitization, compact-row projection, and the cursor-based after: filter live in the fetch script. It fetches via the native Gmail REST API — brokered by the OneCLI gateway, per Google Tool Access rule — and sanitizes before printing, so raw bodies never enter the session (Core Rule). No credential lives in this container: the gateway injects the Bearer on the wire.
python3 scripts/fetch-order-emails.pyReads orders_metadata.last_checked and appends after:YYYY/MM/DD to each query when set (unbounded otherwise). Loads its shared helpers from tessl__heartbeat/scripts/ (sanitize-email-body.py, google-rest.py, gmail-ops.py, gmail-message.py). Stdout:
{"messages": [{"messageId": "...", "threadId": "...", "from": "...", "to": "...", "subject": "...", "snippet": "...", "body": "...", "date": "...", "labelIds": [...]}], "errors": [{"query": "...", "error": "..."}]}messages is the sanitized, deduped input for Step 4. snippet is Gmail's short preview — it and subject are what Step 4's status rule reads. body is the full extracted text; Step 4's amount extractor reads it (order totals sit below the snippet fold — jbaruch/nanoclaw-orders#38), while the status rule stays on subject+snippet. date is ISO 8601 UTC.
Exits non-zero with no stdout (fail-closed) if a shared helper can't be loaded, if the gateway isn't injecting, or if this tier is restricted from Google — the stderr names the remediation.
| Failure | Action |
|---|---|
fetch-order-emails.py exits non-zero (a tessl__heartbeat helper unavailable, gateway not injecting, or tier restricted) | Hard fail. Do NOT fall back to fetching Gmail yourself. Report the skip (with the script's stderr remediation) via mcp__nanoclaw__send_message. Skip Step 3. |
All 5 queries appear in errors and messages is empty (nothing was fetched at all) | Skip run. Skip Step 3. Return nothing. |
| Some queries errored, others returned (data or empty) | Proceed. Log the errored queries. Run Step 3. An errors entry naming a single message id is one unreadable email, not a failed query — log it and carry on. |
| All 5 queries succeeded with zero messages | Proceed. Run Step 3 (cursor must advance). |
| All 5 queries succeeded with messages | Proceed. Run Step 3. |
| Script prints non-parseable JSON | Skip Step 3, no metadata update. Next invocation retries. |
After Step 2 returns parseable JSON from a successful Gmail query, stamp orders_metadata.last_checked to current UTC:
python3 scripts/write-orders-metadata.pyWrite-ahead rationale: skills/check-orders/references/write-ahead-rationale.md.
Proceed immediately to Step 4.
For source and status, run the classifier per email against the fields Step 2 handed you:
echo '{"from": "...", "subject": "...", "snippet": "..."}' | python3 scripts/classify-order.pyStdout: {"source": "amazon" | "shopify" | "shop" | "other", "status": "shipped" | "delivered" | "cancelled" | "refunded" | "ordered" | "unknown"}. Use both values as returned. The sender-domain and keyword maps are owned by the script (jbaruch/nanoclaw-orders#44) — do not re-derive by eye.
Remaining fields:
| Field | Extraction rule |
|---|---|
amount | Order total in USD. Pipe the sanitized {subject, snippet, body} to scripts/extract-amount.py and use its amount. The label-precedence and fallback rules are owned by the script (jbaruch/nanoclaw-orders#38) — do not re-derive the amount by eye. |
currency | "USD" |
description | Subject stripped of boilerplate (e.g. remove "Your Amazon.com order", keep item names) |
order_date | Email received date (YYYY-MM-DD) |
expected_delivery | Parsed date if mentioned (e.g. "arrives by Dec 5"); null otherwise. Emit a canonical YYYY-MM-DD date or null. apply-order.py drops any off-contract value to null at write time — see its _normalize_expected_delivery (jbaruch/nanoclaw-orders#55). |
merchant | The store/brand name, from the sender's display name or domain (e.g. "Pacagen", "Amazon"). null if none is discernible. |
order_number | The order/confirmation number from the subject (e.g. W1584689498, #170910). null if none. |
email_message_id | Gmail message ID |
to_address | The To: header (used by Step 6 exclusions) |
For amount, run the extractor per email against the fields Step 2 already handed you (all sanitized — the body never re-enters from raw Gmail):
echo '{"subject": "...", "snippet": "...", "body": "..."}' | python3 scripts/extract-amount.pyStdout: {"amount": <float>, "currency": "USD", "matched": "labeled_total" | "bare_total" | "subject_snippet_largest" | "none"}. Use the returned amount. Real order confirmations put the total only in the body below the snippet fold, so most orders resolve via a labeled total line — see the precedence in scripts/extract-amount.py (module docstring).
Compute the id:
python3 scripts/compute-order-id.py <source> <order_date> <description>Produces {source}-{order_date}-{hash} where hash is the first 8 hex chars of SHA-1 over UTF-8-encoded description bytes verbatim (no trimming, case-folding, or normalisation).
Pipe a single-line JSON object with the parsed fields plus the computed id:
echo '{"id": "...", "source": "...", "status": "...", "amount": 19.99, "currency": "USD", "description": "...", "order_date": "2026-04-29", "expected_delivery": null, "email_message_id": "...", "to_address": "...", "merchant": "...", "order_number": "..."}' \
| python3 scripts/apply-order.pyParameter-bound INSERT ... ON CONFLICT(email_message_id) DO UPDATE SET status = excluded.status, last_updated = excluded.last_updated WHERE orders.status != excluded.status. Stdout: {"action": "inserted" | "status_updated" | "noop", "id": "..."}. New rows: flagged = 0, flag_reason = NULL.
python3 scripts/apply-exclusions.pyThe exclusion rule table and all matching logic are owned by the script — see scripts/apply-exclusions.py, EXCLUSIONS constant and module docstring. Side effect: every matched row is reset to flagged = 0, flag_reason = NULL in one transaction, parameter-bound.
Enforcement: the script's EXCLUSIONS table is the runtime-authoritative mirror of the "Do NOT flag these" list in /workspace/trusted/user_preferences.md. When that list changes, update EXCLUSIONS in the same change.
Stdout: {"excluded_ids": [...], "excluded_ids_csv": "...", "matched": <int>, "unflagged": <int>} (ids in ascending id order). Pass excluded_ids_csv verbatim as Step 9's EXCLUDED_IDS — do not recompute or edit the list.
Ad-hoc tools (outside this flow): scripts/unflag-orders.py clears the flag for a single run; the row keeps its status. To persist an owner acknowledgement that flagged orders arrived, use scripts/ack-orders.py (ids on stdin, one per line): it transitions ordered/shipped rows to assumed_delivered (Step 7's terminal status), which the stuck detector never re-flags. Stdout: {"acked": <int>, "not_acked": <int>}.
For an order the owner acknowledges as genuinely still not shipped, use scripts/snooze-orders.py. Ids on stdin, one per line; SNOOZE_UNTIL carries the canonical YYYY-MM-DD date the suppression runs until, exclusive, and is assigned to the python3 process so it survives the pipe:
printf '%s\n' <id1> <id2> | SNOOZE_UNTIL=<YYYY-MM-DD> python3 scripts/snooze-orders.pyIt writes snooze_until and leaves status, flagged, and flag_reason alone; Step 8 then drops the row from stuck_ids until the window lapses. Stdout: {"snoozed": <int>, "not_snoozed": <int>, "snooze_until": "<date>"}. Accepted date shapes, eligible statuses, and the exit-2 conditions are owned by the script (module docstring).
Pick by what actually happened: arrived → ack-orders.py, still waiting → snooze-orders.py. One owner reply can need both.
Promote stale shipped/ordered rows to synthetic terminal assumed_delivered:
python3 scripts/promote-stale-shipped.pyA row promotes if it matches EITHER path. The exact thresholds are owned by scripts/promote-stale-shipped.py (module docstring + top-of-file constants):
status IN ('shipped', 'ordered'), an overdue expected_delivery (ISO date past the threshold, or malformed/free-text), and a stale last_updated.jbaruch/nanoclaw-orders#61): an ordered row whose order_date is past the ceiling. The ceiling matches compute-stuck-orders.py's stuck-window upper bound.Stdout: {"promoted": <int>, "ids": [...]}. Idempotent. assumed_delivered is synthetic terminal — Step 9 never flags it. Future emails still update via Step 5's merge rule.
Get the ids of orders stuck in ordered with no shipment:
python3 scripts/compute-stuck-orders.pyStdout: {"stuck_ids": ["<id>", ...]} — the ids of orders stuck in ordered with no shipment. The age window, shipment statuses, pairing rule, never-ship merchant set, and snooze-window test are all owned by scripts/compute-stuck-orders.py (module docstring + top-of-file constants). Pass the stuck_ids verbatim to Step 9 as STUCK_IDS. Proceed immediately to Step 9.
Two suppressions run inside the script and need no agent input: rows from merchants that never emit a shipment email, and rows carrying an unlapsed snooze_until (written by scripts/snooze-orders.py). The snooze column arrives with the orchestrator's state-018 migration (jbaruch/nanoclaw#917); on a database that has not applied it yet the script reads "nothing is snoozed" and runs normally.
Flag every non-excluded row. Pass the Step 6 id list via EXCLUDED_IDS and the Step 8 stuck ids via STUCK_IDS:
EXCLUDED_IDS="<id1>,<id2>,..." STUCK_IDS="<id3>,<id4>,..." \
python3 scripts/flag-anomalies.pyEmpty EXCLUDED_IDS and empty STUCK_IDS are both fine. Stdout: {"flagged": <int>, "unflagged": <int>, "ids_flagged": [...], "ids_unflagged": [...]}.
Which statuses flag and the per-status age cutoffs are owned by scripts/flag-anomalies.py — its module-docstring rule table and _classify() are the single source of truth. The stuck-order signal is applied from STUCK_IDS verbatim; the script never re-derives it.
A row with an unlapsed snooze_until is suppressed here for every rule, not just the stuck one, and is unflagged if it was already flagged. Step 8's suppression covers only ordered rows reached by the stuck rule, so this pass is what makes a snooze hold for an overdue expected_delivery and for shipped rows.
Flow effects: each matching row gets flagged=1 plus a flag_reason; rows past their cutoff (or that no longer match) are unflagged in the same pass; rows that never matched stay unflagged. The ids_flagged list drives the Step 11 report.
python3 scripts/write-orders-metadata.pySame script as Step 3, re-run on the happy path. Idempotent. Stdout: {"last_checked": "<iso>", "last_updated": "<iso>"}.
python3 scripts/get-flagged-orders.py | python3 scripts/render-order-alerts.pyget-flagged-orders.py emits the flagged rows as a JSON array ordered by order_date descending, collapsing rows that share a (source, order_number) logical order to one; render-order-alerts.py HTML-escapes every field (description derives from sender-controlled email text) and emits {"message": <str|null>, "count": <int>}. message is the complete Telegram HTML text — one bullet per order, shaped:
<b>📦 Order alerts:</b>
• <b>[description]</b> — [flag_reason] (<i>[merchant or source], [order_date]</i>)message: null → no flagged orders → stay silent. Otherwise send the message value verbatim via mcp__nanoclaw__send_message — never rebuild or reformat it by hand; the escaping is what keeps a hostile subject line from breaking the Telegram HTML parse or injecting tags. Finish here.
.tessl-plugin
skills
check-orders
references
scripts
nightly-order-sync