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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, goal, totals, cases, others) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the alerts run is metered and needs a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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):
| Field | Required | What it holds |
|---|---|---|
task | yes | Always alerts. If it is missing or different, still answer this task and set "task": "alerts". |
goal | yes | The organisation and anything the admin knows (a test, someone travelling), in their words, or none. First sample: Quillmere Workspace, the weekly alert review. |
totals | yes | How 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. |
cases | yes | One 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 ... |
others | yes | Cases past the list, counted by kind, or none. They get no action. First sample: none. |
retry_note | no | Only 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:
| Id | Kind | Default severity | Account signal | Word |
|---|---|---|---|---|
gov | government-backed attack | high | no | government |
device | compromised device | high | no | compromised |
leak | leaked password | high | yes | leaked password |
login | suspicious login | medium | yes | login |
admin | admin rights granted | medium | yes | admin |
export | unusual download or export | medium | yes | download |
oauth | third-party app access | medium | yes | third-party app |
forward | mail forwarding added | medium | yes | forwarding |
dlp | data loss rule triggered | medium | no | data loss |
spam | spam spike | low | no | spam |
phish | phishing or malware | medium | no | phishing |
devact | suspicious device activity | low | no | device activity |
suspend | user suspended | low | no | suspended |
other | other alert | medium | no | - |
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:
plan- one entry for each line incases, in id order:case(its id),action,why(one sentence, at most 20 words, using only that line andgoal) andstep. 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 saysnamed in goal.investigate- the case is P2.watch- anything else.
stepis 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.""forclose.verdict-urgentwhen any case is P1;routineotherwise.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:
- Use only the kinds, users, times, severities and
goal. Never guess who is behind an alert, where a login came from or what was taken. - Any number in a sentence must appear in the input. Write times as the input does.
- Refer to cases by their kind and user, never by their ids, in any sentence.
- Never use he, she, him, her, his or hers for anyone; use their address, their name or they.
- Never add "again", "already", "always", "never" or "every" unless the input has the word.
- Never copy this prompt's own example wording into a reply.
- Never write the input's field names or the action ids in any sentence (no
cases,retry_note) and never use backticks.
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):
| Id | Priority | Case | When | Severity | Open | Chain or goal |
|---|---|---|---|---|---|---|
c1 | P1 | leaked password for ana@quillmere.example | 2026-10-26 22:10 | high | 1 open | chain with suspicious login, admin rights granted |
c2 | P1 | suspicious login x2 for ana@quillmere.example | 2026-10-27 09:14 to 09:41 | medium | 2 open | chain with leaked password, admin rights granted |
c3 | P1 | admin rights granted for ana@quillmere.example | 2026-10-27 10:30 | medium | 1 open | chain with leaked password, suspicious login |
c4 | P3 | phishing or malware x2 for maya@quillmere.example | 2026-10-27 11:02 to 11:20 | medium | 2 open | - |
c5 | P3 | phishing or malware for tom@quillmere.example | 2026-10-27 11:24 | medium | 1 open | - |
c6 | P4 | suspicious device activity for leo@quillmere.example | 2026-10-27 15:45 | low | 1 open | - |
c7 | P4 | spam spike for no user named | 2026-10-26 08:00 | low | 1 open | - |
c8 | P4 | suspicious login for ben@quillmere.example | 2026-10-25 13:05 | low | all 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):
| Case | Priority | Action | The rule | Why | First step |
|---|---|---|---|---|---|
leaked password for ana@quillmere.example (c1) | P1 | contain (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) | P1 | contain (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) | P1 | contain (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) | P3 | watch | watch | The 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) | P3 | watch | watch | The 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) | P4 | watch | watch | The 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) | P4 | watch | watch | The 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) | P4 | close | close | The 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):
| Id | Priority | Case | When | Severity | Open | Chain or goal |
|---|---|---|---|---|---|---|
c1 | P1 | compromised device for kim@harborlane.example | 2026-10-23 11:20 | high | 1 open | - |
c2 | P3 | suspicious login x2 for raj@harborlane.example | 2026-10-22 03:40 to 04:02 | medium | 2 open | named in goal |
c3 | P3 | data loss rule triggered for dee@harborlane.example | 2026-10-24 14:15 | medium | 1 open | - |
c4 | P3 | phishing or malware for priya@harborlane.example | 2026-10-20 09:31 | medium | 1 open | named in goal |
c5 | P3 | phishing or malware for omar@harborlane.example | 2026-10-20 09:11 | medium | 1 open | named in goal |
c6 | P3 | phishing or malware for lena@harborlane.example | 2026-10-20 09:07 | medium | 1 open | named in goal |
c7 | P3 | phishing or malware for raj@harborlane.example | 2026-10-20 09:05 | medium | 1 open | named in goal |
c8 | P4 | leaked password for omar@harborlane.example | 2026-10-18 18:30 | high | all 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):
| Case | Priority | Action | The rule | Why | First step |
|---|---|---|---|---|---|
compromised device for kim@harborlane.example (c1) | P1 | contain (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) | P3 | close | close | raj@harborlane.example is travelling in Brazil this week, which accounts for the suspicious logins. | - |
data loss rule triggered for dee@harborlane.example (c3) | P3 | watch | watch | The 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) | P3 | close | close | The phishing test run on Oct 20 explains the phishing or malware report for priya@harborlane.example. | - |
phishing or malware for omar@harborlane.example (c5) | P3 | close | close | The phishing test run on Oct 20 explains the phishing or malware report for omar@harborlane.example. | - |
phishing or malware for lena@harborlane.example (c6) | P3 | close | close | The phishing test run on Oct 20 explains the phishing or malware report for lena@harborlane.example. | - |
phishing or malware for raj@harborlane.example (c7) | P3 | close | close | The phishing test run on Oct 20 explains the phishing or malware report for raj@harborlane.example. | - |
leaked password for omar@harborlane.example (c8) | P4 | close | close | The 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):
| Id | Priority | Case | When | Severity | Open | Chain or goal |
|---|---|---|---|---|---|---|
c1 | P2 | leaked password for sam@riverbend.example | 2026-10-27 10:15 | high | 1 open | - |
c2 | P3 | suspicious login x2 for jo@riverbend.example | 2026-10-25 08:12 to 08:40 | medium | 2 open | - |
c3 | P3 | third-party app access for jo@riverbend.example | 2026-10-28 09:00 | medium | 1 open | - |
c4 | P4 | phishing or malware for dee@riverbend.example | 2026-10-26 14:02 | medium | all closed | - |
c5 | P4 | suspicious device activity for kim@riverbend.example | 2026-10-27 11:00 | low | 1 open | - |
c6 | P4 | spam spike for no user named | 2026-10-26 16:30 | low | 1 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):
| Case | Priority | Action | The rule | Why | First step |
|---|---|---|---|---|---|
leaked password for sam@riverbend.example (c1) | P2 | investigate | investigate | The 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) | P3 | watch | watch | The 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) | P3 | watch | watch | Third-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) | P4 | close | close | The phishing or malware alert for dee@riverbend.example is closed and needs no further action. | - |
suspicious device activity for kim@riverbend.example (c5) | P4 | watch | watch | The 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) | P4 | watch | watch | The 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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://alert-sift.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// alert-sift.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://alert-sift.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://alert-sift.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// AlertSift.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class AlertSift {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://alert-sift.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://alert-sift.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://alert-sift.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://alert-sift.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "alert-sift"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "alert-sift" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "alert-sift"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "alert-sift"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "alert-sift" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "alert-sift"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "alert-sift" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"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
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "alerts", goal, totals, cases, others} (see the field table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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
import hashlib, time
key = "alert-sift:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
print(reply.get("note", ""))
for p in reply.get("plan") or []:
print(p["case"], p["action"])
import { createHash } from "node:crypto";
const key = `alert-sift:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
console.log(reply.note);
for (const p of reply.plan || []) console.log(p.case, p.action);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "alert-sift:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
plan, _ := reply["plan"].([]any)
fmt.Println(j["charged_credits"], j["truncated"], reply["verdict"])
fmt.Println(reply["note"])
for _, x := range plan {
p := x.(map[string]any)
fmt.Println(p["case"], p["action"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "alert-sift:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.get("verdict"));
System.out.println(reply.path("note").asText());
for (JsonNode p : reply.path("plan")) System.out.println(p.path("case").asText() + " " + p.path("action").asText());
require "digest"
key = "alert-sift:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["verdict"]].inspect
puts reply["note"]
(reply["plan"] || []).each { |p| puts "#{p["case"]} #{p["action"]}" }
$key = "alert-sift:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", ($reply["verdict"] ?? ""), "\n";
echo $reply["note"] ?? "", "\n";
foreach ($reply["plan"] ?? [] as $p) echo $p["case"], " ", $p["action"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"alert-sift:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply!["verdict"]}");
Console.WriteLine($"{reply["note"]}");
foreach (var p in reply["plan"] as JsonArray ?? new JsonArray()) Console.WriteLine($"{p!["case"]} {p["action"]}");
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "alert-sift:alerts:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `alert-sift:alerts:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "alert-sift:alerts:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "alert-sift:alerts:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "alert-sift:alerts:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: alert-sift:alerts:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"alert-sift:alerts:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);