Add personio-headcount skill (migrated from plugin)
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
---
|
||||
name: personio-headcount
|
||||
description: >
|
||||
Retrieves and reports HelloPrint's current headcount from Personio via the N8N Personio MCP tool.
|
||||
Maintains a local anonymized snapshot (no PII — no names or personal details) so that repeat
|
||||
queries are instant and only delta/incremental fetches are needed. Can generate professional
|
||||
PDF workforce reports with team/department breakdowns, joiner/leaver analysis, and net movement.
|
||||
Use this skill whenever someone asks about headcount, employee count, active employees, FTE count,
|
||||
team size, staffing numbers, Personio data, workforce metrics, workforce report, HR report,
|
||||
or anything related to how many people work at HelloPrint. Also trigger when someone mentions
|
||||
"personio headcount", "HR numbers", "who's active", "onboarding count", "leavers", "attrition",
|
||||
"joiners", "departures", "workforce insights", "headcount report", "team size report",
|
||||
or wants a breakdown by status, department, team, office, or start date. Even casual questions
|
||||
like "how many people do we have?" should trigger this skill.
|
||||
---
|
||||
|
||||
# Personio Headcount Skill
|
||||
|
||||
This skill gives you instant access to HelloPrint's workforce numbers by maintaining a local
|
||||
anonymized snapshot of Personio data. It connects to Personio via the **"N8N Personio"** MCP
|
||||
server (URL: `https://helloprint-staging.app.n8n.cloud/mcp/personio`).
|
||||
|
||||
## How It Works
|
||||
|
||||
There are three modes:
|
||||
|
||||
1. **Snapshot mode** (fast) — uses cached data to answer headcount questions instantly
|
||||
2. **Refresh mode** — fetches latest from Personio via `Call_Personio_Get_Employees_` MCP tool in batches of 50
|
||||
3. **Report mode** — generates a professional PDF workforce insights report
|
||||
|
||||
The skill stores anonymized employee records — only IDs, status, team, department, office, and
|
||||
dates. Never names, emails, or personal information.
|
||||
|
||||
### Data Location
|
||||
|
||||
```
|
||||
<skill-directory>/data/headcount_snapshot.json — anonymized employee records
|
||||
<skill-directory>/data/team_mapping.json — team_id ↔ team name lookup (derived)
|
||||
<skill-directory>/data/department_mapping.json — department_id ↔ department name + team groupings (derived)
|
||||
<skill-directory>/data/office_mapping.json — office_id with headcounts (derived)
|
||||
<skill-directory>/data/team-mapping.csv — SOURCE OF TRUTH for team ID → name
|
||||
<skill-directory>/data/department-mapping.csv — SOURCE OF TRUTH for department ID → name
|
||||
<skill-directory>/scripts/build_snapshot.py — merge/deduplicate employee data, rebuild mappings
|
||||
<skill-directory>/scripts/build_report.py — generate workforce insights PDF report
|
||||
```
|
||||
|
||||
The two CSV files (`team-mapping.csv` and `department-mapping.csv`) are the **single source of
|
||||
truth** for human-readable names. The JSON mapping files are derived from these CSVs combined with
|
||||
headcount data from the snapshot. When rebuilding mappings, always read names from the CSVs.
|
||||
|
||||
Read the snapshot first. If it exists and `last_refreshed` is recent enough for the user's needs,
|
||||
just report from the cached data. If it's stale or the user explicitly asks for fresh data,
|
||||
run a refresh. Always load the team mapping alongside the snapshot to resolve team names.
|
||||
|
||||
### Snapshot Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"last_refreshed": "2026-03-04T19:00:00Z",
|
||||
"total_records": 755,
|
||||
"summary": {
|
||||
"ACTIVE": 177,
|
||||
"INACTIVE": 574,
|
||||
"ONBOARDING": 1,
|
||||
"LEAVE": 3
|
||||
},
|
||||
"employees": [
|
||||
{
|
||||
"personio_id": "35653993",
|
||||
"status": "ONBOARDING",
|
||||
"team_id": "417186",
|
||||
"department_id": "1291054",
|
||||
"office_id": "501083",
|
||||
"start_date": "2026-05-01",
|
||||
"end_date": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Team Mapping Structure
|
||||
|
||||
The team mapping at `data/team_mapping.json` maps real Personio team IDs to human-readable names:
|
||||
|
||||
```json
|
||||
{
|
||||
"total_teams": 65,
|
||||
"teams": [
|
||||
{
|
||||
"team_id": "4400959",
|
||||
"team_name": "AI & Automation",
|
||||
"active_headcount": 6,
|
||||
"total_records": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use `team_id` in the snapshot to look up the human-readable name from the mapping.
|
||||
|
||||
### Department Mapping Structure
|
||||
|
||||
The department mapping at `data/department_mapping.json` groups teams under departments with
|
||||
real names from `department-mapping.csv`:
|
||||
|
||||
```json
|
||||
{
|
||||
"total_departments": 19,
|
||||
"departments": [
|
||||
{
|
||||
"department_id": "1291054",
|
||||
"department_name": "Finance & Business Navigation",
|
||||
"active_headcount": 11,
|
||||
"total_records": 45,
|
||||
"team_ids": ["417186", "417184", "3442693", "3443053", "572110"],
|
||||
"teams": ["Data", "Finance & Administration", "M&A", "Pricing", "Rev Ops"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Office Mapping Structure
|
||||
|
||||
The office mapping at `data/office_mapping.json` tracks headcount per office location:
|
||||
|
||||
```json
|
||||
{
|
||||
"total_offices": 8,
|
||||
"offices": [
|
||||
{
|
||||
"office_id": "501083",
|
||||
"active_headcount": 120,
|
||||
"total_records": 500
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Answering Headcount Questions
|
||||
|
||||
When the user asks about headcount:
|
||||
|
||||
1. **Read the snapshot** from `data/headcount_snapshot.json`
|
||||
2. **Read the team mapping** from `data/team_mapping.json`
|
||||
3. **Check freshness** — if `last_refreshed` is today, use it directly. If older, mention the
|
||||
date and offer to refresh.
|
||||
4. **Report the answer** — the "current headcount" is the count of employees with `status = "ACTIVE"`.
|
||||
|
||||
Common questions and how to derive them:
|
||||
- **"Current headcount"** → count where status = ACTIVE
|
||||
- **"Total employees in system"** → total_records
|
||||
- **"How many people left?"** → count where status = INACTIVE
|
||||
- **"Who's onboarding?"** → count where status = ONBOARDING
|
||||
- **"People on leave"** → count where status = LEAVE
|
||||
- **"Headcount by team"** → group ACTIVE employees by `team_id`, look up names from team_mapping.json
|
||||
- **"Headcount by department"** → group ACTIVE employees by `department_id`, use department_mapping.json for team groupings
|
||||
- **"Headcount by office"** → group ACTIVE employees by `office_id`
|
||||
- **"How big is team X?"** → find team by name in mapping, count ACTIVE employees with that team_id
|
||||
- **"New hires this year"** → ACTIVE employees where start_date >= 2026-01-01
|
||||
|
||||
## Refreshing the Data
|
||||
|
||||
When fresh data is needed, fetch from Personio via the **N8N Personio** MCP tool.
|
||||
|
||||
### Full Refresh Process
|
||||
|
||||
Use the `Call_Personio_Get_Employees_` MCP tool from the "N8N Personio" server:
|
||||
|
||||
```
|
||||
Step 1: Fetch first page
|
||||
Call_Personio_Get_Employees_ with input: {"limit": 50}
|
||||
|
||||
Step 2: Parse result
|
||||
The result is a JSON array containing one object with:
|
||||
- employees: array of employee objects
|
||||
- totalCount: number of records returned
|
||||
- nextCursor: pagination cursor string
|
||||
- hasMore: boolean
|
||||
|
||||
Step 3: If hasMore is true, fetch next page
|
||||
Call_Personio_Get_Employees_ with input: {"limit": 50, "cursor": "<nextCursor>"}
|
||||
|
||||
Step 4: Repeat until hasMore is false
|
||||
```
|
||||
|
||||
Each employee record from the MCP tool contains:
|
||||
- `id` → store as `personio_id`
|
||||
- `status` → ACTIVE, INACTIVE, ONBOARDING, or LEAVE
|
||||
- `team_id` → Personio team ID (look up name in team_mapping.json)
|
||||
- `department_id` → Personio department ID
|
||||
- `office_id` → Personio office ID
|
||||
- `start_date` → employment start
|
||||
- `end_date` → contract end date (null if ongoing)
|
||||
- `first_name`, `last_name`, `email` → **NEVER STORE THESE** — strip before saving
|
||||
|
||||
### Incremental Refresh (Delta Update)
|
||||
|
||||
For efficiency, you don't always need a full refresh. The incremental approach:
|
||||
|
||||
1. Read the existing snapshot
|
||||
2. Fetch all pages from Personio (the API doesn't support a "modified since" filter yet)
|
||||
3. Compare each fetched employee against the snapshot by `personio_id`:
|
||||
- If ID exists → update the record (status, team_id, etc. may have changed)
|
||||
- If ID is new → add it
|
||||
- IDs in snapshot but not in fresh data → keep them (they're historical)
|
||||
4. Recalculate the summary counts
|
||||
5. Update `last_refreshed` timestamp
|
||||
6. Rebuild team_mapping.json counts (active_headcount and total_records per team)
|
||||
7. Save all files
|
||||
|
||||
The helper script at `scripts/build_snapshot.py` automates the merge logic. After collecting
|
||||
all employee batches into a list, run:
|
||||
|
||||
```bash
|
||||
python3 <skill-dir>/scripts/build_snapshot.py \
|
||||
--existing <skill-dir>/data/headcount_snapshot.json \
|
||||
--new-data /tmp/fresh_employees.json \
|
||||
--output <skill-dir>/data/headcount_snapshot.json
|
||||
```
|
||||
|
||||
### Important: Stripping PII
|
||||
|
||||
Before storing ANY data, remove all personally identifiable information:
|
||||
- Remove `first_name` and `last_name`
|
||||
- Remove `email`
|
||||
- Keep only: personio_id, status, team_id, department_id, office_id, start_date, end_date
|
||||
|
||||
The `personio_id` is an internal system identifier, not PII — it's needed for deduplication
|
||||
during incremental updates.
|
||||
|
||||
## MCP Tool Reference
|
||||
|
||||
**MCP Server:** n8n-personio (local config in `claude_desktop_config.json`)
|
||||
**URL:** `https://helloprint-staging.app.n8n.cloud/mcp/personio`
|
||||
**Auth:** Bearer token
|
||||
**Tool:** `Call_Personio_Get_Employees_` (full ID: `mcp__n8n-personio__Call_Personio_Get_Employees_`)
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|--------|---------|--------------------------------------|
|
||||
| limit | number | 50 | Records per page (use 50 for balance)|
|
||||
| cursor | string | "" | Pagination cursor from previous page |
|
||||
|
||||
Pass parameters as a JSON string in the `input` field:
|
||||
- First page: `{"limit": 50}`
|
||||
- Subsequent pages: `{"limit": 50, "cursor": "<nextCursor>"}`
|
||||
|
||||
### Authentication
|
||||
|
||||
The N8N MCP Server Trigger uses **Bearer token authentication**. This is configured in the local
|
||||
`claude_desktop_config.json` under the MCP server name `n8n-personio`. The Bearer token must
|
||||
match the one set in N8N's MCP Server Trigger node.
|
||||
|
||||
Note: Cowork's built-in MCP connector UI does not support Bearer or Header Auth — the local
|
||||
config approach via `claude_desktop_config.json` is the working method.
|
||||
|
||||
## Generating a PDF Report
|
||||
|
||||
When the user asks for a workforce report, headcount report, or PDF report, use the
|
||||
`build_report.py` script to generate a professional PDF.
|
||||
|
||||
### Running the Report Script
|
||||
|
||||
```bash
|
||||
pip install reportlab --break-system-packages
|
||||
python3 <skill-dir>/scripts/build_report.py \
|
||||
--data-dir <skill-dir>/data \
|
||||
--output /path/to/output/helloprint_workforce_report.pdf
|
||||
```
|
||||
|
||||
### What the Report Contains
|
||||
|
||||
The PDF report has 6 pages:
|
||||
|
||||
1. **Cover page** — KPI cards showing active employees, onboarding, on leave, and inactive counts
|
||||
2. **Section 1: Headcount by Team** — all active teams ranked by headcount with percentages
|
||||
3. **Section 2: Headcount by Department** — departments with team listings
|
||||
4. **Section 3: New Joiners** — monthly breakdown since January 2025, with team attribution
|
||||
5. **Section 4: Departures** — monthly breakdown since January 2025, with team attribution
|
||||
6. **Section 5: Net Workforce Movement** — monthly joiners vs leavers with cumulative net change
|
||||
|
||||
Each section includes insight paragraphs highlighting trends (peak hiring months, largest teams,
|
||||
year-start turnover patterns).
|
||||
|
||||
### XML Escaping
|
||||
|
||||
The report script uses `xml_escape()` to handle special characters in team and department names
|
||||
(e.g., "M&A" → "M&A"). This is necessary because reportlab's Paragraph class uses an XML
|
||||
parser internally. The `make_table()` helper automatically escapes all cell content.
|
||||
|
||||
### Customizing the Report
|
||||
|
||||
The script is parameterized via `--data-dir` and `--output`. The color scheme uses HelloPrint
|
||||
branding (HP_BLUE `#1a3a5c`, HP_LIGHT_BLUE `#2980b9`). To change the date range for joiners/
|
||||
leavers analysis, modify the `datetime(2025, 1, 1)` threshold in `build_report()`.
|
||||
|
||||
## Output Guidelines
|
||||
|
||||
When reporting headcount:
|
||||
- Lead with the headline number: **"Current active headcount: X"**
|
||||
- Mention the snapshot date so the user knows how fresh the data is
|
||||
- Offer a breakdown if relevant (by status, team, department, office)
|
||||
- If the data is more than 1 day old, proactively offer to refresh
|
||||
Reference in New Issue
Block a user