This repository contains a small SvelteKit + Node service that reads data from Intercom and exposes:
The system is designed to be:
/conversations/search and /contacts/search APIs.The following conversation-level attributes are expected to exist in Intercom:
Channel (custom conversation attribute)PhoneVideo ConferenceEmailChatService Code (custom conversation attribute)Health Coaching 001Disease Management 002Qualifying coaching call (for engagement):
A conversation where:
custom_attributes.Channel∈ {Phone,Video Conference}custom_attributes.Service Code∈ {Health Coaching 001,Disease Management 002}state = closed
The following contact-level custom attributes are either consumed or maintained by this service:
Enrolled Date (Date)
Registration Date (Date)
Next Coaching Session (Date)
Last Coaching Session (Date / Unix seconds)
closedLast Call (Date / Unix seconds)
Phonelast_close_at when closedcreated_at when not yet closedFirst Session Date (Date / Unix seconds)
Engagement Status (List: Engaged / At Risk / Unengaged)
Engagement Status Date (Date / Unix seconds)
Referral (string)
Counter Health).Eligible Programs (string / list)
Smart Access when Referral = Counter Health).Employer (string)
User ID / Registration Code / Date of Birth
For Engagement Status, we only consider qualifying coaching calls:
state = closedcustom_attributes.Channel ∈ {Phone, Video Conference}custom_attributes.Service Code ∈ {Health Coaching 001, Disease Management 002}These calls feed into Last Coaching Session, First Session Date, and the engagement classifier.
Given a member’s Last Coaching Session (if present) and Enrolled Date, we compute:
Engaged
At Risk
Unengaged
All UI pages live under /intercom/* and talk to /API/intercom/* endpoints.
/intercom/caseloadBackend: POST /API/intercom/caseload
Purpose: Show unique members with at least one coaching session in the lookback window, bucketed by time since last session and channel combination.
For these reports, a coaching session is:
Channel ∈ {Phone, Video Conference, Email, Chat}Note: this is broader than the qualifying coaching call definition used for Engagement Status.
Buckets are based on daysSinceLastSession:
bucket_1: ≤ 7 daysbucket_2: 8–28 daysbucket_3: 29–56 daysbucket_4: > 56 daysCaseloadReport:
summary.bucket_* – count of members in each bucket (all members, unfiltered)members[] – one row per member:sessions[] – session-level detail (used by Sessions UI)/intercom/sessionsBackend: reuses POST /API/intercom/caseload and consumes the sessions[] array.
Purpose: Show session-level counts, not unique members, across configurable windows.
/intercom/new-participantsBackend: POST /API/intercom/new-participants
Purpose: Focus on enrolled participants and bucket them by days without a coaching session.
For each enrolled participant:
daysWithoutSession = days since last coaching session.daysWithoutSession = days since Enrolled Date.gt_14_to_21: 14–21 days without a sessiongt_21_to_28: 22–28 days without a sessiongt_28: > 28 days without a session (considered Unengaged for this report only)This “Unengaged” concept is local to this report and does not overwrite the global Engagement Status attribute.
/intercom/billingBackend: POST /API/intercom/billing
Purpose: Identify billable members for a given calendar month and expose them as an exportable list.
For a given month (monthYearLabel = YYYY-MM):
A member is included if:
They became a new participant during that month (based on Enrolled Date), OR
They met Engaged Participant criteria on at least one day that month:
The backend:
lastSessionAt per member and identifies:memberId (User ID)memberNamememberEmailemployerregistrationAt (now effectively Enrolled Date)lastSessionAtisNewParticipantengagedDuringMonthEndpoint (example): POST /API/intercom/session-sync
Purpose:
Last Coaching Session (qualifying calls with Service Code filter)First Session Date (for newly enrolled members)Last Call (latest phone call, open or closed conversation)Inputs (JSON):
{
"lookbackDays": 365,
"dryRun": true
}
lookbackDays (optional, default in code): days back from “now” to scan conversations.dryRun (boolean): if true, logs would-be updates but does not write to Intercom.Key behaviors & edge cases:
Phonelast_close_at if closed, otherwise created_at.Endpoint (example): POST /API/intercom/engagement-sync
Purpose:
Engagement StatusEngagement Status DateInputs (JSON):
{
"lookbackDays": 365,
"dryRun": true
}
Core logic:
Engagement Status.Engagement Status Date to the current timestamp.Endpoint (example): POST /API/intercom/referral-sync
Purpose:
Current rule:
Referral = "Counter Health", then set:Eligible Programs = "Smart Access".The service can be extended to support additional referral-to-program mappings in the future.
Endpoint: POST /API/intercom/report/engagement
Purpose:
Body (JSON):
{
"outputPath": "/absolute/or/relative/path/to/engagement_report.csv",
"referral": "Counter Health",
"employer": "Acme Corp",
"enrolledDateFrom": "2025-01-01",
"enrolledDateTo": "2025-12-31",
"lastSessionFrom": "2025-03-01",
"lastSessionTo": "2025-03-31",
"engagementStatus": "Engaged",
"perPage": 150
}
All filters are optional; when present, they are pushed down into Intercom queries where possible.
CSV columns (example mapping):
<CSV column name> | <Intercom user attribute> | <Description>
employee_id | User ID | Member’s unique ID in client system.name_first | Name | Member’s first name (without middle).name_last | Name | Member’s last name.member_dob | Date of Birth | ISO 8601 date of birth.group_description | Employer | Employer / group name.last_coaching_session | Last Coaching Session | ISO 8601 date of last qualifying coaching session.program_status | Engagement Status | Current engagement status.status_date | Engagement Status Date | ISO 8601 date when status was set.eligible_programs | Eligible Programs | Program eligibility string.registration_code | Registration Code | Registration code in USPM platform.Environment variables are loaded via SvelteKit’s $env/static/private:
import {
INTERCOM_ACCESS_TOKEN,
INTERCOM_VERSION,
INTERCOM_API_BASE
} from '$env/static/private';
INTERCOM_ACCESS_TOKENcontacts:readcontacts:write (for engagement & helper syncs)conversations:readdata_attributes:read (recommended)INTERCOM_ACCESS_TOKEN=xxxxxxxxxxxxxxxx
INTERCOM_VERSION
2.10 if not set.INTERCOM_VERSION=2.11
INTERCOM_API_BASE
https://api.intercom.io..env exampleCreate a .env file in the project root:
INTERCOM_ACCESS_TOKEN=your_intercom_pat_here
INTERCOM_VERSION=2.11
INTERCOM_API_BASE=https://api.intercom.io
⚠️ These env vars must be available at build time (for
$env/static/private) and at runtime when using a Node adapter.
Check versions:
node -v
npm -v
From the project root:
npm install
Create .env as shown above (or export env vars in your shell).
npm run dev
By default, SvelteKit serves on http://localhost:5173.
Useful routes:
http://localhost:5173/intercom – Reports homehttp://localhost:5173/intercom/caseload – Caseload reporthttp://localhost:5173/intercom/sessions – Sessions reporthttp://localhost:5173/intercom/new-participants – Enrolled participants reporthttp://localhost:5173/intercom/billing – Billing reportnpm test
# or
npm run test:unit
npm run build
With the default @sveltejs/adapter-auto, SvelteKit will try to infer the target. For AWS or other Node environments, it is recommended to use the Node adapter.
Install:
npm install -D @sveltejs/adapter-node
Update svelte.config.js:
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;
Build:
npm run build
This produces a Node server in build/. Run locally with:
node build/index.js
There are many ways to deploy this app. A simple pattern:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs git
git clone <your-github-repo-url> intercom-engagement-job
cd intercom-engagement-job
npm install
npm run build
Create a systemd unit, e.g. /etc/systemd/system/intercom-reports.service:
[Unit]
Description=Intercom Engagement & Coaching Reports
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/ubuntu/intercom-engagement-job
Environment=INTERCOM_ACCESS_TOKEN=your_pat_here
Environment=INTERCOM_VERSION=2.11
Environment=INTERCOM_API_BASE=https://api.intercom.io
ExecStart=/usr/bin/node build/index.js
Restart=on-failure
User=ubuntu
[Install]
WantedBy=multi-user.target
Reload and start:
sudo systemctl daemon-reload
sudo systemctl enable intercom-reports
sudo systemctl start intercom-reports
sudo systemctl status intercom-reports
Expose the app:
For example, to run the engagement classifier daily at 2 AM on the EC2 instance:
crontab -e
Add:
0 2 * * * curl -X POST http://localhost:3000/API/intercom/engagement-sync -H "Content-Type: application/json" -d '{"lookbackDays": 365, "dryRun": false}' >> /var/log/intercom-engagement-cron.log 2>&1
You can follow a similar pattern for:
session-sync backfill / maintenance jobsreferral-sync runsreport/engagement exports (if you want scheduled CSV generation)Recommendations:
INTERCOM_ACCESS_TOKEN as highly sensitive:You can adjust business rules in the following files:
src/routes/API/intercom/session-sync/+server.tssrc/routes/API/intercom/engagement-sync/+server.tssrc/routes/API/intercom/referral-sync/+server.tssrc/routes/API/intercom/report/engagement/+server.tssrc/routes/API/intercom/caseload/+server.tssrc/routes/API/intercom/new-participants/+server.tssrc/routes/API/intercom/billing/+server.tsAnd keep documentation aligned in:
src/routes/intercom/+page.svelte (Reports home & glossary)README.mdWhenever you change:
Update both:
+server.ts files), and This keeps BI stakeholders, coaching teams, and engineering all aligned on a single source of truth.