backend/apps/core-api/src/modules/task-automations/. The matching side lives in Tasks engine. The rows a flow writes are served by Inbox.
Mounting and auth
taskAutomationsRouter(ctx) is mounted in modules/index.ts at /v1/web/task-automations behind serviceAuth (HMAC service auth), webUserContext(ctx.prisma) and webLimiter.
The routes file adds no requireRole or coach access guard. Any authenticated web lane caller of the studio can read and change the automations. Every query is scoped by req.auth.studioId. A missing studio on req.auth returns UNAUTHORIZED (401).
Responses use the standard envelope. A Zod failure returns VALIDATION (422) with the issues in details. See API overview.
Storage
A studio with no row for a trigger gets the catalogue default from
AUTOMATION_DEFAULTS.
Triggers
TASK_TRIGGER_TYPES lists 19 values. AUTOMATION_DEFAULTS holds 18 of them. FORM_FILLED_OVERDUE is in the type list but not in the catalogue, so GET never returns it and the generator never evaluates it.
INACTIVE and MILESTONE default notify to true. Every other trigger defaults it to false. The rule each trigger matches on is documented in Tasks engine.
Endpoints
GET /v1/web/task-automations
Returns one entry per catalogue trigger, merged with the studio’s stored rows.
Auth: web lane, any role.
No parameters.
Response: data.items is an array in catalogue order. Each item:
string
The trigger type.
string
The built-in task title from
CONTENT in tasks/task-content.ts, used when a task has no name.boolean
Stored value or the catalogue default.
number
Stored value or the catalogue default.
string
minutes, hours, days, percent or score.string
OWNER, RESPONSIBLE, SPECIFIC or UNASSIGNED. Defaults to RESPONSIBLE.string or null
Legacy single assignee.
string[]
Falls back to a one item list built from
assigneeCoachId.boolean
Whether the legacy task also sends the trainee a message.
string or null
Stored as given.
string or null
The trainee message text.
string[]
Product ids the trigger is limited to.
object
Days per weekly workout band, keys
3 to 7, normalised by normalizeFrequencyThresholds. Missing bands fall back to the stored thresholdValue, or to the defaults 5, 4, 3, 3, 2 when there is no row. Values are clamped to 1 to 60.object[]
The stored
TaskAutomationTask rows sorted by ordinal, returned as full rows (so they also carry id, automationId, createdAt and updatedAt). When there are none, one synthesised task at ordinal 0 built from the parent row, with formTypes set to all four form types and formIds empty.object
The stored flow when there is one. Otherwise a flow lifted from the stored task blocks with
legacyTasksToFlow, or the default flow with a single task node when the trigger has no blocks.A synthesised flow gets fresh node ids on every read, because
legacyTasksToFlow and defaultFlow generate ids at call time. Ids become stable once a flow is saved.UNAUTHORIZED (401).
PATCH /v1/web/task-automations
Upserts the automation for one trigger type and returns the full list again.
Auth: web lane, any role.
string
required
One of
TASK_TRIGGER_TYPES.boolean
Turns the trigger on or off.
integer
Non negative, coerced from a string.
string
minutes, hours, days, percent or score.string
OWNER, RESPONSIBLE, SPECIFIC or UNASSIGNED.string or null
Legacy single assignee. Ignored when
assigneeCoachIds is sent, in which case the first id is stored.string[]
Coach ids.
boolean
Send the trainee a message when a legacy task is created.
string or null
Stored on the row.
string or null
Message text. The token
{שם פרטי} is replaced with the trainee’s first name at send time.string[]
Product ids.
object
Record with keys
3, 4, 5, 6, 7 and integer values from 1 to 60.object[]
1 to 5 legacy task blocks. Replaces the whole list. The fields of one block are in the table below.
object
The node tree. See Flow schema. It is parsed with
flowBody and then checked by validateFlow.tasks (automationTaskBody):
What the service does:
- When
flowis sent,flowTriggerColumnsprojects the flow trigger onto the legacy columns, overriding whatever the body sent for them. Apercenttrigger storesflow.trigger.pct, ascoretrigger storesflow.trigger.score, and every other trigger storesflow.trigger.timeandflow.trigger.unit.planIdsbecomesflow.trigger.programs. The matchers read these columns. - The repository upserts the row in a transaction. On create, the catalogue threshold is the base. When
tasksis sent, task 0 is mirrored onto the parent row (threshold, assignee,notify,notifyBody), the existing blocks are deleted, and the new ones are inserted with their array index asordinal. - When
tasksis sent, open tasks are brought in line per ordinal. If a block’s name changed,renameOpenTaskssets the new title on that ordinal’sOPENandSNOOZEDautomatic rows. If a block’s assignee changed,resyncOpenTasksreassigns them:UNASSIGNEDclears owners,SPECIFICsets the given ids,OWNERsets every activeHEAD_COACHplus the given ids, andRESPONSIBLEsets each trainee’s trainers plus the given ids. - When
tasksis not sent butassigneeModeorassigneeCoachIdsis, ordinal 0 rows are reassigned from the saved row.
GET, read after the write.
Errors: VALIDATION (422) for schema failures, including these validateFlow messages:
flow has N nodes; the limit is 60conditions nest deeper than 5 levelsnode ids must be uniquea create-task action needs at least one staff membera condition needs exactly one fallback branchthe fallback branch must be lastwebhook url must be https and public
UNAUTHORIZED (401) without a studio.
GET /v1/web/task-automations/wa-templates
Lists the studio’s own approved WhatsApp templates from its connected SmartSend account. The builder’s WhatsApp node uses it for the studio template picker.
Auth: web lane, any role.
No parameters. The handler reads the SmartSend key from Studio.settings.smartsend.apiKey and calls ctx.smartsend.listTemplates.
Response: when no key is stored, connected is false and templates is empty. This is a normal 200 answer. Otherwise each template row has id (the template name that the send call expects) and value (a display label).
SERVICE_UNAVAILABLE (503) smartsend is not answering when the SmartSend call fails. UNAUTHORIZED (401) missing web user context.
GET /v1/web/task-automations/wa-templates/params
Returns the parameters of one studio template, in send order.
Auth: web lane, any role.
string
required
Template name, trimmed.
string
Optional language code, trimmed.
name, label (falls back to name) and type when SmartSend returns one.
Errors: BAD_REQUEST (400) templateName is required. BAD_REQUEST (400) smartsend is not connected when the studio has no key. SERVICE_UNAVAILABLE (503) smartsend is not answering. UNAUTHORIZED (401).
Flow schema
flow.schema.ts owns the wire shape. FLOW_SCHEMA_VERSION is 1.
Trigger
flowFormScope(trigger) returns all four form types and no ids when formTypes is not an array. feedbackFormKinds(trigger) narrows that to CHECK_IN and INTAKE for the feedback trigger.
Node kinds
Every node has anid (trimmed string, 1 to 40 characters) and a kind.
A webhook
url may be empty (the node saves but does nothing). A non empty URL must be https and must not point at a private host: localhost, 127., 0., 10., 192.168., 169.254., 172.16 to 172.31, an IPv6 literal, or a .local or .internal name.
assign maps to the task assignee mode through FLOW_ASSIGN_TO_MODE: none to UNASSIGNED, coach to RESPONSIBLE, specific to SPECIFIC, manager to OWNER, and all to SPECIFIC with no ids.
Branches and rules
A branch hasid, label (up to 60), fallback, a rule and its own steps. A rule has field, op (gt, lt, eq), value (up to 200), fieldName (up to 120, used by formField) and an optional filter record (used by trainee).
For every field except
trainee, an empty value is always true, and a non empty one is false when the run has no client. A trainee rule that does not parse, or that the filter engine turns into no conditions, is true. Otherwise it is false without a client.
Structural limits
validateFlow enforces what Zod cannot express per node:
- At most
MAX_FLOW_NODES= 60 nodes, counting nodes inside branches. - Conditions nest at most
MAX_CONDITION_DEPTH= 5 levels. - At most
MAX_BRANCHES_PER_CONDITION= 6 rule branches plus the fallback. - Node ids and branch ids are unique across the whole flow.
- A
tasknode withassignspecificneeds at least one member. - Each
pathsnode has exactly one fallback branch, and it is the last branch.
Legacy conversion
legacyTasksToFlow lifts stored task blocks into a flow: blocks are sorted by delay, the first block’s delay becomes the trigger timing, the gap to each later block becomes a wait node, each block becomes a task node, and a block with notify on and a message also adds a push node. SPECIFIC with no ids becomes all. Percent and score triggers put their number on the trigger and add no waits. defaultFlow is a single unassigned task node. resolveStoredFlow returns the stored flow when it parses, else the lifted or default one.
Flow runtime
flow-runtime.ts executes flows. One AutomationFlowRun row exists per (studioId, type, entityKey), created once. An entity that ever ran a trigger’s flow never runs it again.
How a run starts
startFlowRuns(ctx, args, deps) creates one run per seed. A unique violation (P2002) counts as skipped. The run’s context stores triggeredAt, a snapshot of the flow, the seed metadata, and the client name when given. With startDelayMs above 0 the run is created WAITING with resumeAt set and a delayed job is queued. Otherwise it is created RUNNING and advanced inline, in the calling request or job.
The four form starters return true when the flow owned the event, including the cases where the flow exists but the form was out of scope or no rating was low. The caller then skips the legacy task writer.
How a run advances
advanceRun(ctx, runId, deps) walks the run’s position, a stack of frames. A frame is a loc (the chain of branch ids from the root) and an index (the next node in that steps list).
- Entry guard. Only a
RUNNINGrun, or aWAITINGrun whoseresumeAthas passed, proceeds. A duplicate job returns at once. If the studio hasdeletedAtset, the run is markedFAILEDwith errorstudio deleted. - Current flow. The flow is re-read from the
TaskAutomationrow on every advance, so an edit applies to the remaining steps. When the row has no parseable flow, the snapshot in the run context is used, then the caller’s fallback. With none, the run is closed asDONEwith a note. Disabling the trigger does not stop runs that already started. - Deleted branch. When a frame’s branch id no longer exists, the run closes as
DONEwith the noteflow structure changed; remaining steps skipped. - Wait. The position is saved at the next node, the run becomes
WAITINGwithresumeAt, and a delayed job is queued onflow-run. A failed enqueue is logged and left to the sweeper. - Paths. Every non fallback branch whose rule is true runs, in builder order. The fallback runs only when none matched, and only when it has steps.
- Effects.
executeNodeinserts theFlowNodeExecutionrow before running the effect. A unique violation means the node already ran, and it is skipped. This is what makes retries and resumes safe. - Failure. A node that throws marks the run
FAILEDwith the first 500 characters of the message and keeps the position at the failed node. - Guard. More than
MAX_STEPS_PER_ADVANCE= 500 loop iterations marks the runFAILEDwithflow walk exceeded the step limit.
Node effects
The webhook node enqueues a job with
directUrl and event automation_flow. The delivery worker posts it without a signature. The payload:
flowNode is the node label, or its id when the label is empty. Delivery, retries and headers are described in Automation hooks. WhatsApp sending is described in WhatsApp.
Queues and workers
workers/flow-run-worker.ts, started from server.ts:
The Postgres
resumeAt column is the durable record of a wait. The delayed BullMQ job is a faster path. Whichever arrives first resumes the run and the other stops at the entry guard.
There is no endpoint in this module that lists or retries flow runs.