Automattic logo

Skill

journey-report

run website visitor journey reports

Covers Reporting Data Analysis Analytics

Description

Run the Journey Report — how far into the site a visit gets and where it ends. Use when the user asks to run the journey report, asks how deep visitors go, where the site loses people, what route visitors take after landing, which pages or sections have the worst loss or exit rate, engagement depth, or how a section of the site performs. Also use for follow-up questions about visit paths, entry points, campaigns or engaged time once a report has been run.

SKILL.md

Run the Journey Report.

The staircase report asks how often a visitor comes back. This asks how much of the site a single visit gets through. They answer different questions and both are meant to exist.

It is built for content sites where signing someone up is not the KPI — the job is to bring people in, have them consume something, and have them leave with a better impression. So the report measures how far into the site a visit gets and where the site loses it. Conversions are counted but are never the headline.

Nothing in it is specific to one customer. Sections are derived from the URL path, so the report runs against any site or business unit without being told what that site contains first.

Output ONLY the report markdown, then a single file:// link to the HTML version. No preamble, no postscript, no re-narration. The report is the analysis; you are the pipe. This applies to conversational invocations exactly as it does to the slash command.

The one exception is the follow-up questions in the last section below. Those are answered in chat, in prose, because the report deliberately does not try to answer everything.

Steps

  1. Resolve the plugin root and the venv python. Re-run this 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 directory two levels above this SKILL.md.
  2. Load the config. Read ${XDG_CONFIG_HOME:-~/.config}/agentic-analytics/bucket.json for bucket, profile and cache_dir. If it is missing, tell the user to run the init flow first (/agentic-analytics:init) and stop. Do not guess values.
    That is the only config file. Journey settings, if there are any, live under a journey key in the same file — the pattern identify-employees already uses for employee_filter. If the key is absent the report runs on defaults, which is the intended way to point it at a new site or business unit. There is nothing to create and nothing to copy.
    What the report works out for itself: sections come from the URL path, so everything under /products/ belongs to section products, and so does /products itself. No list of sections, nothing read from page metadata. A leading locale is detected from the data and removed first, so /de-de/products and /products are one section and the report does not name six languages as its six biggest sections; it says so when it does this, and locale_prefixes overrides it. Two things a URL cannot tell you are the only entries worth adding — how the site tags paid traffic (source_rules) and which traffic blocs cannot be measured (traffic_quality_exclusions) — plus tuning such as the engaged-time bar.
  3. Resolve the site. --site wins; then journey.site; then, if the lake holds exactly one apikey, that one. With several and no answer the engine refuses and lists them, rather than picking.
  4. Top up the cache. Follow the update-cache skill to pull the latest data into the local lake. The pull is incremental, so an already-synced customer pays milliseconds. If the pull fails (auth lapse, S3 hiccup), fall through to whatever is in the lake — the report states its own coverage.
    The Journey Report offers a 90-day window and each window compares against a prior window of equal length, so a full 90-day comparison needs 180 days on disk. The canonical 60-day pull keeps the cache current for the default 60-day window; the 90-day comparison accumulates over subsequent runs as the lake grows. Until it has enough data the engine withholds the change figure and says why, which is correct but less useful.
  5. Run the report. Two ways, and the difference matters.
    Serve it when the user is at their machine and might want to ask anything beyond what the page shows. This is the default; prefer it.
    "$plugin_python" "$plugin_root/skills/journey-report/scripts/serve.py" \
      [--site <site-id>] --windows 7,30,60,90 --default-window 60
    

    It builds the report, opens the browser, and stays running so the question box on the page can reach the data. Tell the user in one line that it is running and that Ctrl-C stops it. Do not wait for it to exit.
    Write files when the user wants something to keep, attach or send. A file has no server behind it, so the question box on that copy explains that rather than pretending.
    slug="<cache_dir>$([ -n "<site-id>" ] && echo "-<site-id>" || echo "-all")"
    "$plugin_python" "$plugin_root/skills/journey-report/scripts/journey.py" \
      [--site <site-id>] \
      --windows 7,30,60,90 --default-window 60 \
      --output-md /tmp/journey-$slug.md \
      --output-html /tmp/journey-$slug.html \
      --output-json /tmp/journey-$slug.json
    

    Neither takes --config: both read the plugin's own bucket.json. Pass --site only when the user names one, or when the lake holds more than one site and the config does not say which.
    If either exits non-zero, do not improvise. The engine refuses to render when a consistency check fails, by design: a plausible-looking wrong number is worse than no report, because nobody audits a number that looks fine. Show the user the error verbatim and stop. --no-strict exists for debugging and must never be used for a customer-facing run.
  6. Output. When you served it, give the URL the server printed and stop — the report is in the browser and the user reads it there. When you wrote files, read /tmp/journey-$slug.md and emit its contents verbatim, then the HTML link on the last line, one blank line after the body:
    [Open the HTML version in browser](file:///tmp/journey-$slug.html)
    

    Link the .html, never the .md — the markdown is what you just emitted. The file:// protocol matters: a bare path gets intercepted by an editor.
    The HTML carries more than the markdown, deliberately: the three-level route tree, the entry/campaign/source filters, the window switcher and the PDF export. Do not try to mirror it into chat.

What the report contains

Engagement depth is the metric everything else hangs off. A visit is a run of events from one visitor with no gap over the configured session gap. A qualifying action is a pageview of a URL not already seen in that visit that cleared the engaged-time bar, or a download, or a video play. Depth is the count of those, minimum 1. The headline is the share of visits at depth 2 or more.

Alongside it the report always shows the same share computed without the engaged-time rule. That contrast is not decoration: it is what demonstrates the metric is not ordinary pages per session, and the engaged-time signal comes from Parse.ly heartbeats arriving as events in the DPL, which is the commercial differentiator.

The headline will be low. Never inflate it by excluding single-action visits from the denominator, and never count non-engaged pageviews toward it. It is a baseline to improve.

Route tree, anchored on a selectable entry point and going forward three levels. Branches are sections, so it reads as "of the visits that landed on /products, how many went on to /news, to /support, or to another page inside /products". Exit is a first-class branch at every level, listed first, with its own share and volume, because it is the largest outcome. No conversion gating: every path is traced whether or not it ends in a conversion. That gating is the specific limitation of Parse.ly's own Journeys view that this section exists to fix.

Loss map, pages ranked by the share of visits that end on them, with the most common next step for those who did continue.

Section scoreboard, with size in one column and pull in the other. The finding lives in the contrast, not in either column alone. Sections are derived, so this table needs no list of what the site contains.

Site shape, measured rather than assumed. Sections read correctly where the site has a section layer and degenerate on a flat site, where every article sits at the root and becomes a section of one page. Both are real shapes; the report states which one it is looking at instead of applying a threshold that would make a flat site look structured.

Composition tiles, so the headline is auditable: engaged against loaded pageviews, downloads, video plays, visits, unique visitors, median engaged time.

Traffic set aside. Blocs configured as traffic-quality exclusions are reported with their own volume and headline rather than dropped. Where a signal is genuinely absent, say which signal is missing and what it would unlock. Never render a silent zero.

Filter scope — decided, do not change

FilterScope
Time window (7 / 30 / 60 / 90 days)Global. Every section recomputes. A header saying "last 7 days" while any number on the page still reflects 60 is a correctness bug, not a gap.
Entry pointLocal to the route tree
CampaignLocal to the route tree
Entry sourceLocal to the route tree

Every window compares against the immediately prior window of equal length, anchored on the latest complete day in the data, never on today.

Follow-up questions

The report is a starting point, not the product. There are two places a follow-up can be answered, and they are good at different things.

The question box on the served page is you. It hands the question to claude verbatim and renders the reply, so the user gets the command line without leaving the report. Nothing is added to the question and nothing is pre-answered; whatever you would say in a terminal is what appears in the box. Point the user at it rather than asking them to switch windows.

It runs headless with an explicit read-only allow-list, so a question can read the data but cannot change anything. If the box reports that tools were denied, the machine's permission settings are stricter than the default; starting the server with JOURNEY_ASK_PERMISSION_MODE=bypassPermissions is the way out.

You, here in the conversation, for anything it cannot do: an odd cut, a combination of filters, a question that needs judgement rather than a table. Query the same derived tables rather than guessing or re-reading raw parquet. Build them once, then use SQL:

"$plugin_python" - <<'PY'
import sys, json
sys.path.insert(0, "PLUGIN_ROOT/scripts/db/lib")
import catalog, sessions
cfg = json.load(open("CONFIG_PATH"))
con = catalog.connect(); catalog.ensure_view(con, "dpl", "events")
lo, hi = sessions.available_days(con, cfg["site"])
sessions.build(con, cfg, site=cfg["site"], day_start=lo, day_end=hi)
# ... then query j_visit / j_step / j_pv / j_sess
PY

The tables, and what one row means in each:

TableOne row isKey columns
j_pva pageviewvsid, ts, url_path, title, engaged_seconds, section, is_section_landing, source, campaign, exclusion
j_actanything that can earn a pointkind in pageview / download / videostart, conv_type, conv_label
j_sessj_act plus a visit idsid
j_stepa distinct URL within one visit, in orderstep_idx (0 is the entry), qualifies, engaged_seconds
j_visita visitentry_path, entry_title, entry_section, source, campaign, depth_qualified, depth_raw, loaded_pageviews, downloads, video_plays, max_step_idx, exclusion

Filter exclusion IS NULL to match the report's clean base. Three worked examples:

-- which pages send the most traffic onward into a different section
SELECT s.title, count(*) AS onward
FROM j_step s JOIN j_step n
  ON n.vsid = s.vsid AND n.sid = s.sid AND n.step_idx = s.step_idx + 1
JOIN j_visit v ON v.vsid = s.vsid AND v.sid = s.sid
WHERE v.exclusion IS NULL AND s.section <> n.section
GROUP BY 1 ORDER BY onward DESC LIMIT 20;

-- entry pages with the worst loss rate, this month against last
SELECT v.entry_title,
       date_trunc('month', v.started_at) AS month,
       count(*) AS visits,
       round(100.0 * avg(CASE WHEN v.max_step_idx = 0 THEN 1 ELSE 0 END), 1) AS loss_pct
FROM j_visit v WHERE v.exclusion IS NULL
GROUP BY 1, 2 HAVING count(*) > 200 ORDER BY 1, 2;

-- for campaign traffic, the most common three-page path
SELECT s0.url_path AS p1, s1.url_path AS p2, s2.url_path AS p3, count(*) AS visits
FROM j_visit v
JOIN j_step s0 ON s0.vsid = v.vsid AND s0.sid = v.sid AND s0.step_idx = 0
JOIN j_step s1 ON s1.vsid = v.vsid AND s1.sid = v.sid AND s1.step_idx = 1
JOIN j_step s2 ON s2.vsid = v.vsid AND s2.sid = v.sid AND s2.step_idx = 2
WHERE v.exclusion IS NULL AND v.campaign = 'PaidCampaign'
GROUP BY 1, 2, 3 ORDER BY visits DESC LIMIT 20;

Answer follow-ups in prose with the numbers inline. Two rules carry over from the report itself: state the volume behind any rate you quote, and if the answer is that the data cannot support the question, say that instead of producing a number.

Constraints (re-stated for emphasis)

  • No "Running the journey report…" preamble, no "Two things stand out…" postscript.
  • If a consistency check fails, surface the error and stop. Do not render partial output and do not reach for --no-strict.
  • If the lake has no events for the configured site, say so in one line and stop.
  • The trailing link points at the .html from step 4, never the .md.
  • Sections are derived from the URL and must stay that way. If you find yourself wanting to write a list of the site's sections, or to read one from page metadata, stop: that is how this became a bespoke report for one customer the first time.
  • There is one config file and the plugin already owns it. Do not add a second.

© 2026 YourAI.tools. Every skill from an identity-verified publisher.

Independent catalog. Not affiliated with, endorsed by, or sponsored by Anthropic or any listed publisher. All trademarks belong to their respective owners.