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 |
|---|---|
|
Who owns the task |
|
Which lead it is about |
|
Optional links to what triggered it |
|
|
|
|
|
HubSpot Task object id |
|
Nooks task / sequence enrolment ids |
|
|
|
Deadline |
|
|
|
When the last escalation fired |
|
Set when the task completes |
|
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) orhubspot_contact_id— amodel_validatorrejects a payload with neither. -
hubspot_owner_idis mandatory. It assigns the HubSpot Task and selects thesales_repsrow for the local mirror. -
subjectis required and becomeshs_task_subject. -
task_typemapscall/email/todo→ HubSpotCALL/EMAIL/TODO, andcall/email→ the localFollowUpTaskTypeenum. -
due_atbecomeshs_timestamp;prioritydefaults toMEDIUM;bodybecomeshs_task_body. -
hubspot_company_idandcampaign_target_idare 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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
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 stampcompletedAt. -
Anything else → stamp
lastHubspotSyncAtonly, 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 — |
1 |
1 |
Direct message to the rep — |
2 |
2 |
No new notification. The task is marked |
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.
|
|
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.