← Selected work
Human-in-the-loop · Project 5

HR Onboarding Agent

Fully onboards new employees — creates accounts, assigns equipment, then pauses for manager approval via webhook before sending welcome email. Session saved to Neon DB — resumes from exact state after any restart.

Anthropic Claude · Neon DB · Bottleneck · Webhook · Idempotency GitHub ↗
New Concepts This Project
Human-in-the-loop — agent pauses mid-flow and waits for manager. Webhook resume — manager clicks email link → agent continues from saved state. Persistent state — full session saved to Neon DB, survives server restart. Idempotency — safe to retry any step, never creates duplicate accounts.

State Machine

started
  ↓ Agent begins running
creating_accounts
  ↓ GitHub + Slack + Jira created in parallel
  ↓ Equipment + Orientation scheduled in parallel
waiting_approval      ← ⏸ AGENT PAUSES HERE
  ↓ Manager clicks Approve
approved
  ↓ Welcome email + team notification
completed ✅

OR:

waiting_approval
  ↓ Manager clicks Reject
rejected ❌

System Prompt

You are an HR Onboarding Agent.

ONBOARDING STEPS (in order):
1. get_employee_details — fetch info first
2. create_github_account + create_slack_account + create_jira_account
   — run in PARALLEL
3. assign_equipment + schedule_orientation — run in PARALLEL
4. request_manager_approval — ALWAYS call this before welcome email
   Agent pauses here until manager approves
5. (After approval) send_welcome_email + notify_team — run in PARALLEL
6. complete_onboarding — final step

IMPORTANT: Run independent tools in PARALLEL (same turn)

Tool Definitions

[
  { "name": "get_employee_details",    "description": "Fetch employee info. Call first." },
  { "name": "create_github_account",   "description": "Create GitHub account" },
  { "name": "create_slack_account",    "description": "Create Slack and add to channels" },
  { "name": "create_jira_account",     "description": "Create Jira with project access" },
  { "name": "assign_equipment",        "description": "Assign laptop and equipment" },
  { "name": "schedule_orientation",    "description": "Schedule orientation session" },
  {
    "name": "request_manager_approval",
    "description": "Send approval email to manager. Agent PAUSES here."
  },
  { "name": "send_welcome_email",      "description": "Send welcome email after approval" },
  { "name": "notify_team",             "description": "Post announcement to team" },
  { "name": "complete_onboarding",     "description": "Mark complete, generate report" }
]

End-to-End Flow

👤 HR submits new employee
POST /onboard
{
  "employeeName":  "Priya Sharma",
  "employeeEmail": "[email protected]",
  "employeeRole":  "Senior Frontend Engineer",
  "employeeDept":  "Engineering",
  "managerEmail":  "[email protected]",
  "startDate":     "2026-08-15"
}

Response: { sessionId: "abc-123", poll: "/session/abc-123" }
🧠 LLM Response — Turn 1
{
  "stop_reason": "tool_use",
  "content": [{
    "type": "tool_use", "id": "tool_001",
    "name": "get_employee_details",
    "input": { "employee_id": "EMP-001" }
  }],
  "usage": { "input_tokens": 520, "output_tokens": 80 }
}
🔧 Idempotency Check
// Before every tool execution:
const cached = await isStepCompleted(sessionId, "create_github_account");
if (cached) return { ...cached, cached: true };  // skip if already done

// After execution:
await markStepCompleted(sessionId, "create_github_account", result);

// DB: completed_steps table
// session_id + step_name = UNIQUE constraint
// ON CONFLICT → DO NOTHING (safe to retry) ✅
🧠 LLM Response — Turn 2 (3 parallel tools)
{
  "stop_reason": "tool_use",
  "content": [
    { "type": "tool_use", "id": "tool_002", "name": "create_github_account",
      "input": { "employee_name": "Priya Sharma", "department": "Engineering" } },
    { "type": "tool_use", "id": "tool_003", "name": "create_slack_account",
      "input": { "employee_name": "Priya Sharma", "department": "Engineering" } },
    { "type": "tool_use", "id": "tool_004", "name": "create_jira_account",
      "input": { "employee_name": "Priya Sharma", "role": "Senior Frontend Engineer" } }
  ],
  "usage": { "input_tokens": 890, "output_tokens": 240 }
}
3 accounts created in ONE turn — parallel execution. State saved to Neon DB after every turn.
🧠 LLM Response — Turn 4 (approval request → PAUSE)
{
  "stop_reason": "tool_use",
  "content": [{
    "type":  "tool_use",
    "id":    "tool_007",
    "name":  "request_manager_approval",
    "input": {
      "employee_name": "Priya Sharma",
      "manager_email": "[email protected]",
      "summary": "GitHub ✅ Slack ✅ Jira ✅ MacBook Pro ✅ Orientation Aug 15 ✅"
    }
  }]
}
⏸ Agent PAUSES — saves full state to Neon DB
// Agent detects: tool === "request_manager_approval"
// 1. Sends approval email (mocked to console)
// 2. Saves to Neon DB:
{
  session_id:     "abc-123",
  state:          "waiting_approval",
  messages:       [...all 4 turns of conversation...],
  approval_token: "xyz-456"
}
// 3. agent.js function RETURNS — stops running

// Server can restart safely — full state is in DB ✅
// Agent can wait hours or days here ✅

// Console output (mocked email):
// APPROVE: http://localhost:5177/approve?token=xyz-456&action=approve
// REJECT:  http://localhost:5177/approve?token=xyz-456&action=reject
✅ Manager clicks Approve link
GET /approve?token=xyz-456&action=approve

Server:
  1. Load session by token from Neon DB
  2. Verify state === "waiting_approval"
  3. Update state to "approved"
  4. Call continueAfterApproval()
  5. Redirect browser to frontend
▶️ Agent RESUMES — Turn 5
// Load messages[] from Neon DB (4 turns from before pause)
// Add approval to history:
messages.push({
  role:    "user",
  content: "✅ Manager approved. Continue with welcome email + notify team."
})

// Call Claude with full history — Claude reads it and continues
// Claude has NO idea it paused. Just sees conversation and acts.

LLM Response Turn 5:
{
  "stop_reason": "tool_use",
  "content": [
    { "type": "tool_use", "name": "send_welcome_email", ... },
    { "type": "tool_use", "name": "notify_team", ... }
  ]
}
🧠 LLM Response — Turn 7 (Final)
{
  "stop_reason": "end_turn",
  "content": [{
    "type": "text",
    "text": "🎉 Priya Sharma is fully onboarded!\n\n✅ GitHub: priya.sharma\n✅ Slack: @priya.sharma (#general, #engineering)\n✅ Jira: ENG, INFRA, DOCS\n✅ MacBook Pro 16-inch M3\n✅ Orientation: Aug 15, 9:00-12:00 AM MST\n✅ Manager approved\n✅ Welcome email sent\n✅ Team notified\n\nPriya is ready for day one! 🚀"
  }],
  "usage": { "input_tokens": 2450, "output_tokens": 185 }
}
How Resume Works — The Key Insight

Claude doesn't "resume" — it's stateless. YOU reload messages[] from Neon DB and send the full history again. Claude reads the entire conversation and continues naturally. It has no idea it "paused" for any amount of time. The illusion of continuity is created by your session management, not Claude.

Token Cost (full session)

Phase
Turns
Input
Cost
Before pause
4
~4,000
$0.000018
After approval
3
~3,200
$0.000014
Total session
7
~7,200
~$0.000032