Use these workflows when you want a repeatable scheduled job without running a
server. Keep HollyHR tokens in GitHub Actions secrets, run the workflows in a
private repository when they emit HR data, and start with read-only scopes.
Recommended repository secrets:
Secret
Used for
HOLLYHR_API_BASE_URL
Tenant API base URL, for example https://acme.hollyhr.com/api/v1.
HOLLYHR_API_TOKEN
Scoped HollyHR API key. Start with read-only scopes.
HOLLYHR_MCP_URL
Tenant MCP URL, for example https://acme.hollyhr.com/api/mcp.
SLACK_WEBHOOK_URL
Slack incoming webhook for the digest recipe.
SDK first-call smoke
This workflow installs the public SDK and runs the packaged first-call example.
Use it as a scheduled confidence check that the key, base URL, network path,
and people read scope still work.
This workflow posts a weekday digest of approved absences for the next seven
days. It deliberately posts only dates and names, not absence categories.
Code
name: HollyHR Slack who's awayon: workflow_dispatch: schedule: - cron: "30 8 * * 1-5"jobs: digest: runs-on: ubuntu-latest steps: - name: Post Slack digest env: HOLLYHR_API_BASE_URL: ${{ secrets.HOLLYHR_API_BASE_URL }} HOLLYHR_API_TOKEN: ${{ secrets.HOLLYHR_API_TOKEN }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} run: | node <<'EOF' const apiBaseUrl = process.env.HOLLYHR_API_BASE_URL; const apiToken = process.env.HOLLYHR_API_TOKEN; const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL; if (!apiBaseUrl || !apiToken || !slackWebhookUrl) { throw new Error("Missing HOLLYHR_API_BASE_URL, HOLLYHR_API_TOKEN, or SLACK_WEBHOOK_URL"); } async function holly(path) { const response = await fetch(`${apiBaseUrl}${path}`, { headers: { Authorization: `Bearer ${apiToken}`, Accept: "application/json", }, }); if (!response.ok) { throw new Error(`HollyHR API request failed: ${response.status} ${await response.text()}`); } return response.json(); } async function listAll(path) { const items = []; let cursor; do { const separator = path.includes("?") ? "&" : "?"; const page = await holly( `${path}${cursor ? `${separator}cursor=${encodeURIComponent(cursor)}` : ""}`, ); items.push(...page.data); cursor = page.pagination?.next_cursor; } while (cursor); return items; } function isoDate(date) { return date.toISOString().slice(0, 10); } function addDays(date, days) { const next = new Date(date); next.setUTCDate(next.getUTCDate() + days); return next; } const today = new Date(); const from = isoDate(today); const to = isoDate(addDays(today, 6)); const [people, timeOff] = await Promise.all([ listAll("/people?limit=100"), listAll(`/time-off?limit=100&status=approved&from=${from}&to=${to}`), ]); const peopleById = new Map(people.map((person) => [person.id, person])); const lines = timeOff.map((entry) => { const person = peopleById.get(entry.person_id); const name = person?.display_name ?? "Someone"; const department = person?.department?.name ? ` (${person.department.name})` : ""; const dates = entry.start_date === entry.end_date ? entry.start_date : `${entry.start_date} to ${entry.end_date}`; return `- ${name}${department}: away, ${dates}`; }); const text = lines.length > 0 ? `Who's away from ${from} to ${to}:\n${lines.join("\n")}` : `Nobody is away from ${from} to ${to}.`; const slackResponse = await fetch(slackWebhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); if (!slackResponse.ok) { throw new Error(`Slack webhook failed: ${slackResponse.status} ${await slackResponse.text()}`); } EOF
Minimum HollyHR scopes:
Code
people:read time_off:read
Payroll readiness export artifact
The payroll readiness export is read-only but elevated. Run this only in a
private repository, keep artifact retention short, and limit access to the
people who are allowed to handle payroll handoff material.
This workflow proves that protected-resource metadata is reachable and that the
hosted MCP endpoint returns the expected 405 Method Not Allowed for
authenticated GET /api/mcp when server-to-client SSE is disabled.