← Alert Sift
API

Run Alert Sift from your own code

The one metered task, alerts, is available over HTTP. It takes Google Workspace security alerts as the page has read them - what the admin already knows, the totals, one numbered line per case (c1, c2 ...) with its priority, kind, how many alerts, the user, the time span, the severity, how many are open, the type as reported, any takeover chain on the same user and whether the goal names it, and the cases past the list counted by kind - and returns one action per listed case (contain, investigate, watch, close) with a reason and the first Admin console step for each, a verdict (urgent or routine) and a note.

The division of labour first: the page reads the alerts, maps each to a kind, groups them into cases by kind and user, finds chains of account-takeover signals on one user within 48 hours and ranks every case P1 to P4; the run decides what to do with each listed case, naming cases only by id so the page can tie each action to real alerts. The page reads up to 20000 lines and lists up to 20 cases; the rest are counted by kind and get no action. After the reply it keeps one known action per listed case, checks each one against its own rule and the verdict against the priorities, and warns when a sentence quotes a number the alerts do not have, names a case by its id, guesses who is behind an alert, or calls anyone he or she. Nothing is changed in the Admin console - not by the page and not by the run; the plan is a list for you to act on. Your code can do the same with the engine files the page loads (see "Build the input yourself").

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"alert-sift"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, goal, totals, cases, others) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the alerts run is metered and needs a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field, the input and the rules

Every run sends task: alerts (the one task is alerts); a missing or different one is still answered as alerts. All fields are strings. What each holds, in the words of the system prompt, with the first sample's value (its first line where it has several):

FieldRequiredWhat it holds
taskyesAlways alerts. If it is missing or different, still answer this task and set "task": "alerts".
goalyesThe organisation and anything the admin knows (a test, someone travelling), in their words, or none. First sample: Quillmere Workspace, the weekly alert review.
totalsyesHow many alerts, cases, users and open alerts, the cases per priority, and the time span. First sample: 10 alerts in 8 cases across 5 users; 9 open; P1 3, P2 0, P3 2, P4 3; from 2026-10-25 13:05 to 2026-10-27 15:45.
casesyesOne line per case: c1. P1 | kind xN for user | first to last | severity | N open or all closed | reported as "..." | same user within 48 h: other kinds | named in goal. Parts that do not apply are left out. P1 means a chain (two or more of suspicious login, leaked password, admin rights granted, unusual download or export, third-party app access, mail forwarding added on one user within 48 hours), a government-backed attack, or an open compromised device; P2 an open high-severity case; P3 an open medium one; P4 an open low one or a case whose alerts are all closed. First sample: c1. P1 | leaked password for ana@quillmere.example | 2026-10-26 22:10 | high | 1 open | same user within 48 h: suspicious login, admin rights granted ...
othersyesCases past the list, counted by kind, or none. They get no action. First sample: none.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

How the page reads the alerts (AlertKit.readReport, called by AlertKit.form): up to 20000 lines. JSON (text that starts with [ or {) is read as an Alert Center list - a bare array, or an object with alerts, items or data, as gws alertcenter alerts list or the Alert Center API returns it - where each alert has a type, a createTime (or startTime), the user in data.email (or the first of data.affectedUserEmails, data.actor.email, a message recipient or data.affectedUsers), and metadata.severity and metadata.status. A table (tab or comma separated) is found by its header row: a type column (type, alert type, alert name, name, title, event, rule) and a time column (a header holding time, date, created, start, detected or when); a user column (user, email, actor, affected ...), a severity column, a status column and a source column are used when there are. Without a header, each line is split by |: the first part that reads as a time, an address as the user, high, medium or low as the severity, open or closed as the status and the first other part as the type, as in 2026-10-27 09:14 | Suspicious login | ana@example.com | medium. Times are read as printed (2026-10-27 09:14, 2026-10-27T09:14:05Z, 10/27/2026 9:14 AM, 27/10/2026 21:14); a zone suffix is ignored. A row with no type or no readable time is set aside and counted in the warning. A status such as closed, resolved, dismissed or false positive makes the alert closed; any other is open.

What the page works out, free and before any run. Each alert type maps to a kind, the first pattern that matches (AlertKit.KINDS), with a default severity when the alert has none; the word is what names the kind in the goal:

IdKindDefault severityAccount signalWord
govgovernment-backed attackhighnogovernment
devicecompromised devicehighnocompromised
leakleaked passwordhighyesleaked password
loginsuspicious loginmediumyeslogin
adminadmin rights grantedmediumyesadmin
exportunusual download or exportmediumyesdownload
oauththird-party app accessmediumyesthird-party app
forwardmail forwarding addedmediumyesforwarding
dlpdata loss rule triggeredmediumnodata loss
spamspam spikelownospam
phishphishing or malwaremediumnophishing
devactsuspicious device activitylownodevice activity
suspenduser suspendedlownosuspended
otherother alertmediumno-

Alerts of one kind on one user make a case (AlertKit.form), with the highest severity among its alerts and the first and last time. A chain: on one user, two or more cases of the account signals (leaked password, suspicious login, admin rights granted, unusual download or export, third-party app access, mail forwarding added) with alerts within 48 hours of each other; each such case lists the other kinds. A case is named in goal when the goal holds the user's address, the user's name before the @ as a word (3 letters or more), or the kind's word. The priority: P1 for a government-backed attack, an open compromised device or an open case in a chain; P4 for a case whose alerts are all closed; otherwise P2 when open and high, P3 when open and medium and P4 when open and low. Cases are sorted by priority, then severity, then the number of alerts, then the latest alert, then the user, and the first 20 get the ids c1 ... c20, each sent as one line (AlertKit.caseLine); the rest are counted by kind in others and get no action.

The model's rules

As the system prompt states them, in order:

  1. plan - one entry for each line in cases, in id order: case (its id), action, why (one sentence, at most 20 words, using only that line and goal) and step. Take the first action that applies:
    • contain - the case is P1. Act now, before looking further.
    • close - the case's alerts are all closed, or the line says named in goal.
    • investigate - the case is P2.
    • watch - anything else.
    step is one sentence, at most 20 words: the first thing the admin does in the Google Admin console for that case - a real console action that fits its kind and user. "" for close.
  2. verdict - urgent when any case is P1; routine otherwise.
  3. note - one sentence, at most 30 words: what is urgent (or that nothing is), and which case to act on first - a chain before any other P1, then the other P1 cases, then P2.

And the house rules:

The reply

One JSON object, in the shape the system prompt asks for:

{
  "task": "alerts",
  "verdict": "urgent | routine",
  "plan": [ { "case": "c1", "action": "contain | close | investigate | watch", "why": "one sentence", "step": "one sentence or empty" } ],
  "note": "one sentence"
}

The page reads the reply with ReplyKit.normalize (the verdict, the case ids and the actions matched in any case; an entry with no case id is dropped) and keeps what it trusts with ReplyKit.trusted(reply, built): an entry is set aside, with the reason, when it names a case the page did not send, a case that already has an action, or an action the page does not know; every listed case with no action is listed as missing. t.acts maps each id to its action, t.why to its reason and t.step to the first step (none for close). The kinds, the users, the times and the priorities always come from the page's own reading, never from the reply.

ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one the priorities contradict (AlertKit.expectVerdict: urgent when any case is P1); every entry set aside, and every listed case with no action; an action that is not the one the rule gives (AlertKit.expectAction(c): contain a P1; close a case whose alerts are all closed or that the goal names; investigate a P2; watch the rest); an action to take with no first step, or a close that carries one; a number in a sentence that is not in the input; a sentence that names a case id or an internal field or uses backticks; a sentence that calls anyone he, she, him or her; a guess about who is behind an alert or what was taken (such as hacker, stolen or exfiltrated) that the input does not have; and words such as "again", "already", "always", "never" or "every" that the input never used. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "Something needs action now" or "Nothing needs action now".

The exports: AlertKit.toCSV(form, t.acts) writes one row per case, the cases past the list included, in priority order - with the id, the priority, the kind, the user, the number of alerts and of open ones, the severity, the first and last time, the chain, whether the goal names it and the plan, with CRLF line ends; ReplyKit.md and ReplyKit.json write the counts and every listed case with its plan, reason and first step; and the triage board (AlertBoard.draw, a strip of alerts per day over four priority columns of case cards, each stamped with its action) saves as PNG or SVG.

Worked examples - every sample

These are the free samples on the page, one per paste shape: the form as filled in, how the page reads the alerts - the cases, the chains and the priorities - the exact input it sends, the reply that replays for free, and what the page does with it - the actions it trusts, case by case against its own rule, the verdict against the priorities, and its own check. Together they cover the verdicts urgent and routine.

A takeover in progress (gws JSON)

The form: what you already know: Quillmere Workspace, the weekly alert review.

The alerts as pasted:

{
 "alerts": [
  {
   "customerId": "C03x7k2",
   "alertId": "a-001",
   "createTime": "2026-10-26T22:10:41Z",
   "startTime": "2026-10-26T22:10:41Z",
   "type": "Leaked password",
   "source": "Google identity",
   "data": {
    "@type": "type.googleapis.com/google.apps.alertcenter.type.AccountWarning",
    "email": "ana@quillmere.example"
   },
   "metadata": {
    "severity": "HIGH",
    "status": "NOT_STARTED"
   }
  },
  {
   "customerId": "C03x7k2",
   "alertId": "a-002",
   "createTime": "2026-10-27T09:14:05Z",
   "startTime": "2026-10-27T09:14:05Z",
   "type": "Suspicious login",
   "source": "Google identity",
   "data": {
    "@type": "type.googleapis.com/google.apps.alertcenter.type.AccountWarning",
    "email": "ana@quillmere.example",
    "loginDetails": {
     "ipAddress": "203.0.113.58"
    }
   },
   "metadata": {
    "severity": "MEDIUM",
    "status": "NOT_STARTED"
   }
  },
  {
   "customerId": "C03x7k2",
   "alertId": "a-003",
   "createTime": "2026-10-27T09:41:52Z",
   "startTime": "2026-10-27T09:41:52Z",
   "type": "Suspicious login",
   "source": "Google identity",
   "data": {
    "@type": "type.googleapis.com/google.apps.alertcenter.type.AccountWarning",
    "email": "ana@quillmere.example",
    "loginDetails": {
     "ipAddress": "203.0.113.58"
    }
   },
   "metadata": {
    "severity": "MEDIUM",
    "status": "NOT_STARTED"
   }
  },
  {
   "customerId": "C03x7k2",
   "alertId": "a-004",
   "createTime": "2026-10-27T10:30:12Z",
   "startTime": "2026-10-27T10:30:12Z",
   "type": "User granted Admin privilege",
   "source": "Google Operations",
   "data": {
    "@type": "type.googleapis.com/google.apps.alertcenter.type.UserChanges",
    "email": "ana@quillmere.example"
   },
   "metadata": {
    "severity": "MEDIUM",
    "status": "NOT_STARTED"
   }
  },
  {
   "customerId": "C03x7k2",
   "alertId": "a-005",
   "createTime": "2026-10-27T11:02:00Z",
   "startTime": "2026-10-27T11:02:00Z",
   "type": "User reported phishing",
   "source": "Gmail phishing",
   "data": {
    "@type": "type.googleapis.com/google.apps.alertcenter.type.MailPhishing",
    "messages": [
     {
      "recipient": "maya@quillmere.example"
     }
    ]
   },
   "metadata": {
    "severity": "MEDIUM",
    "status": "NOT_STARTED"
   }
  },
  {
   "customerId": "C03x7k2",
   "alertId": "a-006",
   "createTime": "2026-10-27T11:20:00Z",
   "startTime": "2026-10-27T11:20:00Z",
   "type": "User reported phishing",
   "source": "Gmail phishing",
   "data": {
    "@type": "type.googleapis.com/google.apps.alertcenter.type.MailPhishing",
    "messages": [
     {
      "recipient": "maya@quillmere.example"
     }
    ]
   },
   "metadata": {
    "severity": "MEDIUM",
    "status": "NOT_STARTED"
   }
  },
  {
   "customerId": "C03x7k2",
   "alertId": "a-007",
   "createTime": "2026-10-27T11:24:00Z",
   "startTime": "2026-10-27T11:24:00Z",
   "type": "User reported phishing",
   "source": "Gmail phishing",
   "data": {
    "@type": "type.googleapis.com/google.apps.alertcenter.type.MailPhishing",
    "messages": [
     {
      "recipient": "tom@quillmere.example"
     }
    ]
   },
   "metadata": {
    "severity": "MEDIUM",
    "status": "NOT_STARTED"
   }
  },
  {
   "customerId": "C03x7k2",
   "alertId": "a-008",
   "createTime": "2026-10-25T13:05:00Z",
   "startTime": "2026-10-25T13:05:00Z",
   "type": "Suspicious login",
   "source": "Google identity",
   "data": {
    "@type": "type.googleapis.com/google.apps.alertcenter.type.AccountWarning",
    "email": "ben@quillmere.example"
   },
   "metadata": {
    "severity": "LOW",
    "status": "CLOSED"
   }
  },
  {
   "customerId": "C03x7k2",
   "alertId": "a-009",
   "createTime": "2026-10-26T08:00:00Z",
   "startTime": "2026-10-26T08:00:00Z",
   "type": "Spike in user-reported spam",
   "source": "Gmail phishing",
   "data": {
    "@type": "type.googleapis.com/google.apps.alertcenter.type.MailPhishing"
   },
   "metadata": {
    "severity": "LOW",
    "status": "NOT_STARTED"
   }
  },
  {
   "customerId": "C03x7k2",
   "alertId": "a-010",
   "createTime": "2026-10-27T15:45:00Z",
   "startTime": "2026-10-27T15:45:00Z",
   "type": "Suspicious device activity",
   "source": "Device management",
   "data": {
    "@type": "type.googleapis.com/google.apps.alertcenter.type.DeviceCompromised",
    "email": "leo@quillmere.example"
   },
   "metadata": {
    "severity": "LOW",
    "status": "NOT_STARTED"
   }
  }
 ]
}

The page reads the paste (181 lines) as Alert Center JSON (an alerts list): 10 alerts (9 open) in 8 cases across 5 users. By priority: P1 3, P2 0, P3 2, P4 3.

The cases (AlertKit.form), most urgent first, each listed one sent as one line (AlertKit.caseLine):

IdPriorityCaseWhenSeverityOpenChain or goal
c1P1leaked password for ana@quillmere.example2026-10-26 22:10high1 openchain with suspicious login, admin rights granted
c2P1suspicious login x2 for ana@quillmere.example2026-10-27 09:14 to 09:41medium2 openchain with leaked password, admin rights granted
c3P1admin rights granted for ana@quillmere.example2026-10-27 10:30medium1 openchain with leaked password, suspicious login
c4P3phishing or malware x2 for maya@quillmere.example2026-10-27 11:02 to 11:20medium2 open-
c5P3phishing or malware for tom@quillmere.example2026-10-27 11:24medium1 open-
c6P4suspicious device activity for leo@quillmere.example2026-10-27 15:45low1 open-
c7P4spam spike for no user named2026-10-26 08:00low1 open-
c8P4suspicious login for ben@quillmere.example2026-10-25 13:05lowall closed-

Input:

{
 "task": "alerts",
 "goal": "Quillmere Workspace, the weekly alert review",
 "totals": "10 alerts in 8 cases across 5 users; 9 open; P1 3, P2 0, P3 2, P4 3; from 2026-10-25 13:05 to 2026-10-27 15:45",
 "cases": "c1. P1 | leaked password for ana@quillmere.example | 2026-10-26 22:10 | high | 1 open | same user within 48 h: suspicious login, admin rights granted\nc2. P1 | suspicious login x2 for ana@quillmere.example | 2026-10-27 09:14 to 09:41 | medium | 2 open | same user within 48 h: leaked password, admin rights granted\nc3. P1 | admin rights granted for ana@quillmere.example | 2026-10-27 10:30 | medium | 1 open | reported as \"User granted Admin privilege\" | same user within 48 h: leaked password, suspicious login\nc4. P3 | phishing or malware x2 for maya@quillmere.example | 2026-10-27 11:02 to 11:20 | medium | 2 open | reported as \"User reported phishing\"\nc5. P3 | phishing or malware for tom@quillmere.example | 2026-10-27 11:24 | medium | 1 open | reported as \"User reported phishing\"\nc6. P4 | suspicious device activity for leo@quillmere.example | 2026-10-27 15:45 | low | 1 open\nc7. P4 | spam spike for no user named | 2026-10-26 08:00 | low | 1 open | reported as \"Spike in user-reported spam\"\nc8. P4 | suspicious login for ben@quillmere.example | 2026-10-25 13:05 | low | all closed",
 "others": "none"
}

Reply:

{
 "task": "alerts",
 "verdict": "urgent",
 "plan": [
  {
   "case": "c1",
   "action": "contain",
   "why": "A leaked password for ana@quillmere.example appeared with a suspicious login and admin rights granted within 48 hours.",
   "step": "In the Admin console, reset ana@quillmere.example's password and sign them out of all sessions."
  },
  {
   "case": "c2",
   "action": "contain",
   "why": "Suspicious logins for ana@quillmere.example happened alongside a leaked password and admin rights granted within 48 hours.",
   "step": "In the Admin console, review the sign-in activity for ana@quillmere.example and force a sign-out from all sessions."
  },
  {
   "case": "c3",
   "action": "contain",
   "why": "Admin rights were granted to ana@quillmere.example following a leaked password and suspicious logins within 48 hours.",
   "step": "In the Admin console, remove the admin privileges granted to ana@quillmere.example."
  },
  {
   "case": "c4",
   "action": "watch",
   "why": "The 2 phishing or malware alerts for maya@quillmere.example remain open.",
   "step": "In the Admin console, open the Security Investigation tool to review the phishing messages reported by maya@quillmere.example."
  },
  {
   "case": "c5",
   "action": "watch",
   "why": "The phishing or malware alert for tom@quillmere.example is still open and was reported by the user.",
   "step": "In the Admin console, use the Security Investigation tool to review the phishing message reported by tom@quillmere.example."
  },
  {
   "case": "c6",
   "action": "watch",
   "why": "The suspicious device activity alert for leo@quillmere.example is low severity and still open.",
   "step": "In the Admin console, check the device details for leo@quillmere.example under device management."
  },
  {
   "case": "c7",
   "action": "watch",
   "why": "The spam spike alert has no user named and is still open at low severity.",
   "step": "In the Admin console, open the Alert Center to review the spam spike details."
  },
  {
   "case": "c8",
   "action": "close",
   "why": "The suspicious login alert for ben@quillmere.example is closed and needs no further action.",
   "step": ""
  }
 ],
 "note": "The leaked password, suspicious logins and admin rights granted for ana@quillmere.example form an urgent chain; act on the leaked password first."
}

The actions the page trusts (ReplyKit.trusted), case by case, next to the one its own rule gives (AlertKit.expectAction):

CasePriorityActionThe ruleWhyFirst step
leaked password for ana@quillmere.example (c1)P1contain (contain now)contain (contain now)A leaked password for ana@quillmere.example appeared with a suspicious login and admin rights granted within 48 hours.In the Admin console, reset ana@quillmere.example's password and sign them out of all sessions.
suspicious login x2 for ana@quillmere.example (c2)P1contain (contain now)contain (contain now)Suspicious logins for ana@quillmere.example happened alongside a leaked password and admin rights granted within 48 hours.In the Admin console, review the sign-in activity for ana@quillmere.example and force a sign-out from all sessions.
admin rights granted for ana@quillmere.example (c3)P1contain (contain now)contain (contain now)Admin rights were granted to ana@quillmere.example following a leaked password and suspicious logins within 48 hours.In the Admin console, remove the admin privileges granted to ana@quillmere.example.
phishing or malware x2 for maya@quillmere.example (c4)P3watchwatchThe 2 phishing or malware alerts for maya@quillmere.example remain open.In the Admin console, open the Security Investigation tool to review the phishing messages reported by maya@quillmere.example.
phishing or malware for tom@quillmere.example (c5)P3watchwatchThe phishing or malware alert for tom@quillmere.example is still open and was reported by the user.In the Admin console, use the Security Investigation tool to review the phishing message reported by tom@quillmere.example.
suspicious device activity for leo@quillmere.example (c6)P4watchwatchThe suspicious device activity alert for leo@quillmere.example is low severity and still open.In the Admin console, check the device details for leo@quillmere.example under device management.
spam spike for no user named (c7)P4watchwatchThe spam spike alert has no user named and is still open at low severity.In the Admin console, open the Alert Center to review the spam spike details.
suspicious login for ben@quillmere.example (c8)P4closecloseThe suspicious login alert for ben@quillmere.example is closed and needs no further action.-

The verdict: urgent - "Something needs action now"; it agrees with the priorities.

The note: "The leaked password, suspicious logins and admin rights granted for ana@quillmere.example form an urgent chain; act on the leaked password first."

The page's check (ReplyKit.check): kept.

No notes.

After a phishing test (console CSV)

The form: what you already know: We ran a phishing test on Oct 20; Raj is travelling in Brazil this week.

The alerts as pasted:

Alert ID,Alert type,Source,Severity,Status,Created,Affected user
8f21,User reported phishing,Gmail,Medium,Not started,10/20/2026 9:05 AM,raj@harborlane.example
8f22,User reported phishing,Gmail,Medium,Not started,10/20/2026 9:07 AM,lena@harborlane.example
8f23,User reported phishing,Gmail,Medium,Not started,10/20/2026 9:11 AM,omar@harborlane.example
8f24,User reported phishing,Gmail,Medium,Not started,10/20/2026 9:31 AM,priya@harborlane.example
8f30,Suspicious login,Google identity,Medium,Not started,10/22/2026 3:40 AM,raj@harborlane.example
8f31,Suspicious login,Google identity,Medium,Not started,10/22/2026 4:02 AM,raj@harborlane.example
8f40,Device compromised,Google endpoint management,High,Not started,10/23/2026 11:20 AM,kim@harborlane.example
8f51,Data loss prevention rule violation,Drive,Medium,In progress,10/24/2026 2:15 PM,dee@harborlane.example
8f60,Leaked password,Google identity,High,Closed,10/18/2026 6:30 PM,omar@harborlane.example

The page reads the paste (10 lines) as a comma-separated Alert Center export with a header row: 9 alerts (8 open) in 8 cases across 6 users. By priority: P1 1, P2 0, P3 6, P4 1.

The cases (AlertKit.form), most urgent first, each listed one sent as one line (AlertKit.caseLine):

IdPriorityCaseWhenSeverityOpenChain or goal
c1P1compromised device for kim@harborlane.example2026-10-23 11:20high1 open-
c2P3suspicious login x2 for raj@harborlane.example2026-10-22 03:40 to 04:02medium2 opennamed in goal
c3P3data loss rule triggered for dee@harborlane.example2026-10-24 14:15medium1 open-
c4P3phishing or malware for priya@harborlane.example2026-10-20 09:31medium1 opennamed in goal
c5P3phishing or malware for omar@harborlane.example2026-10-20 09:11medium1 opennamed in goal
c6P3phishing or malware for lena@harborlane.example2026-10-20 09:07medium1 opennamed in goal
c7P3phishing or malware for raj@harborlane.example2026-10-20 09:05medium1 opennamed in goal
c8P4leaked password for omar@harborlane.example2026-10-18 18:30highall closed-

Input:

{
 "task": "alerts",
 "goal": "We ran a phishing test on Oct 20; Raj is travelling in Brazil this week",
 "totals": "9 alerts in 8 cases across 6 users; 8 open; P1 1, P2 0, P3 6, P4 1; from 2026-10-18 18:30 to 2026-10-24 14:15",
 "cases": "c1. P1 | compromised device for kim@harborlane.example | 2026-10-23 11:20 | high | 1 open | reported as \"Device compromised\"\nc2. P3 | suspicious login x2 for raj@harborlane.example | 2026-10-22 03:40 to 04:02 | medium | 2 open | named in goal\nc3. P3 | data loss rule triggered for dee@harborlane.example | 2026-10-24 14:15 | medium | 1 open | reported as \"Data loss prevention rule violation\"\nc4. P3 | phishing or malware for priya@harborlane.example | 2026-10-20 09:31 | medium | 1 open | reported as \"User reported phishing\" | named in goal\nc5. P3 | phishing or malware for omar@harborlane.example | 2026-10-20 09:11 | medium | 1 open | reported as \"User reported phishing\" | named in goal\nc6. P3 | phishing or malware for lena@harborlane.example | 2026-10-20 09:07 | medium | 1 open | reported as \"User reported phishing\" | named in goal\nc7. P3 | phishing or malware for raj@harborlane.example | 2026-10-20 09:05 | medium | 1 open | reported as \"User reported phishing\" | named in goal\nc8. P4 | leaked password for omar@harborlane.example | 2026-10-18 18:30 | high | all closed",
 "others": "none"
}

Reply:

{
 "task": "alerts",
 "verdict": "urgent",
 "plan": [
  {
   "case": "c1",
   "action": "contain",
   "why": "A compromised device for kim@harborlane.example is high severity and still open.",
   "step": "In the Admin console, open device management, find the device, and block or wipe it."
  },
  {
   "case": "c2",
   "action": "close",
   "why": "raj@harborlane.example is travelling in Brazil this week, which accounts for the suspicious logins.",
   "step": ""
  },
  {
   "case": "c3",
   "action": "watch",
   "why": "The data loss rule triggered for dee@harborlane.example is still open at medium severity.",
   "step": "In the Admin console, review the triggered data loss prevention rule and its details for dee@harborlane.example."
  },
  {
   "case": "c4",
   "action": "close",
   "why": "The phishing test run on Oct 20 explains the phishing or malware report for priya@harborlane.example.",
   "step": ""
  },
  {
   "case": "c5",
   "action": "close",
   "why": "The phishing test run on Oct 20 explains the phishing or malware report for omar@harborlane.example.",
   "step": ""
  },
  {
   "case": "c6",
   "action": "close",
   "why": "The phishing test run on Oct 20 explains the phishing or malware report for lena@harborlane.example.",
   "step": ""
  },
  {
   "case": "c7",
   "action": "close",
   "why": "The phishing test run on Oct 20 explains the phishing or malware report for raj@harborlane.example.",
   "step": ""
  },
  {
   "case": "c8",
   "action": "close",
   "why": "The leaked password alert for omar@harborlane.example is closed and needs no further action.",
   "step": ""
  }
 ],
 "note": "The compromised device for kim@harborlane.example is urgent and should be addressed first, ahead of the other alerts."
}

The actions the page trusts (ReplyKit.trusted), case by case, next to the one its own rule gives (AlertKit.expectAction):

CasePriorityActionThe ruleWhyFirst step
compromised device for kim@harborlane.example (c1)P1contain (contain now)contain (contain now)A compromised device for kim@harborlane.example is high severity and still open.In the Admin console, open device management, find the device, and block or wipe it.
suspicious login x2 for raj@harborlane.example (c2)P3closecloseraj@harborlane.example is travelling in Brazil this week, which accounts for the suspicious logins.-
data loss rule triggered for dee@harborlane.example (c3)P3watchwatchThe data loss rule triggered for dee@harborlane.example is still open at medium severity.In the Admin console, review the triggered data loss prevention rule and its details for dee@harborlane.example.
phishing or malware for priya@harborlane.example (c4)P3closecloseThe phishing test run on Oct 20 explains the phishing or malware report for priya@harborlane.example.-
phishing or malware for omar@harborlane.example (c5)P3closecloseThe phishing test run on Oct 20 explains the phishing or malware report for omar@harborlane.example.-
phishing or malware for lena@harborlane.example (c6)P3closecloseThe phishing test run on Oct 20 explains the phishing or malware report for lena@harborlane.example.-
phishing or malware for raj@harborlane.example (c7)P3closecloseThe phishing test run on Oct 20 explains the phishing or malware report for raj@harborlane.example.-
leaked password for omar@harborlane.example (c8)P4closecloseThe leaked password alert for omar@harborlane.example is closed and needs no further action.-

The verdict: urgent - "Something needs action now"; it agrees with the priorities.

The note: "The compromised device for kim@harborlane.example is urgent and should be addressed first, ahead of the other alerts."

The page's check (ReplyKit.check): kept.

No notes.

A quiet week (plain lines)

The form: what you already know: Small nonprofit, one part-time admin; the weekly check.

The alerts as pasted:

2026-10-25 08:12 | Suspicious login | jo@riverbend.example | medium
2026-10-25 08:40 | Suspicious login | jo@riverbend.example | medium
2026-10-26 14:02 | User reported phishing | dee@riverbend.example | medium | closed
2026-10-26 16:30 | Spike in user-reported spam | low
2026-10-27 10:15 | Leaked password | sam@riverbend.example | high
2026-10-27 11:00 | Suspicious device activity | kim@riverbend.example | low
2026-10-28 09:00 | Third-party app access granted | jo@riverbend.example | medium

The page reads the paste (7 lines) as plain lines split by |: 7 alerts (6 open) in 6 cases across 4 users. By priority: P1 0, P2 1, P3 2, P4 3.

The cases (AlertKit.form), most urgent first, each listed one sent as one line (AlertKit.caseLine):

IdPriorityCaseWhenSeverityOpenChain or goal
c1P2leaked password for sam@riverbend.example2026-10-27 10:15high1 open-
c2P3suspicious login x2 for jo@riverbend.example2026-10-25 08:12 to 08:40medium2 open-
c3P3third-party app access for jo@riverbend.example2026-10-28 09:00medium1 open-
c4P4phishing or malware for dee@riverbend.example2026-10-26 14:02mediumall closed-
c5P4suspicious device activity for kim@riverbend.example2026-10-27 11:00low1 open-
c6P4spam spike for no user named2026-10-26 16:30low1 open-

Input:

{
 "task": "alerts",
 "goal": "Small nonprofit, one part-time admin; the weekly check",
 "totals": "7 alerts in 6 cases across 4 users; 6 open; P1 0, P2 1, P3 2, P4 3; from 2026-10-25 08:12 to 2026-10-28 09:00",
 "cases": "c1. P2 | leaked password for sam@riverbend.example | 2026-10-27 10:15 | high | 1 open\nc2. P3 | suspicious login x2 for jo@riverbend.example | 2026-10-25 08:12 to 08:40 | medium | 2 open\nc3. P3 | third-party app access for jo@riverbend.example | 2026-10-28 09:00 | medium | 1 open | reported as \"Third-party app access granted\"\nc4. P4 | phishing or malware for dee@riverbend.example | 2026-10-26 14:02 | medium | all closed | reported as \"User reported phishing\"\nc5. P4 | suspicious device activity for kim@riverbend.example | 2026-10-27 11:00 | low | 1 open\nc6. P4 | spam spike for no user named | 2026-10-26 16:30 | low | 1 open | reported as \"Spike in user-reported spam\"",
 "others": "none"
}

Reply:

{
 "task": "alerts",
 "verdict": "routine",
 "plan": [
  {
   "case": "c1",
   "action": "investigate",
   "why": "The leaked password for sam@riverbend.example is high severity and still open, requiring investigation.",
   "step": "In the Admin console, reset the password for sam@riverbend.example and review recent sign-in activity."
  },
  {
   "case": "c2",
   "action": "watch",
   "why": "The suspicious logins for jo@riverbend.example are medium severity and remain open.",
   "step": "In the Admin console, review the sign-in activity for jo@riverbend.example under security investigation."
  },
  {
   "case": "c3",
   "action": "watch",
   "why": "Third-party app access was granted for jo@riverbend.example and the alert is still open.",
   "step": "In the Admin console, review and revoke the third-party app access for jo@riverbend.example if needed."
  },
  {
   "case": "c4",
   "action": "close",
   "why": "The phishing or malware alert for dee@riverbend.example is closed and needs no further action.",
   "step": ""
  },
  {
   "case": "c5",
   "action": "watch",
   "why": "The suspicious device activity for kim@riverbend.example is low severity and still open.",
   "step": "In the Admin console, check the device details for kim@riverbend.example under device management."
  },
  {
   "case": "c6",
   "action": "watch",
   "why": "The spam spike alert has no user named and remains open at low severity.",
   "step": "In the Admin console, open the Alert Center to review the spam spike details."
  }
 ],
 "note": "Nothing here is urgent; address the leaked password for sam@riverbend.example first since it is high severity and open."
}

The actions the page trusts (ReplyKit.trusted), case by case, next to the one its own rule gives (AlertKit.expectAction):

CasePriorityActionThe ruleWhyFirst step
leaked password for sam@riverbend.example (c1)P2investigateinvestigateThe leaked password for sam@riverbend.example is high severity and still open, requiring investigation.In the Admin console, reset the password for sam@riverbend.example and review recent sign-in activity.
suspicious login x2 for jo@riverbend.example (c2)P3watchwatchThe suspicious logins for jo@riverbend.example are medium severity and remain open.In the Admin console, review the sign-in activity for jo@riverbend.example under security investigation.
third-party app access for jo@riverbend.example (c3)P3watchwatchThird-party app access was granted for jo@riverbend.example and the alert is still open.In the Admin console, review and revoke the third-party app access for jo@riverbend.example if needed.
phishing or malware for dee@riverbend.example (c4)P4closecloseThe phishing or malware alert for dee@riverbend.example is closed and needs no further action.-
suspicious device activity for kim@riverbend.example (c5)P4watchwatchThe suspicious device activity for kim@riverbend.example is low severity and still open.In the Admin console, check the device details for kim@riverbend.example under device management.
spam spike for no user named (c6)P4watchwatchThe spam spike alert has no user named and remains open at low severity.In the Admin console, open the Alert Center to review the spam spike details.

The verdict: routine - "Nothing needs action now"; it agrees with the priorities.

The note: "Nothing here is urgent; address the leaked password for sam@riverbend.example first since it is high severity and open."

The page's check (ReplyKit.check): kept.

No notes.

The CSV for the first sample

What AlertKit.toCSV(form, t.acts) writes for "A takeover in progress (gws JSON)" above: one row per case in priority order, with the trusted plan (lines end in CRLF). A case past the list has no id and no plan.

case,priority,kind,user,alerts,open,severity,first,last,chain,named,plan
c1,P1,leaked password,ana@quillmere.example,1,1,high,2026-10-26 22:10,2026-10-26 22:10,suspicious login; admin rights granted,,contain
c2,P1,suspicious login,ana@quillmere.example,2,2,medium,2026-10-27 09:14,2026-10-27 09:41,leaked password; admin rights granted,,contain
c3,P1,admin rights granted,ana@quillmere.example,1,1,medium,2026-10-27 10:30,2026-10-27 10:30,leaked password; suspicious login,,contain
c4,P3,phishing or malware,maya@quillmere.example,2,2,medium,2026-10-27 11:02,2026-10-27 11:20,,,watch
c5,P3,phishing or malware,tom@quillmere.example,1,1,medium,2026-10-27 11:24,2026-10-27 11:24,,,watch
c6,P4,suspicious device activity,leo@quillmere.example,1,1,low,2026-10-27 15:45,2026-10-27 15:45,,,watch
c7,P4,spam spike,,1,1,low,2026-10-26 08:00,2026-10-26 08:00,,,watch
c8,P4,suspicious login,ben@quillmere.example,1,0,low,2026-10-25 13:05,2026-10-25 13:05,,,close

Build the input yourself

Load the engine in node (global.window = global; require("./alertkit.js"); require("./replykit.js"); require("./board.js")), then:

const K = AlertKit, R = ReplyKit;
const form = K.form({ goal: "Riverbend staff, the weekly check",
  report: "2026-10-27 09:14 | Suspicious login | jo@riverbend.example | medium\n2026-10-27 10:02 | Leaked password | jo@riverbend.example | high\n2026-10-26 16:30 | User reported phishing | dee@riverbend.example | medium | closed" });
// form.alerts: every alert [{type, kind, label, at, user, sev, open, source}]
// form.cases: the cases most urgent first [{id, p, kind, label, user, count, openCount, sev, first, last, chain, named}]
// form.items: the first 20 (c1 ...); form.rest: the ones past the list
K.expectVerdict(form); K.expectAction(form.items[0])   // the page's own verdict and rule, no run needed
const built = K.buildInput(form);     // built.ok is false, with built.note, until an alert with a type and a time is read
// built.items: the listed cases; built.warn: what the page warns about
const wire = K.toWire(built.input);   // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// const t = R.trusted(reply, built)       // {acts: {c1: action}, why, step, dropped, missing}
// R.check(reply, built)                    // {status: kept or flagged, notes}
// K.toCSV(form, t.acts)                    // every case with its priority, chain and plan
// AlertBoard.draw(form, t.acts, {dark: false})   // the triage board as an SVG string

Form keys: report (the pasted alerts, any shape above) and goal (what you already know - a test, someone travelling - or empty). Nothing here touches the Admin console: the engine only reads the text you give it.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://alert-sift.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"alert-sift"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "alerts", "goal": "Riverbend staff, the weekly check", "totals": "3 alerts in 3 cases across 2 users; 2 open; P1 2, P2 0, P3 0, P4 1; from 2026-10-26 16:30 to 2026-10-27 10:02", "cases": "c1. P1 | leaked password for jo@riverbend.example | 2026-10-27 10:02 | high | 1 open | same user within 48 h: suspicious login\nc2. P1 | suspicious login for jo@riverbend.example | 2026-10-27 09:14 | medium | 1 open | same user within 48 h: leaked password\nc3. P4 | phishing or malware for dee@riverbend.example | 2026-10-26 16:30 | medium | all closed | reported as \"User reported phishing\"", "others": "none"}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses alert-sift:<task>:<inputHash>:a<attempt> (the one task is alerts), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="alert-sift:alerts:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '.verdict, .note, (.plan[] | "\(.case) \(.action)")' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: alert-sift:alerts:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json