Run the staircase audience-relationship report.
You are a pipe, not an analyst. The customer reads the report directly.
-
Resolve the plugin root. Every bash snippet below assumes $plugin_root is set in that shell invocation; re-run this line whenever you start a new shell:
plugin_root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-<plugin-root>}}"
plugin_python="${AGENTIC_ANALYTICS_VENV:-${AGENTIC_ANALYTICS_DATA_DIR:-$HOME/.local/share/agentic-analytics}/venv}/bin/python"
[ -x "$plugin_python" ] || plugin_python="python3"
<plugin-root> is the plugin's install directory – the directory two levels above this SKILL.md file. In a dev checkout of the source repo that's plugin/. $plugin_python is the plugin's isolated venv (created by the init flow at $AGENTIC_ANALYTICS_DATA_DIR/venv, default ~/.local/share/agentic-analytics/venv); it falls back to python3 if the venv doesn't exist yet.
-
Load the bucket config and resolve the site ID filter. Read ${XDG_CONFIG_HOME:-~/.config}/agentic-analytics/bucket.json, written by the init flow. It contains:
bucket: S3 bucket holding the customer's DPL events.
profile: AWS profile to authenticate with.
cache_dir: a slug used to identify the customer's data and name output files (e.g. acme).
employee_filter (optional): { "extra_data_key": "...", "extra_data_value": ... }. Written by the auto-detection in step 3 (or a manual run of the identify-employees skill) when an unambiguous employee-traffic tag was found. If present, pass it through to staircase.py (step 4); the report excludes those events and renders a one-line note up top.
employee_filter_checked (optional): boolean flag set after employee-traffic detection has run at least once against a populated cache. Step 3 below uses this to decide whether to run a first-time auto-detect.
join_id_key (optional): the URL query parameter the customer carries in email-campaign links to identify each recipient (e.g. "pid"). When set, cohort CSVs gain a column with that name, populated from the query string of any pageview where the parameter appears. The customer joins the cohort back to their CRM list on that column. If present, pass it through to staircase.py (step 4) as --join-id-key <value>.
If the file is missing, tell the user to run the init flow first (/agentic-analytics:init on harnesses with slash commands, or "set up agentic analytics" in chat) and stop.
The site ID filter:
- If empty: the report runs across every site in the bucket. Pass NO
--site-id flag to staircase.py.
- If non-empty: filter events to that site. Pass
--site-id <value> to staircase.py. The site ID itself shows up in the report's Site ID filter: line, so the report stays identifiable without a separate header label.
-
Top up the cache, then compute windows from what's actually on disk. A customer's cache may start well after the date the report was requested. Using a fixed today - 30d window can land the prior window entirely before the data starts, producing a report where every prior count is 0 and every row is labeled "new". Always anchor on what's on disk.
Follow the update-cache skill to pull the latest data into the local lake. The pull is incremental, so already-synced customers pay milliseconds. If the pull fails (auth lapse, S3 hiccup), fall through to whatever is in the lake.
Then probe the catalog for the earliest and latest day available, split the available span into two equal-size windows. In single-site mode filter by site; in all-sites mode query the whole lake:
read earliest latest < <(python3 -c "
import sys; sys.path.insert(0, '$plugin_root/scripts/db/lib')
import catalog
con = catalog.connect()
catalog.ensure_view(con, 'dpl', 'events')
row = con.execute(\"SELECT min(day), max(day) FROM dpl_events WHERE apikey = '<site-id>'\").fetchone()
if row[0] is None: sys.exit(1)
print(row[0], row[1])
")
read earliest latest < <(python3 -c "
import sys; sys.path.insert(0, '$plugin_root/scripts/db/lib')
import catalog
con = catalog.connect()
catalog.ensure_view(con, 'dpl', 'events')
row = con.execute('SELECT min(day), max(day) FROM dpl_events').fetchone()
if row[0] is None: sys.exit(1)
print(row[0], row[1])
")
if [ -z "$earliest" ]; then echo "ERROR: no data found in catalog for the requested site/bucket" >&2; exit 1; fi
read available window current_start current_end prior_start prior_end < <(python3 -c "
from datetime import date, timedelta
import sys
earliest = date.fromisoformat('$earliest')
latest = date.fromisoformat('$latest')
available = (latest - earliest).days + 1
window = 30 if available >= 60 else available // 2
if window < 1:
sys.stderr.write(f'ERROR: not enough catalog coverage (only {available} day(s))\n')
sys.exit(1)
current_end = latest
current_start = latest - timedelta(days=window - 1)
prior_end = latest - timedelta(days=window)
prior_start = prior_end - timedelta(days=window - 1)
print(available, window, current_start, current_end, prior_start, prior_end)
")
[ -z ] || [ -lt 1 ]; 1;
-
Auto-detect employee traffic on first populated cache (silent). If employee_filter_checked is absent from bucket.json, run the detection script in quiet mode now that the pull has had a chance to populate the lake. The script writes employee_filter_checked: true after scanning so this only runs once, and writes employee_filter only if it finds an unambiguous match. On match, the report's own one-line note (rendered by staircase.py when --employee-filter-* is passed) is what surfaces the result – this step adds no chat output of its own.
if ! python3 -c "import json,sys; sys.exit(0 if json.load(open('${XDG_CONFIG_HOME:-$HOME/.config}/agentic-analytics/bucket.json')).get('employee_filter_checked') else 1)" 2>/dev/null; then
"$plugin_python" "$plugin_root/skills/identify-employees/scripts/detect_employee_filter.py" \
--bucket-config "${XDG_CONFIG_HOME:-$HOME/.config}/agentic-analytics/bucket.json" \
[--site <site-id>] \
--quiet || true
fi
Pass --site <site-id> only when the site ID filter is non-empty (single-site mode); omit it in all-sites mode.
The || true is intentional: detection failures (empty lake, transient I/O) should never block the report. Re-read bucket.json after this step so any newly-written employee_filter is available for step 4.
-
Run the report. Slug the output filename from cache_dir plus the site ID filter (or all when none):
slug="<cache_dir>$([ -n "<site-id>" ] && echo "-<site-id>" || echo "-all")"
"$plugin_python" "$plugin_root/skills/staircase-report/scripts/staircase.py" \
[--site-id <site-id>] \
--current <current_start> <current_end> \
--prior <prior_start> <prior_end> \
[--internal-domains <corp-domain>] \
[--network-label "<network-label>" --network-sources "<network-sources>"] \
[--data-coverage-note "$note"] \
[--employee-filter-key <key> --employee-filter-value <json-value>] \
[--join-id-key <key>] \
--output-md /tmp/staircase-$slug.md \
--output-html /tmp/staircase-$slug.html
--events-dir and --cache-root are NOT passed; staircase.py opens the catalog and reads events from the lake internally.
--site-label is omitted in the public flow – reports just say "Relationship Staircase Report" at the top. Callers that want a labeled header (the dev wrapper) can still pass --site-label "<value>".
--network-label and --network-sources are paired: pass both or neither. They classify cross-promotion traffic from the customer's own properties under a named channel. Source values for the customer come from the wrapper (when invoked via the dev /staircase wrapper) or future plugin config; the public init flow doesn't set them.
--internal-domains only applies when the wrapper supplies a corp domain or the user has configured one. Plain customer installs typically omit it.
--employee-filter-key and --employee-filter-value are paired: pass both or neither. Read from bucket.json["employee_filter"] when present (step 1). The key passes through as-is. The value must be JSON-encoded on the command line: a Python boolean becomes true or false, a string gets wrapped in quotes (e.g. with single-quoted shell escaping), and numbers stay bare (). decodes it with and compares with against . When set, matching events are excluded from the report and a one-line note appears at the top.
-
Output the report: read /tmp/staircase-$slug.md and emit its contents verbatim. Don't add formatting, don't add framing.
-
Append the HTML link as the last line, with one blank line separating it from the report body:
[Open the HTML version in browser](file:///tmp/staircase-$slug.html)
The link target is the .html file — same $slug as step 4, with the .html extension. Do not link the .md file: that's the report body you just emitted verbatim in step 5, not the HTML version. The file:// protocol matters: a bare path can be intercepted by an editor.