Follow-up tasks and escalation

A follow-up task is a commitment that a specific rep will do a specific thing by a specific time. Morpheus creates them, mirrors them into HubSpot, watches whether they get done, and escalates when they do not.

Table: follow_up_tasks. Implementation: lambdas/morpheus_hubspot/events/tasks/crm_task_create.py, events/follow_up_task_sync.py, events/scheduled/follow_up_task_hubspot_poll.py, lambdas/phone_agent/events/task_deadline_reminder.py.

The record

Column Meaning

salesRepId

Who owns the task

contactId

Which lead it is about

campaignTargetId, contactSequenceId

Optional links to what triggered it

type

call, email, or sequence_enrollment

status

pending, in_progress, completed, skipped, failed

hubspotTaskId

HubSpot Task object id

nooksTaskId, nooksSequenceStateId

Nooks task / sequence enrolment ids

hubspotSyncStatus, nooksSyncStatus

pending, synced, failed per platform

dueAt

Deadline

escalationStage

0 → 1 → 2 → 3; 3 means abandoned

lastEscalatedAt

When the last escalation fired

completedAt

Set when the task completes

lastHubspotSyncAt, lastNooksSyncAt

Poll deprioritisation stamps

Both external ids are nullable — a task may be pushed to only one platform depending on which integrations are active.

Creation — Crm.TaskCreate

HubSpot first, local mirror second, because the local row needs the HubSpot id.

sequenceDiagram
  participant Q as CrmSyncQueue
  participant H as CrmTaskCreateHandler
  participant HS as HubSpot
  participant DB as PostgreSQL

  Q->>H: Crm.TaskCreate
  H->>DB: resolve sales_reps row by hubspot_owner_id
  H->>DB: resolve contact (contact_id or hubspot_contact_id)
  H->>HS: create_crm_task() → HubSpot task id
  H->>DB: insert follow_up_tasks row with hubspotTaskId

Requirements enforced by the event model and the handler:

  • At least one of contact_id (internal UUID) or hubspot_contact_id — a model_validator rejects a payload with neither.

  • hubspot_owner_id is mandatory. It assigns the HubSpot Task and selects the sales_reps row for the local mirror.

  • subject is required and becomes hs_task_subject.

  • task_type maps call/email/todo → HubSpot CALL/EMAIL/TODO, and call/email → the local FollowUpTaskType enum.

  • due_at becomes hs_timestamp; priority defaults to MEDIUM; body becomes hs_task_body.

  • hubspot_company_id and campaign_target_id are optional associations.

There is a second creation path: Crm.NewActivity creates a follow-up Task automatically when it logs an incoming email (hs_email_direction = INCOMING_EMAIL) and the event carries a hubspot_owner_id. See activities.

Status sync — FollowUpTask.Sync

Published when a task’s status changes. The handler reads HubSpot first, which matters: a rep who completed the task in HubSpot should not have that overwritten by a stale local status.

  • If HubSpot already reports COMPLETED → mark the local row completed, and make no HubSpot write.

  • Otherwise → PATCH the HubSpot task with the mapped status and update the local row.

Status mapping:

FollowUpTaskStatus HubSpot hs_task_status

pending

NOT_STARTED

in_progress

IN_PROGRESS

completed

COMPLETED

skipped

DEFERRED

failed

DEFERRED

HubSpot has no "failed" concept, so both terminal non-success states land on DEFERRED.

Completion polling — Hubspot.FollowUpTaskPoll

Every 30 minutes, morpheus-hubspot pulls pending tasks that have a hubspotTaskId (list_pending_with_hubspot_task(), capped by HUBSPOT_TASK_POLL_BATCH_SIZE, default 100) and reads each one from HubSpot:

  • hs_task_status = COMPLETED → mark the local row completed and stamp completedAt.

  • Anything else → stamp lastHubspotSyncAt only, which deprioritises the task in the next window so the batch keeps advancing through unchecked tasks.

  • NotFoundException — the task was deleted in HubSpot — is handled rather than treated as an error.

This is how work done in HubSpot becomes visible to Morpheus. Without it, a rep completing a task in the CRM would still be escalated.

The escalation ladder — Phone.TaskDeadlineReminder

Runs in phone-agent at 08:00, 14:00, and 20:00 UTC and does two things in one invocation. Both phases return early with a warning when SLACK_BOT_TOKEN or SALES_SLACK_CHANNEL_ID is unset.

Phase 1 — pre-deadline reminders

list_pending_due_soon(window_hours=24) selects pending tasks due within 24 hours. They are grouped by rep (group_by_rep), rendered into Slack Block Kit (build_reminder_blocks), and posted as a single channel message: "N follow-up tasks due in the next 24 hours".

An Activity row of type reminder is then inserted per task — subject "Slack deadline reminder sent", notes slack_reminder_sent — as a best-effort audit trail. A failure there is logged and ignored.

Phase 2 — escalation

list_stale_for_escalation() selects tasks past their deadline by a day or more. Each is escalated exactly one stage per run, with a 3-second sleep between tasks to stay inside Slack rate limits. A failure on one task is logged and the loop continues.

Stage Action New stage

0

Public channel message — :warning: Overdue Task — mentioning the rep (Slack lookup by email, falling back to their name), with a 100-character note preview and a HubSpot contact deep link when available.

1

1

Direct message to the rep — :rotating_light: Urgent — Overdue Task — after resolving their Slack user id from their email. Plus an Email.SendInternal message published to EmailAgentQueue carrying the task id, rep name and email, contact id, due date, and notes.

2

2

No new notification. The task is marked status = failed.

3

At stage 3, if the task has a hubspotTaskId, a FollowUpTaskSyncEvent with status failed is published to CrmSyncQueue so HubSpot reflects the abandonment (mapping to DEFERRED).

Every escalation writes escalationStage and lastEscalatedAt in one update, so the ladder cannot advance twice in one run.

Email.SendInternal is published to EmailAgentQueue but is not registered in email_agent/registry.py. The dispatcher logs "No handler registered for SQS event_type" and deletes the message. Stage-1 escalation therefore currently notifies by Slack DM only; the email path is scaffolding awaiting a handler.

If EMAIL_AGENT_QUEUE_URL is unset, the publish is skipped with a warning and the stage still advances.

Slack integration

lambdas/phone_agent/services/slack_client.py wraps the Slack Web API:

  • send_message(channel, text, blocks) — channel post

  • send_dm(user_id, text, blocks) — direct message

  • resolve_user_id(email) — email → Slack user id, needed for DMs

  • mention(email, name) — a real <@Uxxxx> mention when the lookup succeeds, the plain name otherwise

Block builders live in utils/task_blocks.py. HubSpot deep links are constructed as https://app.hubspot.com/contacts/9298797/record/0-1/<contactId>;.

Ownership

Tasks inherit their rep from whoever created them. When no rep is specified, the round-robin selector assigns one — see Ownership and round-robin. The require_nooks flag on that selector exists for sequence_enrollment tasks, which need a nooksUserId.