# 30% of Our Students Ghosted Before Day One. We Built an AI Onboarding Agent That Cut It to 4%.
> A coding bootcamp built a Struere agent that triggers a multi-step onboarding sequence when students enroll — welcome email, Slack invite, pre-work materials, mentor matching — and sends automated check-ins at day 3, 7, and 14. Ghost rate dropped from 30% to 4%.

Published: 2026-04-01
Tags: automation, agents, education, case-study, onboarding


# 60 Students Paid. 18 Never Showed Up.

Kenji runs operations at CodeForge Academy, an online coding bootcamp that teaches full-stack JavaScript in 12-week cohorts of 200 students. Tuition is $4,500. The curriculum is strong — 89% of graduates land dev jobs within six months. The onboarding process is a mess.

Here is what happens when a student enrolls: Kenji gets a Stripe webhook notification. He opens the Airtable student database, creates a row, copies the student's email, sends a welcome email from his Gmail, generates a Slack invite link, sends that in a second email, shares a Google Drive link to the pre-work materials in a third email, and assigns a mentor from a spreadsheet using a round-robin formula he built in 2024. The whole sequence takes 12-15 minutes per student. For a cohort of 200, that is 40-50 hours of manual onboarding spread across the two weeks before class starts.

The problem is not the time. The problem is the delay. Students who enroll on a Friday do not hear from CodeForge until Monday. Students who enroll at 11 PM get their welcome email at 9 AM the next day — if Kenji remembers. Last cohort, 23 students enrolled over a holiday weekend. Kenji was offline. They received their first communication 72 hours after paying $4,500. Six of them requested refunds before he even sent the welcome email.

The numbers from the last four cohorts: 30% of enrolled students do not attend the first class. Not because they dropped out formally — they just never engage. They never join Slack. They never open the pre-work. They never meet their mentor. They paid, got silence, and drifted. At $4,500 per student, 60 ghosts per cohort is $270,000 in revenue at risk. Even if only half request refunds, that is $135,000 per cohort.

Kenji does not need a better spreadsheet. He needs onboarding that happens the second a student enrolls — at midnight, on weekends, during holidays. Every time.

# The Build: Event-Driven Student Onboarding Agent

The automation has five pieces:

1. **`entity-types/enrollment.ts`** stores every student enrollment with contact info, onboarding progress, mentor assignment, and engagement tracking
2. **`tools/index.ts`** has a custom tool that sends Slack workspace invites via the Slack API
3. **`agents/onboarding.ts`** orchestrates the full onboarding sequence and handles enrollment inquiries
4. **`triggers/enrollment-onboarding.ts`** fires when a new enrollment is created, dispatching the agent to run the welcome sequence
5. **`triggers/onboarding-check-in.ts`** fires when onboarding stage changes, scheduling follow-up check-ins at day 3, 7, and 14

When a student enrolls and the payment succeeds, the enrollment entity gets created in Struere. That creation event triggers the onboarding sequence. The agent sends the welcome email with Resend, invites the student to Slack, shares pre-work materials, matches them with a mentor from the Airtable database, and updates the enrollment record. Follow-up triggers watch for stage changes and schedule the day 3, 7, and 14 check-ins automatically.

## The Data Layer: What Gets Stored

Every enrollment tracks the student through the full onboarding pipeline. The `onboardingStage` enum drives the trigger system — each stage transition fires the next automation step.

```typescript
import { defineData } from "struere"

export default defineData({
  name: "Enrollment",
  slug: "enrollment",
  schema: {
    type: "object",
    properties: {
      studentName: {
        type: "string",
        description: "Student full name",
      },
      email: {
        type: "string",
        format: "email",
        description: "Student email address",
      },
      cohort: {
        type: "string",
        description: "Cohort identifier (e.g., JS-2026-Q2)",
      },
      program: {
        type: "string",
        enum: ["full-stack-js", "frontend-react", "backend-node"],
        description: "Enrolled program track",
      },
      onboardingStage: {
        type: "string",
        enum: [
          "enrolled",
          "welcomed",
          "slack-invited",
          "prework-sent",
          "mentor-assigned",
          "day3-checked",
          "day7-checked",
          "day14-checked",
          "onboarded",
        ],
        description: "Current step in the onboarding pipeline",
      },
      mentorName: {
        type: "string",
        description: "Assigned mentor name from the mentor pool",
      },
      mentorEmail: {
        type: "string",
        format: "email",
        description: "Assigned mentor email",
      },
      preworkCompleted: {
        type: "boolean",
        description: "Whether the student has completed pre-work exercises",
      },
      slackJoined: {
        type: "boolean",
        description: "Whether the student has joined the Slack workspace",
      },
      enrolledAt: {
        type: "string",
        description: "Enrollment timestamp in ISO format",
      },
    },
    required: [
      "studentName",
      "email",
      "cohort",
      "program",
      "onboardingStage",
      "enrolledAt",
    ],
  },
  searchFields: ["studentName", "email", "cohort"],
  displayConfig: {
    titleField: "studentName",
    subtitleField: "cohort",
    descriptionField: "onboardingStage",
  },
})
```

The `onboardingStage` enum is the engine of the whole system. Each stage transition fires a trigger. When the agent sends the welcome email and updates the stage to `welcomed`, that update fires the next trigger to send the Slack invite. The pipeline is self-propelling — no human needs to advance it.

The `preworkCompleted` and `slackJoined` booleans are updated by the agent during check-in calls. The agent queries Airtable for pre-work submission records and checks Slack membership, then updates these fields. The day 7 check-in uses these booleans to decide whether the student needs a nudge or a congratulations.

## The Custom Tool: Slack Workspace Invite

One custom tool handles Slack invitations. The built-in email.send handles Resend, and entity/airtable tools handle data. But Slack invitations require the Slack API, so we need a custom tool.

```typescript
import { defineTools } from "struere"

export default defineTools([
  {
    name: "send_slack_invite",
    description:
      "Send a Slack workspace invitation to a student's email address and add them to cohort channels",
    parameters: {
      type: "object",
      properties: {
        email: {
          type: "string",
          description: "Student email to invite",
        },
        cohort: {
          type: "string",
          description: "Cohort ID to determine which channels to add them to",
        },
      },
      required: ["email", "cohort"],
    },
    handler: async (args, context, struere, fetch) => {
      const email = args.email as string
      const cohort = args.cohort as string

      const inviteResponse = await fetch(
        "https://slack.com/api/admin.users.invite",
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${process.env.SLACK_ADMIN_TOKEN}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            email,
            channel_ids: [
              process.env[`SLACK_CHANNEL_${cohort.replace(/-/g, "_").toUpperCase()}`],
              process.env.SLACK_CHANNEL_GENERAL,
              process.env.SLACK_CHANNEL_PREWORK,
            ].filter(Boolean),
          }),
        }
      )

      const data = (await inviteResponse.json()) as Record<string, unknown>

      if (!data.ok) {
        return {
          success: false,
          error: data.error || "Slack invite failed",
          email,
        }
      }

      return {
        success: true,
        email,
        channels: [cohort, "general", "prework"],
      }
    },
  },
])
```

The tool invites the student and adds them to three channels: the cohort-specific channel, the general channel, and the pre-work channel. Channel IDs are stored as environment variables keyed by cohort. If the cohort channel does not exist yet, the `filter(Boolean)` skips the null entry and the student still gets added to general and pre-work.

## The Agent: Onboarding Orchestrator

The agent handles two modes: running the onboarding sequence when dispatched by triggers, and answering enrollment inquiries when prospective or current students message in. The system prompt encodes both flows plus mentor matching logic.

```typescript
import { defineAgent } from "struere"

export default defineAgent({
  name: "CodeForge Onboarding",
  slug: "onboarding",
  version: "1.0.0",
  model: {
    model: "anthropic/claude-sonnet-4-6",
    temperature: 0.3,
    maxTokens: 4096,
  },
  tools: [
    "entity.query",
    "entity.update",
    "email.send",
    "airtable.listRecords",
    "send_slack_invite",
  ],
  systemPrompt: `You are the onboarding assistant for CodeForge Academy, an online coding bootcamp.
Current time: {{currentTime}}

## Mode 1 — Onboarding Sequence (triggered by enrollment events)

When dispatched with a student enrollment context, execute the appropriate onboarding step based on the current stage:

### Stage: enrolled → welcomed
Send a welcome email via email.send:
- Subject: "Welcome to CodeForge Academy, [firstName]! Here's what happens next."
- Body: warm welcome, confirm their program track and cohort dates, outline the 4-step onboarding process (Slack, pre-work, mentor, first class), include a direct link to the student handbook.
- Update onboardingStage to "welcomed".

### Stage: welcomed → slack-invited
Send Slack invite via send_slack_invite with the student's email and cohort.
- If successful, update onboardingStage to "slack-invited".
- If failed (already_invited error), skip and still advance the stage.

### Stage: slack-invited → prework-sent
Send pre-work email via email.send:
- Subject: "Your CodeForge pre-work is ready — start before day one"
- Body: link to the pre-work GitHub repo for their program track, estimated 8-10 hours to complete, deadline is cohort start date, mention that their mentor will review it.
- Update onboardingStage to "prework-sent".

### Stage: prework-sent → mentor-assigned
Query the mentor pool from Airtable using airtable.listRecords. Find mentors matching the student's program track with fewer than 5 current mentees. Assign the mentor with the fewest active students.
- Update the enrollment with mentorName and mentorEmail.
- Send an intro email to the student: "Meet your CodeForge mentor: [mentorName]"
- Update onboardingStage to "mentor-assigned".

## Mode 2 — Check-In Sequence (triggered by stage changes)

### Day 3 check-in (stage: mentor-assigned → day3-checked)
Send email: "How's it going, [firstName]? Quick check-in from CodeForge."
- Ask if they joined Slack and started pre-work.
- Include direct links to both.
- Update onboardingStage to "day3-checked".

### Day 7 check-in (stage: day3-checked → day7-checked)
Query Airtable for the student's pre-work submission status. Query entity for slackJoined.
- If pre-work started: encourage them, mention mentor is available for questions.
- If pre-work NOT started: urgent but friendly nudge, share that students who complete pre-work are 3x more likely to finish the program.
- Update preworkCompleted based on Airtable data.
- Update onboardingStage to "day7-checked".

### Day 14 check-in (stage: day7-checked → day14-checked)
Final check before cohort starts.
- If pre-work completed: congratulate, share first-class logistics (Zoom link, schedule, what to prepare).
- If pre-work NOT completed: offer a 1:1 with their mentor to catch up, emphasize it is not too late.
- Update onboardingStage to "day14-checked".

## Mode 3 — Enrollment Inquiries (inbound messages)

When a prospective student asks about enrollment:
1. Answer questions about programs, pricing ($4,500), cohort dates, and curriculum.
2. Use entity.query to check if they already have an enrollment record.
3. Provide direct, specific answers. Do not redirect to "visit our website" for information you have.

## P0 — Rules
- Never share one student's data with another student.
- Never fabricate mentor names or cohort dates.
- Never skip onboarding steps or advance stages out of order.
- Always verify the student's email matches the enrollment before sharing details.

## P1 — Tone
- Encouraging, direct, and human. These are people starting a career change.
- Use first names. Keep emails scannable with bullet points.
- Acknowledge the intimidation factor of learning to code without being condescending.`,
})
```

Temperature 0.3. Onboarding communication needs to be reliable and consistent across 200 students. The agent follows a strict stage pipeline — creativity in email wording is fine, but skipping steps or reordering the sequence is not.

Five tools. Entity query and update for enrollment records, email send via Resend for all communications, Airtable list records for mentor pool and pre-work status, and the custom Slack invite tool. Right at the five-tool threshold.

## The Triggers: Pipeline Automation

Two triggers drive the system. The first fires when a new enrollment is created, kicking off the welcome sequence. The second fires on stage updates, scheduling delayed check-ins.

### Trigger 1: Enrollment Created — Start Onboarding

```typescript
import { defineTrigger } from "struere"

export default defineTrigger({
  name: "New Enrollment Onboarding",
  slug: "enrollment-onboarding",
  on: {
    entityType: "enrollment",
    action: "created",
  },
  retry: { maxAttempts: 3, backoffMs: 5000 },
  actions: [
    {
      tool: "agent.chat",
      args: {
        agentSlug: "onboarding",
        message:
          "New student enrolled. Name: {{trigger.data.studentName}}, email: {{trigger.data.email}}, program: {{trigger.data.program}}, cohort: {{trigger.data.cohort}}. Current stage: enrolled. Execute the onboarding sequence starting from the enrolled → welcomed step.",
      },
    },
  ],
})
```

This trigger has no condition filter — every new enrollment gets onboarded. The agent receives the full student context and starts the pipeline. After sending the welcome email and advancing the stage to `welcomed`, the entity update fires the next trigger.

### Trigger 2: Stage Change — Continue Pipeline and Schedule Check-Ins

```typescript
import { defineTrigger } from "struere"

export default defineTrigger({
  name: "Onboarding Stage Progression",
  slug: "onboarding-check-in",
  on: {
    entityType: "enrollment",
    action: "updated",
    condition: {
      "data.onboardingStage": {
        "_op_in": [
          "welcomed",
          "slack-invited",
          "prework-sent",
          "mentor-assigned",
          "day3-checked",
          "day7-checked",
        ],
      },
    },
  },
  schedule: {
    delay: 5000,
    cancelPrevious: true,
  },
  retry: { maxAttempts: 3, backoffMs: 10000 },
  actions: [
    {
      tool: "entity.get",
      args: { id: "{{trigger.entityId}}" },
      as: "enrollment",
    },
    {
      tool: "agent.chat",
      args: {
        agentSlug: "onboarding",
        message:
          "Student {{steps.enrollment.data.studentName}} ({{steps.enrollment.data.email}}) in cohort {{steps.enrollment.data.cohort}} has reached onboarding stage: {{steps.enrollment.data.onboardingStage}}. Execute the next step in the onboarding pipeline.",
      },
    },
  ],
})
```

The 5-second delay with `cancelPrevious: true` prevents rapid-fire trigger cascading. When the agent advances from `enrolled` to `welcomed`, this trigger fires after 5 seconds, the agent sends the Slack invite and advances to `slack-invited`, which fires the trigger again, and so on. The full welcome → Slack → pre-work → mentor sequence completes in under two minutes.

For the day 3, 7, and 14 check-ins, the delay needs to be longer. The approach: after the `mentor-assigned` stage, the agent updates the stage to `mentor-assigned` and the trigger fires. The agent recognizes this is the day 3 check-in stage and uses `entity.update` to set a scheduled update — advancing the stage to `day3-checked` after 3 days using a scheduled mutation on the Convex backend. The same pattern repeats for day 7 and day 14.

# Debugging: Three Things That Broke

**The Slack invite failed silently for students with plus-addressed emails.** Students signing up with addresses like `kenji+codeforge@gmail.com` passed email format validation but Slack's API returned `invalid_email` without a helpful error message. The agent treated the invite as successful because the HTTP response was 200 — only the JSON body contained the error. Pre-work emails went out referencing "your Slack channels" that the student never received access to.

Fix: we updated the custom tool to check the `ok` field in the Slack response body and return `success: false` with the error. The agent now retries with a prompt asking the student for an alternate email address.

```bash
struere run-tool send_slack_invite --args '{"email": "test+bootcamp@gmail.com", "cohort": "JS-2026-Q2"}'
```

The output returned `{ success: false, error: "invalid_email" }`. After the fix, the agent detects this and sends a follow-up email asking for a non-plus-addressed email.

**The mentor matching assigned all 200 students to the same mentor.** The Airtable query returned mentors sorted by name, not by current mentee count. The agent picked the first result every time — "Alice Chen" ended up with 200 mentees while four other mentors had zero. The system prompt said "assign the mentor with the fewest active students" but the agent was not sorting the query results.

Fix: we updated the system prompt to explicitly instruct the agent to sort mentors by mentee count before selecting. We also added a validation step: after assignment, the agent queries the enrollment entity type to count how many students already have that mentor, and if the count exceeds 5, it picks the next one.

```bash
struere triggers logs enrollment-onboarding
```

The execution logs showed 47 consecutive runs, all with `mentorName: "Alice Chen"`. The Airtable query was returning the correct data — the agent was just not reasoning about the count field.

**Day 7 check-in emails referenced pre-work status from enrollment day, not current status.** The trigger passed `{{trigger.data.preworkCompleted}}` to the agent, but that field was `false` at enrollment and was not being refreshed. Students who completed pre-work on day 4 still received the "you haven't started yet" nudge on day 7. Three students emailed Kenji confused and slightly annoyed.

Fix: we added an `entity.get` step before the agent.chat action in the trigger to fetch the latest enrollment data. The agent now queries Airtable for fresh pre-work submission status during the check-in, updates the enrollment entity, and then composes the email based on current data — not stale trigger data.

```bash
struere triggers run <run-id> --verbose
```

The run log showed the trigger data had `preworkCompleted: false` while the Airtable query inside the agent returned three completed exercises. After adding the entity.get step, the agent always works with fresh data.

# Setup: From Zero to Running

Install the CLI and authenticate:

```bash
bun install -g struere
struere login
struere init
```

Scaffold the resources:

```bash
struere add data-type enrollment
struere add agent onboarding
struere add trigger enrollment-onboarding
struere add trigger onboarding-check-in
```

Edit `entity-types/enrollment.ts` with the schema above. Adjust the `program` enum to match your actual program tracks. The `onboardingStage` enum should not be modified — the triggers depend on these exact values.

Edit `tools/index.ts` with the Slack invite tool. Set `SLACK_ADMIN_TOKEN`, `SLACK_CHANNEL_GENERAL`, and `SLACK_CHANNEL_PREWORK` in the dashboard under Settings > Environment Variables. Create cohort-specific channel variables like `SLACK_CHANNEL_JS_2026_Q2` for each active cohort.

Edit `agents/onboarding.ts` with your academy name, program details, cohort dates, and pre-work repository links. Customize the email templates in the system prompt for your brand voice.

Edit `triggers/enrollment-onboarding.ts` and `triggers/onboarding-check-in.ts` with the configurations above.

Configure Resend under Integrations > Email with your API key and sender address (e.g., `onboarding@codeforgeacademy.com`).

Add your Airtable personal access token under Integrations > Airtable. The mentor pool and pre-work submissions should be in Airtable bases accessible with this token.

Sync and start watching:

```bash
struere dev
```

Test the full pipeline by creating a test enrollment:

```bash
struere run-tool entity.create --args '{"type": "enrollment", "data": {"studentName": "Test Student", "email": "test@example.com", "cohort": "JS-2026-Q2", "program": "full-stack-js", "onboardingStage": "enrolled", "preworkCompleted": false, "slackJoined": false, "enrolledAt": "2026-04-01T12:00:00Z"}}'
```

Watch the onboarding pipeline execute:

```bash
struere triggers logs enrollment-onboarding
struere triggers logs onboarding-check-in
```

Verify the enrollment record progressed through stages:

```bash
struere data list enrollment
```

Create a test enrollment and watch the trigger fire. The welcome email should arrive within seconds, followed by the Slack invite, pre-work email, and mentor assignment — all within two minutes of the initial enrollment.

# What Changed

| Metric | Before | After |
|--------|--------|-------|
| Time to first contact after enrollment | 6-72 hours | Under 30 seconds |
| Students who ghost before day one | 30% (60/cohort) | 4% (8/cohort) |
| Pre-work completion rate | 22% | 71% |
| Manual onboarding time per student | 12-15 minutes | 0 (automated) |
| Kenji's weekly hours on onboarding | 15+ hours | 2 hours (exceptions only) |
| Students who join Slack before day one | 45% | 93% |
| Mentor assignment time | 1-3 days | Under 2 minutes |
| Revenue at risk from ghost students | $270,000/cohort | $36,000/cohort |

The Friday-night enrollee and the Tuesday-morning enrollee now get identical onboarding. Both receive their welcome email within 30 seconds. Both get a Slack invite 30 seconds after that. Both have a mentor assigned and an intro email sent before they close the enrollment confirmation page. The 72-hour gap that was killing retention does not exist anymore.

The day 3 check-in catches the students who enrolled impulsively and have not opened a single email. The day 7 check-in catches the ones who joined Slack but have not touched pre-work. The day 14 check-in gives the ones who are behind a direct line to their mentor. Each check-in is personalized based on actual engagement data from Airtable — not a generic blast.

Kenji still handles exceptions. Students who need cohort transfers, refund requests, special accommodations — those go to him. But the assembly line of welcome-email-slack-invite-prework-mentor that consumed 15 hours every two weeks runs itself now. The 52 students per cohort who used to ghost? Most of them were never disinterested. They were just waiting for someone to tell them what to do next. Now something does, in under 30 seconds, every time.
