Churn Risk Alert
ivy.agent.churn-risk-alert · v1.0.0
ivyx
Takes an account's usage figures, has Jev rate the churn risk, and alerts Slack and the account manager when the account is at risk; otherwise logs the check through your API.
Steps
In the order the agent's file lists them, each with where its inputs come from and the values the file fixes. A branch or a loop is a step too.
- 01Ivy NodeRate the churn riskivy.node.jev-score
Asks TypeSafe Jev where a text or JSON state sits on an ordered scale of 2 to 10 described levels and returns a score from 0 to the top level with the nearest level, its description and the confidence.
- takes
statefrom the agent's input,payload- set
instructionsRate this account's churn risk on these levels: healthy, watch, at risk. Falling logins, open tickets and a request t...levels["healthy","watch","at risk"]- secret
jev_api_keythe stored secret jev-api-key- returns
level
- 02Ivy NodeSum up the figuresivy.node.template-render
Fills {{ name }} placeholders in a text template from a mapping, and reports which names were missing.
- takes
valuesfrom the agent's input,payload- set
templateChurn risk on {{ account }} ({{ plan }} plan): logins fell from {{ logins_prev_30d }} to {{ logins_last_30d }} in 30 ...on_missingempty- returns
summary
- 03BranchOnly when at risk
Lets what follows run only when is.at_risk of step 1 is true.
- 04Ivy NodeAlert the channelivy.node.http-request
Sends one HTTP request to a URL and returns the status, headers and body, parsed as JSON when the response is JSON.
Runs only on the then branch of step 3.
- takes
json.textfrom step 2,text- set
methodPOST- secret
urlthe stored secret slack-webhook-url- returns
alerted
- 05Ivy NodeMail the account managerivy.node.email-send
Sends a plain-text or HTML email through an SMTP server in one step and returns the recipients it went to.
Runs only on the then branch of step 3.
- takes
bodyfrom step 2,textfrom_addrfrom the agent's input,usernameusernamefrom the agent's inputtofrom the agent's inputsmtp_hostfrom the agent's inputsmtp_portfrom the agent's inputuse_tlsfrom the agent's input,smtp_tls- set
smtp_hostsmtp.gmail.comsmtp_port587use_tlstruesubjectChurn risk: an account needs a call- secret
passwordthe stored secret mail-password- returns
sent
- 06Ivy NodeLog the checkivy.node.http-request
Sends one HTTP request to a URL and returns the status, headers and body, parsed as JSON when the response is JSON.
Runs only on the else branch of step 3.
- takes
urlfrom the agent's input,log_urljson.accountfrom the agent's input,payload.accountjson.levelfrom step 1,level_textjson.summaryfrom step 2,text- set
methodPOST- secret
tokenthe stored secret saas-api-key- returns
logged
Nodes it brings
Adding this agent in IVYX Studio adds these nodes with it. A node your workspace already has is kept as it is, even at another version.
What it touches
Collected from what each of its nodes declares, plus the model call when a step is a model turn. A declaration is the author's statement, and it is what policy rules select on.
What it needs
- A stored secret named
jev-api-key. The agent's file carries the name, never the value. - A stored secret named
mail-password. The agent's file carries the name, never the value. - A stored secret named
saas-api-key. The agent's file carries the name, never the value. - A stored secret named
slack-webhook-url. The agent's file carries the name, never the value.
Inputs
| Field | Type | Description |
|---|---|---|
| payloadrequired | object | The event your service posted: account, plan, logins_last_30d, logins_prev_30d, open_tickets and notes. |
| log_urlrequired | string | Your API's log endpoint; a POST records a check that raised no alert. The stand-in's address is used in the cases. |
| usernamerequired | string | The sending address, which is the login name; its password is the stored secret mail-password. |
| torequired | string | The account manager's address. |
| smtp_host | string | The SMTP server; smtp.gmail.com when left out. |
| smtp_port | integer | The SMTP port; 587 when left out. |
| smtp_tls | boolean | Upgrade SMTP with STARTTLS; on when left out. |
Outputs
| Field | Type | Description |
|---|---|---|
| level | string | healthy, watch or at risk. |
| alerted | boolean | True once Slack accepted the alert; absent when none was sent. |
| sent | boolean | True once the server accepted the mail to the account manager; absent when none was sent. |
| logged | boolean | True once your API recorded a check that raised no alert; absent when it alerted. |
| summary | string | The one-paragraph summary of the figures. |
Tests
2 of 2 test cases passed on Oct 7, 2026, in the publisher's own environment, before this version was published. The registry keeps that record; it does not run the cases again.
Requires: python:3.9, the stored secret jev-api-key, a Slack incoming webhook URL stored as the secret slack-webhook-url, your own API's key stored as the secret saas-api-key, an SMTP server (Gmail unless smtp_host says otherwise) with the sending account's password stored as the secret mail-password, for the cases, GreenMail on 127.0.0.1:3025 (mkdata-mail.py), for the cases, the stand-in API on 127.0.0.1:18910 (api-standin.py), which the secrets crm-1, gh-1, saas-1 and the stand-in's /slack/hook address satisfy
- collapse
Logins down from 41 to 2 with three open tickets and an export request is at risk: Slack and the manager hear.
- given
smtp_host127.0.0.1smtp_port3025smtp_tlsfalsepayload{"account":"Acme Analytics","plan":"team","logins_last_30d":2,"logins_prev_30d":41,"open_tickets":3,"notes":"Asked ho...log_urlhttp://127.0.0.1:18910/logusernameagent@ivyx.testtoagent@ivyx.test- expects
levelequals at riskalertedequals truesentequals truesummarymatches 41 to 2
- growing
Logins up from 30 to 38 with no tickets is healthy or watch: only the log hears.
- given
smtp_host127.0.0.1smtp_port3025smtp_tlsfalsepayload{"account":"Globex","plan":"business","logins_last_30d":38,"logins_prev_30d":30,"open_tickets":0,"notes":"Added two s...log_urlhttp://127.0.0.1:18910/logusernameagent@ivyx.testtoagent@ivyx.test- expects
levelmatches ^(healthy|watch)$loggedequals true