SentinelOne Management Console API
Wraps the SentinelOne Management Console API (Swagger 2.0, spec version 2.1, 781 operations) with a pre-built Python client, a compact endpoint index, and per-tag reference files.
Setup — configure credentials first
Credentials live in config.json at the skill root. Users update two fields:
{
"base_url": "https://REPLACE-ME.sentinelone.net",
"api_token": "REPLACE_WITH_YOUR_API_TOKEN"
}
The base_url is the user's tenant console URL (no trailing slash, no /web/api/v2.1). The api_token is an API User token from Settings → Users → Service Users in the S1 console.
Environment variables override the file: S1_BASE_URL, S1_API_TOKEN, S1_VERIFY_TLS.
Before running anything, confirm config.json has been filled in. If the placeholder strings are still present, stop and ask the user to update them.
Workflow
When the user asks for something involving the S1 API, follow this pattern:
- Find the right endpoint.
- If the user's ask is verb-shaped ("list / count / isolate / hunt …") and you need orientation, read
references/CAPABILITY_MAP.md first — it's a compact per-tag summary of what verbs each resource supports.
- For a specific multi-step task ("threat triage", "endpoint isolation", "DV hunt"),
references/WORKFLOWS.md has ready-to-adapt recipes.
- Otherwise go straight to
scripts/search_endpoints.py with a keyword matching the user's intent. It now ranks results by relevance (path segment hits + verb intent + tag) and supports synonyms ("isolate" → "disconnect", "endpoint" → "agent"). Add --only-works to restrict to endpoints confirmed reachable on this tenant by the most recent smoke test.
- Read the per-tag reference. Open
references/tags/<Tag>.md (names match the table in references/TAG_INDEX.md) to see full parameter lists, descriptions, required permissions, and response codes for that group. Only read the tag file(s) relevant to the task — don't read them all.
- Call the endpoint. Either use
scripts/call_endpoint.py for one-off calls, or import S1Client from scripts/s1_client.py in a Python script for anything that needs loops, joins, or transforms. For independent GETs, prefer c.get_many([(path, params), ...]) — it fans out in parallel over the client's pooled connection and is ~3× faster than a sequential loop.
- Paginate correctly. S1 list endpoints use cursor-based pagination. The client's
paginate() and iter_items() handle this automatically — prefer them over manual skip/limit math, which caps at 1000 items.
- Summarize the result for the user. Don't dump raw JSON unless asked. Prefer a short prose summary plus a table or CSV/XLSX if the volume warrants.
Probing a new tenant
When starting on an unfamiliar tenant, run the non-destructive smoke test once:
python scripts/smoke_test_queries.py --workers 12
It enumerates every GET plus a curated allow-list of read-only query POSTs, records which ones return 200/403/404/etc., and writes references/tenant_capabilities.md and .json. Useful for "what's this token actually allowed to do" and as a pre-sales capability snapshot. The sweep is read-only — no writes, no agent actions — so tenant start-state and end-state are identical.
Files in this skill
config.json — credentials (user updates these).
scripts/s1_client.py — importable Python client. Handles auth, pooled HTTP connections, retries on 429/5xx, pagination, parallel fan-out via get_many(), and optional short-TTL response caching for rarely-changing reads (accounts, sites, groups, system/info, etc.).
scripts/call_endpoint.py — CLI for one-shot calls: python scripts/call_endpoint.py GET /web/api/v2.1/agents --param limit=5.
scripts/search_endpoints.py — ranked keyword search over the endpoint index, with synonym expansion and an --only-works filter that restricts to endpoints confirmed reachable on this tenant.
scripts/smoke_test_queries.py — non-destructive sweep of every GET + safe query POST. Writes references/tenant_capabilities.{json,md}. Read-only; tenant state unchanged.
scripts/purple_ai.py — Purple AI natural-language wrapper over POST /web/api/v2.1/graphql (undocumented endpoint). Exports purple_query() and PurpleAIError.
scripts/call_purple.py — CLI wrapper: python scripts/call_purple.py "show powershell.exe outbound connections".
scripts/unified_alerts.py — Unified Alert Management (UAM) GraphQL wrapper over POST /web/api/v2.1/unifiedalerts/graphql. Covers the full query + mutation surface (list/filter/group/notes/history/trigger-actions). See references/UNIFIED_ALERTS.md.
scripts/call_unified_alerts.py — CLI for UAM: python scripts/call_unified_alerts.py list --filter detectionProduct=EDR --first 10, ... add-note <id> "…", ... set-status --scope <acct> --alert-id <id> RESOLVED.
references/UNIFIED_ALERTS.md — UAM reference: operation catalogue, schema quirks, filter patterns, action catalogue, worked recipes.
references/TAG_INDEX.md — table of all 113 tags with file pointers and op counts. Start here when you don't know which tag owns an endpoint.
references/CAPABILITY_MAP.md — per-tag verb-and-resource summary (L=list, G=get-one, C=count, E=export, A=action, F=filter, S=search, X=mutate) plus an "I want to…" quick lookup. Your fastest orientation when you know the verb but not the path.
references/WORKFLOWS.md — ready-to-adapt multi-step recipes: threat triage, endpoint isolation, DV / PowerQuery hunt, RemoteOps, audit trail, tenant capability snapshot, etc. Each lists the endpoints you actually need and the params that matter.
references/tenant_capabilities.{json,md} — auto-generated by smoke_test_queries.py: per-endpoint status (200/403/404/etc.) for the tenant in config.json. Regenerate whenever the token or tenant changes. The committed copy is a worked example from the Purple demo tenant.
references/endpoint_index.json — compact machine-readable index (one entry per op). Used by search_endpoints.py but can be read directly if you need to filter programmatically.
references/tags/<Tag>.md — per-tag reference with parameters, descriptions, and required permissions. Load only the files you need.
references/common_params.md — shared query params (skip, limit, cursor, sortBy, etc.) and the pagination pattern.
references/POWERQUERY_RECIPES.md — PowerQuery / SDL query recipes tested on-tenant: indicator prevalence, PowerShell outbound to public IPs, failed-login triage, storyline activity summary, UAM-indicator SDL crosscheck, endpoint heartbeat. For full PQ language reference use the dedicated sentinelone-powerquery skill.
spec/swagger_2_1.json — the original full Swagger spec (14 MB). Use only when the per-tag reference is insufficient — e.g. to resolve a deeply nested request-body schema by $ref. Never read this whole file into context.
tests/test_ioc_lifecycle.py — reversible CREATE → LIST → DELETE → VERIFY round-trip for Threat Intelligence IOCs. Uses a unique run-tag per invocation, scopes to a single account, and cleans up before exit. Covers the one "create content" path against the S1 detection surface.
tests/test_alerts_dual_api.py — dual-API round-trip for alerts: GraphQL list/detail/addNote/notes/deleteNote plus a parallel REST /cloud-detection/alerts read. Demonstrates that UAM GraphQL is the PRIMARY alert surface and REST is SECONDARY, with the note mutation cleaned up before exit (handles the mgmt_note_id propagation delay).
scripts/pq.py — foolproof PowerQuery runner over the LRQ API. Wraps launch/poll/cancel, auth flip to Bearer, X-Dataset-Query-Forward-Tag capture, exponential backoff on 5xx/429/connection errors, and a best-effort cancel. One call: run_pq(client, "<query>", hours=24) returns {row_count, columns, rows, matchCount, ...}. Also exposes list_data_sources(client, hours=24) for the first-response "does this data source actually exist on this tenant?" check. Use this any time a user says "query logs", "run a PQ", "search for events" via the mgmt console API.
scripts/inspect_source.py — source-agnostic schema discovery. For any dataSource.name, samples raw events via the LRQ LOG queryType (or sync /sdl/api/query when available) and classifies every attribute the parser emits into principal_user / principal_host / principal_ip / action / temporal / network / file / process / grouping_candidate / other. Picks prim_key + action_key from whatever the source actually carries, so downstream code never hardcodes field names. Exports discover_schema(client, source, hours, sample, extra_filter, backend, escalate) and pick_keys(schema); CLI: python scripts/inspect_source.py --source "<name>" --window 24h. See "Data source + schema discovery" below.
scripts/uam_alert_interface.py — UAM (Unified Alert Management) Alert Interface client for pushing OCSF indicators + alerts INTO UAM via POST /v1/indicators and POST /v1/alerts on ingest.us1.sentinelone.net. Handles the gzip-compressed concatenated-JSON body, Bearer auth (the endpoint rejects ApiToken), and the S1-Scope header. Exposes UAMAlertInterfaceClient, plus build_file_indicator(), build_process_indicator(), build_network_indicator(), and build_alert_referencing() payload helpers. URL is configurable via uam_alert_interface_url in config.json (defaults to https://ingest.us1.sentinelone.net; legacy key ingestion_gateway_url is still honored as a fallback).
tests/test_uam_alert_interface_single.py — minimum-viable reversible write-side round-trip: POST one OCSF FileSystem-Activity indicator + one SecurityAlert referencing it, poll UAM GraphQL until the alert surfaces, verify the indicator is stitched in, then close the alert via bulk-ops (status=RESOLVED, analystVerdict=TRUE_POSITIVE_BENIGN). Covers the single-indicator happy path into UAM.
tests/test_uam_alert_interface_batch.py — comprehensive reversible round-trip: batched POST of 3 indicators (OCSF classes 1001 FileSystem Activity, 1007 Process Activity, 4001 Network Activity) each carrying 3+ observables, referenced by a single SecurityAlert via finding_info.related_events[]. Verifies all 3 metadata.uids and their observable names surface in alert.rawIndicators, then closes the alert. Covers batching, multi-observable, and multi-indicator linkage.
scripts/ingestion_gateway.py + tests/test_ingestion_gateway_alert_with_indicator.py — deprecated back-compat shims. The helper re-exports from uam_alert_interface; the test prints a pointer to the renamed file and exits non-zero.
build_ps_report_data.py: collector for the CTO report pipeline. Runs dimension probes + per-principal mix + timeline for a named data source via scripts/pq.py and writes reports/<slug>_<window>/data.json. Outputs to a per-source subfolder so multiple sources and windows coexist cleanly.
render_charts.py: pure-function renderer. data.json in, PNG charts out under reports/<slug>_<window>/charts/. No tenant calls.
build_docx.py / build_pptx.py: source-agnostic renderers that read data.json and emit <Slug>_CTO_Report_<window>.docx and <Slug>_CTO_Deck_<window>.pptx. Every section is gated on dims so dimension-sparse sources render cleanly. See "CTO report generation pipeline" below for the full contract and renderer gotchas.
reports/<slug>_<window>/: per-run artefact directory. Holds data.json, charts/, and the rendered .docx / .pptx. Treat this as the portable unit: move or archive the whole folder.
Using the client in Python
import sys
sys.path.insert(0, "scripts") # or set PYTHONPATH
from s1_client import S1Client, S1APIError
c = S1Client(cache_ttl=60) # optional 60s cache for accounts/sites/groups/system-info
# single page
r = c.get("/web/api/v2.1/threats", params={"limit": 100, "resolved": False})
# full iteration
for threat in c.iter_items("/web/api/v2.1/threats", params={"limit": 200}):
...
# parallel fan-out — independent GETs over pooled connections (~3× faster)
results = c.get_many([
("/web/api/v2.1/accounts", {"limit": 1}),
("/web/api/v2.1/sites", {"limit": 1}),
("/web/api/v2.1/groups", {"limit": 1}),
("/web/api/v2.1/system/info", None),
], max_workers=8)
# -> [{"path":..., "ok":True, "status":200, "data":..., "elapsed_ms":...}, ...]
# action endpoint
c.post("/web/api/v2.1/agents/actions/disconnect", json_body={"filter": {"ids": ["AGENT_ID"]}})
Authentication
The API uses header auth: Authorization: ApiToken <token>. The client injects this automatically — do not hand-roll headers.
Token scopes are enforced server-side. Each endpoint in the per-tag references lists Required permissions — if a 403 comes back, the token lacks one of those scopes, and the fix is a new token (not a code change). Surface this clearly to the user.
Rate limits and retries
The client retries automatically on 429 and 5xx with exponential backoff (max 30s), honoring Retry-After when present. For bulk operations across thousands of entities, prefer a single filtered action endpoint (/agents/actions/...) over a loop of per-ID calls — the API is designed around filter-based bulk ops.
Destructive actions — confirm first
Many endpoints are destructive or operationally sensitive: disconnect/reconnect agent, uninstall, isolate, shutdown, decommission, script execution via RemoteOps, policy changes, user mutations, account/site deletion. Before firing any POST/PUT/DELETE that affects agents, policies, or tenant config, summarize exactly what will happen (endpoint, filter, estimated scope) and get explicit user confirmation. A 200 response on a wrong filter can isolate thousands of endpoints — there is no undo on many of these.
The safe pattern: run the matching GET with countOnly=true first to show the blast radius, then the mutating call.
Purple AI — natural-language query
Precedence: if the user explicitly mentions "purple mcp" or "mcp", prefer the Purple MCP tools (mcp__purple-mcp__purple_ai, mcp__purple-mcp__powerquery, etc.) — this skill is the backup path in that case. Use the wrapper below when the user has asked for the S1 console/API directly, when Purple MCP is unavailable, or when you need to script a raw GraphQL call.
SentinelOne exposes an undocumented GraphQL endpoint at POST /web/api/v2.1/graphql that powers the console's Purple AI chat. The skill wraps the purpleLaunchQuery operation so workflows can ask Purple AI in natural language and receive a structured response (summary text plus a generated PowerQuery).
Auth is identical to REST — the same Authorization: ApiToken <token> header. No extra credential setup beyond config.json.
import sys
sys.path.insert(0, "scripts")
from s1_client import S1Client
from purple_ai import purple_query, PurpleAIError
c = S1Client()
try:
r = purple_query(
c,
"Show powershell.exe processes making outbound connections in the last 24h, top 10.",
view_selector="EDR", # EDR | IDENTITY | CLOUD | NGFW | DATA_LAKE
hours=24,
)
except PurpleAIError as e:
# entitlement or permission failure — the token's role can't use Purple AI,
# or the tenant isn't entitled. These return HTTP 200 with an in-body error.
print(f"purple error: {e} (type={e.error_type})")
else:
print(r["message"]) # natural-language answer
print(r["power_query"]) # generated PQ (may be None — see below)
print(r["suggested_questions"])
CLI equivalent:
python scripts/call_purple.py "show powershell.exe outbound connections, top 10"
python scripts/call_purple.py --selector CLOUD --hours 48 "show s3 downloads by user"
python scripts/call_purple.py --json "..." # machine-readable normalized result
python scripts/call_purple.py --raw "..." # full GraphQL response
Purple AI's domain boundary — important
Purple AI answers questions about SDL telemetry: process events, network events, file events, indicators, and ingested third-party logs. It does not answer questions about console entities — alerts, threats, agents, sites, policies. Those are REST resources; use the matching REST endpoint (e.g. GET /web/api/v2.1/threats, GET /web/api/v2.1/cloud-detection/alerts) instead.
Out-of-domain questions return HTTP 200 with result_type: "MESSAGE" and a scope refusal like "Purple can query for threat indicators, OS events, and some third-party vendor logs ingested into the Singularity Data Lake." — this is Purple's own guardrail, not a skill failure. When the user's ask is about an entity and Purple refuses, switch to the REST path and tell them why.
Interpreting the response
Key fields in the normalized dict:
result_type: "POWER_QUERY" means Purple generated an executable PQ (check power_query). "MESSAGE" means docs/RAG mode — message has the answer, power_query will be None.
state: "COMPLETED" is the successful path. Any other state is unexpected.
power_query: the PQ Purple generated. Do not auto-execute it without showing it to the user first — Purple can hallucinate fields and execution has a tenant cost. Prefer: render it → confirm with user → then run it through the existing DV/PowerQuery endpoints.
suggested_questions: the "you might also ask" chips from the UI.
Caveats
- The GraphQL endpoint is undocumented and not a committed public API. Field names, schema, and behavior can change between console releases. Flag this when building anything production-grade on top.
- Entitlement and permission failures come back as HTTP 200 with
status.error populated. The wrapper raises PurpleAIError on these so they don't masquerade as empty results — surface the error_type to the user verbatim (it's the best hint we have for "re-issue the token with Purple AI permission" vs "the tenant isn't licensed for Purple").
teamToken and accountId in the request body are UI-session artifacts; empty strings are accepted for API-token auth.
Querying logs via the mgmt console API — the foolproof procedure
Every time somebody rolls their own requests.post(...) for a PowerQuery, one of the same six things goes wrong: wrong auth prefix, wrong endpoint path, missing tenant: true, missing X-Dataset-Query-Forward-Tag, no retry on transient 5xx, or 0 rows and the wrong debugging reflex. The fix is: do not hand-roll the call. Use scripts/pq.py.
Step 0 — pick the right surface before you write a query
| The user wants… |
Use |
Why |
| Raw event telemetry (EDR, third-party logs, SDL data) |
scripts/pq.py (LRQ PowerQuery) |
This is what SDL/PowerQuery is for. All dataSource.*, event.*, src.process.*, tgt.file.*, i.scheme="edr" filters. |
| Triage/filter/note/status on an existing alert |
scripts/unified_alerts.py (UAM GraphQL) |
Alerts are entities, not log events. UAM filter syntax is GraphQL FilterInput, NOT PowerQuery. Do not confuse the two. |
| Legacy STAR/cloud-detection alert REST shape |
/web/api/v2.1/cloud-detection/alerts |
Only when you need agentDetectionInfo / sourceProcess etc. Otherwise UAM. |
| A console entity — threat, agent, site, policy, IOC, group |
REST via s1_client.py |
Not a log query. GET /web/api/v2.1/{threats,agents,sites,...}. |
| Natural-language hunt that can be hand-reviewed |
purple_ai.purple_query(...) then LRQ-execute the returned PQ |
Purple generates PQ text; pq.py runs it. |
If the user names a vendor ("Prompt Security", "Zscaler", "Okta", "FortiGate") and says "query" or "search logs", that is always the PQ path, never UAM filter syntax.
Step 1 — use scripts/pq.py, not inline requests
import sys
sys.path.insert(0, "scripts")
from s1_client import S1Client
from pq import run_pq, list_data_sources, PQError
c = S1Client()
# One call. Handles launch, polling, forward-tag, cancel, retry, the lot.
res = run_pq(
c,
"dataSource.name = 'Prompt Security' "
"| group ct = count() by event.type "
"| sort -ct "
"| limit 50",
hours=24,
)
print(res["matchCount"], "events ->", res["row_count"], "rows")
for row in res["rows"]:
print(row)
The helper does ALL of this for you, so there is nothing to remember:
Authorization: Bearer <jwt> (flipped from the REST ApiToken prefix; same JWT, different scheme).
POST /sdl/v2/api/queries on the tenant console host. NOT /web/api/v2.1/sdl/v2/api/queries, NOT xdr.us1.sentinelone.net. Do not "fix" a 404 by adding /web/api/v2.1 — that path does not exist; the fix is the shorter path.
- Captures
X-Dataset-Query-Forward-Tag from the POST response and echoes it on every GET / DELETE (mandatory for shard routing; without it you get rejections).
- Sets
queryType: "PQ", tenant: true, pq: {query, resultType: "TABLE"} (omit tenant and you silently get matchCount=0).
- Polls at 1s (query expires 30s after the last poll — slower polling means you lose the query).
- Retries 5xx / 429 / connection errors with exponential backoff. Honors
Retry-After. The DNS-cache-overflow 503s behind some egress proxies are exactly what this is for.
- Cancels on every exit path (success, deadline, failure) to release the per-account concurrent-query budget.
Step 2 — if you get 0 rows, follow the ladder, do NOT widen the window first
run_pq returning row_count=0 has an ordered diagnostic. Burning time by widening the window first is the most common failure mode; the window is almost never the cause.
Enumerate the data sources. If your filter names a vendor / product, first confirm it exists on THIS tenant and you have the string right. Spelling, case, and punctuation matter — the filter is a literal string match.
sources = list_data_sources(c, hours=24)
for s in sources[:30]:
print(s["dataSource.name"], s["dataSource.category"], s["ct"])
If "Prompt Security" isn't in the list, the tenant isn't ingesting it — no amount of widening the window will help. If it's there under a different spelling ("PromptSecurity", "Prompt Sec"), use the exact string.
Compare matchCount vs row_count. matchCount=0 means the initial filter discarded everything before any aggregation — the filter is too tight (or naming the wrong thing). matchCount > 0 with row_count = 0 means a post-pipe stage (| group, | filter after group) ate the rows — inspect the pipe.
Only after the above come back clean, widen the time window — in that order: 24h → 7d → 30d.
Step 3 — for large windows / heavy aggregates, slice
For ranges past 2-3 days with event.type=*-scale aggregates, slice the window and run slices in parallel. Full reference, measured perf (30d 574M-event aggregate lands in ~29s with two service-user JWTs), and the two-JWT runner recipe are in the sentinelone-powerquery skill at references/lrq-api.md. run_pq is the single-slice primitive underneath.
Step 3a — timeseries: DO NOT use timebucket(...)
The PQ engine does not expose a timebucket function. Any pipeline of the form | group n=count() by timebucket('1d'), action fails with HTTP 500 "undefined field 'timebucket'". The fix is client-side day slicing:
from datetime import datetime, timedelta, timezone
import concurrent.futures as cf
def slice_day(c, base, start, end):
iso = lambda t: t.strftime("%Y-%m-%dT%H:%M:%SZ")
return run_pq(c, base + " | group n=count() by action | sort -n",
start_time=iso(start), end_time=iso(end),
poll_deadline_s=90)
end = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
days = [(end - timedelta(days=i+1), end - timedelta(days=i)) for i in range(7)]
with cf.ThreadPoolExecutor(max_workers=3) as ex: # 3rps user cap
results = list(ex.map(lambda se: slice_day(c, base, *se), days))
7 daily slices run in ~20s wall-clock (vs ~2 min for a 7d aggregate) and respect the per-user 3 rps cap. For hourly buckets over a 24h window use 24 slices at the same concurrency; for 30d use hourly slicing with 2 JWTs (see sentinelone-powerquery skill).
Step 3b — window-scaling playbook (performance by period)
| Window |
Recommended runner |
Why |
| seconds to 1h |
single run_pq(hours=1) |
server returns in <5s |
| 1h to 24h |
single run_pq(hours=24) |
5-30s depending on filter selectivity |
| 24h to 7d |
single call OK for selective filters; for event.type=*-scale aggregates, 7 x 1d slices in parallel (max_workers=3) |
single-call ~2 min; sliced ~20s |
| 7d to 30d |
mandatory slicing (daily buckets) + 2 JWTs |
two-JWT runner in sentinelone-powerquery |
| 30d+ |
hourly slicing + 2-3 JWTs, cache results |
574M-event aggregate at 30d = ~29s with two JWTs |
Step 3c — LRQ response-shape gotchas (handled by run_pq)
If you ever have to read a raw LRQ response (e.g. debugging), know:
columns is a list of dicts {name, cellType, decimalPlaces}, not a list of strings. Zipping values by col["name"] (not str(col)) is mandatory.
matchCount lives inside the data block (response["data"]["matchCount"]), not at top level. Default to that path; fall back to top-level for older engines.
values is an array of arrays (one per row); run_pq pairs it with column names for you.
Step 4 — when NOT to use pq.py
- If the user said "purple mcp" or "mcp", defer to
mcp__purple-mcp__powerquery first; this is the backup path when the MCP times out or 5xxs.
- If the user is working with alerts as entities (listing, filtering, note, status), that's UAM GraphQL (
unified_alerts.py), not PowerQuery. UAM filter syntax is [{fieldId, stringEqual: {...}}]; it is NOT PowerQuery | filter syntax. Mixing them is a common trap in screenshot-driven debugging.
Checklist before running a PQ programmatically
Unified Alert Management (UAM) — PRIMARY alert API
Alert API precedence — important:
- PRIMARY — GraphQL UAM at
POST /web/api/v2.1/unifiedalerts/graphql. This is the modern, multi-source alerts inbox (EDR, XDR, Identity, STAR, Cloud, NGFW, and ingested third-party telemetry). IDs are UUIDs (e.g. 019db24c-8b6d-7451-8697-b1b2e1a270f1). Use this for any alert listing, filtering, triage, note, status, verdict, assignment, group-by, facet, or CSV-export task.
- SECONDARY — REST at
GET /web/api/v2.1/cloud-detection/alerts. Older surface, scoped to cloud-detection events (STAR rule hits, EDR overflow). IDs are int64 (e.g. 2055164731151448891). Use only when you specifically need the denormalized REST payload (agentDetectionInfo, sourceProcess, targetProcess, ruleInfo) or when UAM is unavailable. These are parallel surfaces, not redundant — the same alert will have different IDs in each.
- No
createAlert. S1 does not expose a mutation for creating alerts directly. Alerts are server-side byproducts of detection engines — create a STAR/Custom Detection rule (POST /web/api/v2.1/cloud-detection/rules), upload an IOC that matches live telemetry (POST /web/api/v2.1/threat-intelligence/iocs), or generate synthetic endpoint activity. addAlertNote is the closest reversible content-creation path against an existing alert.
Auth is the same Authorization: ApiToken header as REST; no extra credentials. Full reference in references/UNIFIED_ALERTS.md. The end-to-end dual-API round-trip test is tests/test_alerts_dual_api.py.
Use UAM whenever the user is working with alerts as first-class entities — triaging, filtering, adding notes, resolving, bulk-assigning — rather than the older GET /web/api/v2.1/threats surface.
import sys
sys.path.insert(0, "scripts")
from s1_client import S1Client
import unified_alerts as uam
c = S1Client()
# discover: fieldIds, enum values, which views have data
cols = uam.column_metadata(c)
avail = uam.view_data_availability(c)
# triage: top 20 NEW CRITICAL EDR alerts from the last day
page = uam.list_alerts(c, filters=[
uam.build_filter(fieldId="detectionProduct", stringEqual={"value": "EDR"}),
uam.build_filter(fieldId="status", stringEqual={"value": "NEW"}),
uam.build_filter(fieldId="severity", stringEqual={"value": "CRITICAL"}),
], first=20)
# act: bulk resolve a specific list of alerts, with a note
account = uam.scope(["<account_id>"])
uam.set_alert_status(
c, scope_input=account,
alert_ids=["<alert1>", "<alert2>"],
status="RESOLVED",
note="Auto-closed: part of campaign tracked in JIRA-1234",
)
CLI equivalents:
python scripts/call_unified_alerts.py list --filter detectionProduct=EDR --first 20
python scripts/call_unified_alerts.py facets status severity detectionProduct
python scripts/call_unified_alerts.py notes <alert-id>
python scripts/call_unified_alerts.py add-note <alert-id> "Investigating"
python scripts/call_unified_alerts.py set-status --scope <account-id> --alert-id <id1> <id2> RESOLVED --note "..."
python scripts/call_unified_alerts.py csv-export --filter severity=CRITICAL -o crit.csv
UAM domain — what belongs here vs REST
UAM owns everything in the modern Alerts inbox, including alert notes, alert history, mitigation results, trigger-actions, and Cursor-paginated group-by / facet views. The older /web/api/v2.1/threats REST surface still exists and covers the classic endpoint-protection threat lifecycle — when the user says "alerts", "unified alerts", "alert notes", or mentions XDR / multi-source detections, route to UAM. When they say "threat", "threat group", "incident", or reference /threats, stay on REST.
Important quirks (hidden by the wrapper, but mind them if writing raw GraphQL)
- The
alerts query takes a flat filters: [FilterInput!] (AND-joined); mutations and alertAvailableActions take filter: OrFilterSelectionInput shaped as { or: [{ and: [FilterInput, ...] }, ...] }. Mixing these up is a validation error. The wrapper exposes build_filter(...), or_filter(...), and scope(...) helpers so callers don't have to hand-assemble them.
updateAlertNote and deleteAlertNote fail for ~30–90s after a note is freshly created ("Alert Note with ID ... does not have mgmt_note_id set, unable to [edit|delete], try again later!") because the management-console backend is still propagating an internal id. The wrapper retries automatically with backoff — callers don't need to sleep.
aiInvestigations has no data wrapper, but alertNotes / alertFiltersCount / alertGroupByCount / alertAvailableActions / alertMitigationActionResults / CSV exports all do. alerts / alertHistory / alertTimeline / alertGroups use connection shape (edges/pageInfo/totalCount).
- Full list of traps — including the
SortOrderType enum name, alertGroupByCount using limit (not first), subselection requirements on CsvResponse / ActionsError, and the actual shape of alertsViewDataAvailability — is in references/UNIFIED_ALERTS.md under "Schema quirks".
Destructive actions — blast radius
alertTriggerActions is the single mutation that can touch many alerts at once. Passing filter: null means every alert in scope — potentially hundreds of thousands. The safe pattern is the same as REST bulk actions:
- Use
list_alerts(..., first=1) with the proposed filter and read totalCount.
- Show the user the exact filter + action list + count.
- Only after explicit confirmation, call
trigger_actions(...) or one of the set_alert_status / set_analyst_verdict / assign_alerts convenience wrappers (all of which constrain the filter to an explicit alert-id list by default).
UAM Alert Interface (Unified Alert Management) -- pushing OCSF indicators + alerts INTO UAM
Everything else in this skill talks to <tenant>.sentinelone.net/web/api/v2.1/... (the Mgmt Console) and is read-or-mutate on pre-existing server state. The UAM Alert Interface (formerly "Ingestion Gateway") is a separate API family on a separate host for the write-side path: it lets you push OCSF-formatted indicators and alerts INTO UAM so they show up in the console as real alerts with attached indicators. Use it when a user asks to "create an alert", "ingest indicators", "send alerts from my pipeline", or "test alert ingestion".
Host and wire contract:
- Prod (US1):
https://ingest.us1.sentinelone.net. Override via uam_alert_interface_url in config.json or the --uam-url flag / S1_UAM_ALERT_INTERFACE_URL env var. The legacy config key ingestion_gateway_url and env var S1_IGW_URL are still honored as fallbacks.
- Auth:
Authorization: Bearer <JWT>. NOT ApiToken. The mgmt-console JWT in config.json works; the endpoint rejects ApiToken ... with HTTP 401 "Unsupported auth type".
- Body: concatenated JSON (one or more objects back-to-back, optionally newline-separated), gzip-compressed.
Content-Encoding: gzip is mandatory. zstd also accepted.
- Scope:
S1-Scope: <accountId> or <accountId>:<siteId>[:<groupId>] is mandatory.
- Success shape:
202 Accepted with {"details":"Success","status":202}.
Endpoints:
POST /v1/indicators -- raw behavioral indicators. Each must carry metadata.profiles = ["s1/security_indicator"] and a unique metadata.uid (this is the join key). Batching: send many indicators in one call by passing a list; the client concatenates + gzips.
POST /v1/alerts -- SecurityAlert wrappers. Each references its indicator(s) via finding_info.related_events[].uid == indicator.metadata.uid. A single alert can reference multiple indicators (one entry per indicator). The server stitches them into alert.rawIndicators / the UAM Indicators tab once both land. Call with ONE alert per POST. The wire format accepts multi-alert bodies and the gateway returns HTTP 202, but the stitcher silently drops all but one alert in a multi-alert batch (usea1-purple 2026-04-22); loop one at a time, or use post_alert_with_indicators which enforces the safe pattern.
Supported indicator classes (via builders):
build_file_indicator(...) -- OCSF class 1001 FileSystem Activity. Observables: Hostname, File Name, Hash (SHA-256/MD5), User Name, IP Address.
build_process_indicator(...) -- OCSF class 1007 Process Activity. Observables: Hostname, Process Name, Resource UID (pid), User Name, IP Address, plus parent process.
build_network_indicator(...) -- OCSF class 4001 Network Activity. Observables: Hostname, src/dst IP Address, URL, User Name.
Python usage:
import sys, time, uuid
sys.path.insert(0, "scripts")
from s1_client import S1Client
from uam_alert_interface import (
UAMAlertInterfaceClient,
build_file_indicator, build_process_indicator, build_network_indicator,
build_alert_referencing,
)
mgmt = S1Client()
uam_iface = UAMAlertInterfaceClient(bearer_token=mgmt.api_token)
now_ms = int(time.time() * 1000)
ind_uid_a, ind_uid_b, alert_uid = (str(uuid.uuid4()) for _ in range(3))
ind_a = build_file_indicator(
indicator_uid=ind_uid_a, file_name="payload.iso",
file_sha256="0"*64, device_uid=str(uuid.uuid4()),
device_hostname="host-1", device_ip="192.0.2.10",
user_uid=str(uuid.uuid4()), now_ms=now_ms,
)
ind_b = build_process_indicator(
indicator_uid=ind_uid_b, process_name="powershell.exe",
process_pid=4242, process_cmd_line="powershell -enc ...",
parent_process_name="explorer.exe",
device_uid=str(uuid.uuid4()), device_hostname="host-1",
user_uid=str(uuid.uuid4()), now_ms=now_ms,
)
alert = build_alert_referencing(
alert_uid=alert_uid, indicators=[ind_a, ind_b], now_ms=now_ms,
title="Ingested alert", description="...",
)
# Preferred safe path. Posts the indicators, sleeps 3s (so each
# metadata.uid registers before the stitcher resolves related_events),
# then posts the single alert. For many alerts, LOOP this call -- do
# NOT pass multiple alerts to post_alerts() in one go (see constraints
# below).
uam_iface.post_alert_with_indicators(
alert, [ind_a, ind_b], scope=f"{account_id}:{site_id}")
# Then poll UAM GraphQL (unified_alerts.list_alerts) to see it surface.
Validation: after ingest, find the alert via UAM GraphQL (unified_alerts.list_alerts filtered by name, or get_alert(alert_id) once you know it). get_alert_with_raw_indicators(c, alert_id) returns the raw indicator dict(s) so you can confirm every metadata.uid and its observable names made it through.
Cleanup: ingested alerts are not hard-deletable via public API. The standard reversibility pattern is to set status=RESOLVED and analystVerdict=TRUE_POSITIVE_BENIGN via the bulk-ops mutations in unified_alerts so the alert exits the active SOC queue and is tagged as synthetic.
Multi-indicator alert constraints (empirically confirmed on
usea1-purple 2026-04-22):
- One alert per
POST /v1/alerts call. The wire format accepts
concatenated JSON for N alerts in one body and the gateway returns
HTTP 202, but the stitcher silently drops all but one of the alerts.
Callers with many alerts MUST loop. post_alerts emits a
RuntimeWarning when len(alerts) > 1 to flag the hazard. Use
post_alert_with_indicators(alert, indicators, ...) for the safe
one-at-a-time path.
- Sleep between
POST /v1/indicators and POST /v1/alerts. If
the alert is posted immediately after its indicators, the stitcher
can resolve finding_info.related_events[].uid before the indicator's
metadata.uid is registered on the scope and silently drop the alert
(HTTP 202 still returned). A ~3s sleep between the two POSTs avoids
this; reducing below ~2s has been observed to regress on loaded
tenants. post_alert_with_indicators builds the sleep in; callers
using the low-level post_indicators + post_alerts path MUST add
it manually. test_uam_alert_interface_batch.py encodes this exact
sequence.
- Alerts with multiple
resources[] entries (i.e. indicators spanning
different device.uid values) are silently dropped by the stitcher.
Return: HTTP 202 at the wire, NEVER surfaces in UAM. The builder
collapses to a single resources[] entry (first indicator's device)
to avoid this. If you truly need per-indicator assets, emit separate
alerts.
- Each
finding_info.related_events[] entry MUST carry class_uid,
type_uid, category_uid, activity_id, severity_id, time,
message, and enriched observables[] (each with type +
typeName alongside type_id/name/value). build_alert_referencing()
populates all of these. Omitting any of them tends to cause the
stitcher to silently drop the alert.
file.hashes MUST be an OCSF Fingerprint array, not a dict.
OCSF 1.6.0 defines file.hashes as Array of Fingerprint objects:
[{"algorithm_id": 3, "algorithm": "SHA-256", "value": "<hex>"}, ...].
Posting {"sha256": "<hex>"} (dict form) causes the stitcher to
silently drop the file indicator even though POST returns 202.
build_file_indicator() emits the correct array shape; custom
payload builders must follow the same convention (algorithm_id 2=MD5,
3=SHA-256, 4=SHA-1, 5=SHA-512).
- Multi
…(truncated)
1---2name: sentinelone-mgmt-console-api3description: Use whenever the user wants to query, update, create, or act on a SentinelOne Management Console — threats, alerts, agents, sites, accounts, groups, exclusions, RemoteOps, Deep Visibility, Hyperautomation, Unified Alert Management (UAM), Purple AI, IOCs, or any other S1 Mgmt API resource. Trigger on "console", "query/update/create console", "SentinelOne", "S1", "Singularity", "UAM", "Purple AI", "/web/api/v2.1/...", S1 agent/threat/site IDs, or asks like "list endpoints", "triage alerts", "add note to alert", "create an IOC", "isolate endpoint", "run RemoteOps", "pull DV results". For alerts the PRIMARY API is GraphQL UAM at /web/api/v2.1/unifiedalerts/graphql; REST /cloud-detection/alerts is SECONDARY (older, cloud-detection scoped, int64 IDs). Defer to Purple MCP if the user says "purple mcp" or "mcp"; this skill is the backup then. Wraps the S1 Mgmt REST API (781 ops, 113 tags, v2.1) plus UAM GraphQL and Purple AI GraphQL, with a Python client, searchable index, and reversible tests.4---56# SentinelOne Management Console API78Wraps the SentinelOne Management Console API (Swagger 2.0, spec version 2.1, 781 operations) with a pre-built Python client, a compact endpoint index, and per-tag reference files.910## Setup — configure credentials first1112Credentials live in `config.json` at the skill root. Users update two fields:1314```json15{16 "base_url": "https://REPLACE-ME.sentinelone.net",17 "api_token": "REPLACE_WITH_YOUR_API_TOKEN"18}19```2021The `base_url` is the user's tenant console URL (no trailing slash, no `/web/api/v2.1`). The `api_token` is an API User token from Settings → Users → Service Users in the S1 console.2223Environment variables override the file: `S1_BASE_URL`, `S1_API_TOKEN`, `S1_VERIFY_TLS`.2425Before running anything, confirm `config.json` has been filled in. If the placeholder strings are still present, stop and ask the user to update them.2627## Workflow2829When the user asks for something involving the S1 API, follow this pattern:30311. **Find the right endpoint.**32 - If the user's ask is verb-shaped ("list / count / isolate / hunt …") and you need orientation, read `references/CAPABILITY_MAP.md` first — it's a compact per-tag summary of what verbs each resource supports.33 - For a specific multi-step task ("threat triage", "endpoint isolation", "DV hunt"), `references/WORKFLOWS.md` has ready-to-adapt recipes.34 - Otherwise go straight to `scripts/search_endpoints.py` with a keyword matching the user's intent. It now ranks results by relevance (path segment hits + verb intent + tag) and supports synonyms ("isolate" → "disconnect", "endpoint" → "agent"). Add `--only-works` to restrict to endpoints confirmed reachable on this tenant by the most recent smoke test.352. **Read the per-tag reference.** Open `references/tags/<Tag>.md` (names match the table in `references/TAG_INDEX.md`) to see full parameter lists, descriptions, required permissions, and response codes for that group. Only read the tag file(s) relevant to the task — don't read them all.363. **Call the endpoint.** Either use `scripts/call_endpoint.py` for one-off calls, or import `S1Client` from `scripts/s1_client.py` in a Python script for anything that needs loops, joins, or transforms. For independent GETs, prefer `c.get_many([(path, params), ...])` — it fans out in parallel over the client's pooled connection and is ~3× faster than a sequential loop.374. **Paginate correctly.** S1 list endpoints use cursor-based pagination. The client's `paginate()` and `iter_items()` handle this automatically — prefer them over manual `skip`/`limit` math, which caps at 1000 items.385. **Summarize the result for the user.** Don't dump raw JSON unless asked. Prefer a short prose summary plus a table or CSV/XLSX if the volume warrants.3940## Probing a new tenant4142When starting on an unfamiliar tenant, run the non-destructive smoke test once:4344```45python scripts/smoke_test_queries.py --workers 1246```4748It enumerates every GET plus a curated allow-list of read-only query POSTs, records which ones return 200/403/404/etc., and writes `references/tenant_capabilities.md` and `.json`. Useful for "what's this token actually allowed to do" and as a pre-sales capability snapshot. The sweep is read-only — no writes, no agent actions — so tenant start-state and end-state are identical.4950## Files in this skill5152- `config.json` — credentials (user updates these).53- `scripts/s1_client.py` — importable Python client. Handles auth, pooled HTTP connections, retries on 429/5xx, pagination, parallel fan-out via `get_many()`, and optional short-TTL response caching for rarely-changing reads (accounts, sites, groups, system/info, etc.).54- `scripts/call_endpoint.py` — CLI for one-shot calls: `python scripts/call_endpoint.py GET /web/api/v2.1/agents --param limit=5`.55- `scripts/search_endpoints.py` — ranked keyword search over the endpoint index, with synonym expansion and an `--only-works` filter that restricts to endpoints confirmed reachable on this tenant.56- `scripts/smoke_test_queries.py` — non-destructive sweep of every GET + safe query POST. Writes `references/tenant_capabilities.{json,md}`. Read-only; tenant state unchanged.57- `scripts/purple_ai.py` — Purple AI natural-language wrapper over `POST /web/api/v2.1/graphql` (undocumented endpoint). Exports `purple_query()` and `PurpleAIError`.58- `scripts/call_purple.py` — CLI wrapper: `python scripts/call_purple.py "show powershell.exe outbound connections"`.59- `scripts/unified_alerts.py` — Unified Alert Management (UAM) GraphQL wrapper over `POST /web/api/v2.1/unifiedalerts/graphql`. Covers the full query + mutation surface (list/filter/group/notes/history/trigger-actions). See `references/UNIFIED_ALERTS.md`.60- `scripts/call_unified_alerts.py` — CLI for UAM: `python scripts/call_unified_alerts.py list --filter detectionProduct=EDR --first 10`, `... add-note <id> "…"`, `... set-status --scope <acct> --alert-id <id> RESOLVED`.61- `references/UNIFIED_ALERTS.md` — UAM reference: operation catalogue, schema quirks, filter patterns, action catalogue, worked recipes.62- `references/TAG_INDEX.md` — table of all 113 tags with file pointers and op counts. Start here when you don't know which tag owns an endpoint.63- `references/CAPABILITY_MAP.md` — per-tag verb-and-resource summary (L=list, G=get-one, C=count, E=export, A=action, F=filter, S=search, X=mutate) plus an "I want to…" quick lookup. Your fastest orientation when you know the verb but not the path.64- `references/WORKFLOWS.md` — ready-to-adapt multi-step recipes: threat triage, endpoint isolation, DV / PowerQuery hunt, RemoteOps, audit trail, tenant capability snapshot, etc. Each lists the endpoints you actually need and the params that matter.65- `references/tenant_capabilities.{json,md}` — auto-generated by `smoke_test_queries.py`: per-endpoint status (200/403/404/etc.) for the tenant in `config.json`. Regenerate whenever the token or tenant changes. The committed copy is a worked example from the Purple demo tenant.66- `references/endpoint_index.json` — compact machine-readable index (one entry per op). Used by `search_endpoints.py` but can be read directly if you need to filter programmatically.67- `references/tags/<Tag>.md` — per-tag reference with parameters, descriptions, and required permissions. Load only the files you need.68- `references/common_params.md` — shared query params (`skip`, `limit`, `cursor`, `sortBy`, etc.) and the pagination pattern.69- `references/POWERQUERY_RECIPES.md` — PowerQuery / SDL query recipes tested on-tenant: indicator prevalence, PowerShell outbound to public IPs, failed-login triage, storyline activity summary, UAM-indicator SDL crosscheck, endpoint heartbeat. For full PQ language reference use the dedicated `sentinelone-powerquery` skill.70- `spec/swagger_2_1.json` — the original full Swagger spec (14 MB). Use only when the per-tag reference is insufficient — e.g. to resolve a deeply nested request-body schema by `$ref`. Never read this whole file into context.71- `tests/test_ioc_lifecycle.py` — reversible CREATE → LIST → DELETE → VERIFY round-trip for Threat Intelligence IOCs. Uses a unique run-tag per invocation, scopes to a single account, and cleans up before exit. Covers the one "create content" path against the S1 detection surface.72- `tests/test_alerts_dual_api.py` — dual-API round-trip for alerts: GraphQL list/detail/addNote/notes/deleteNote plus a parallel REST `/cloud-detection/alerts` read. Demonstrates that UAM GraphQL is the PRIMARY alert surface and REST is SECONDARY, with the note mutation cleaned up before exit (handles the `mgmt_note_id` propagation delay).73- `scripts/pq.py` — foolproof PowerQuery runner over the LRQ API. Wraps launch/poll/cancel, auth flip to `Bearer`, `X-Dataset-Query-Forward-Tag` capture, exponential backoff on 5xx/429/connection errors, and a best-effort cancel. One call: `run_pq(client, "<query>", hours=24)` returns `{row_count, columns, rows, matchCount, ...}`. Also exposes `list_data_sources(client, hours=24)` for the first-response "does this data source actually exist on this tenant?" check. Use this any time a user says "query logs", "run a PQ", "search for events" via the mgmt console API.74- `scripts/inspect_source.py` — source-agnostic schema discovery. For any `dataSource.name`, samples raw events via the LRQ `LOG` queryType (or sync `/sdl/api/query` when available) and classifies every attribute the parser emits into `principal_user` / `principal_host` / `principal_ip` / `action` / `temporal` / `network` / `file` / `process` / `grouping_candidate` / `other`. Picks `prim_key` + `action_key` from whatever the source actually carries, so downstream code never hardcodes field names. Exports `discover_schema(client, source, hours, sample, extra_filter, backend, escalate)` and `pick_keys(schema)`; CLI: `python scripts/inspect_source.py --source "<name>" --window 24h`. See "Data source + schema discovery" below.75- `scripts/uam_alert_interface.py` — UAM (Unified Alert Management) Alert Interface client for pushing OCSF indicators + alerts INTO UAM via `POST /v1/indicators` and `POST /v1/alerts` on `ingest.us1.sentinelone.net`. Handles the gzip-compressed concatenated-JSON body, `Bearer` auth (the endpoint rejects `ApiToken`), and the `S1-Scope` header. Exposes `UAMAlertInterfaceClient`, plus `build_file_indicator()`, `build_process_indicator()`, `build_network_indicator()`, and `build_alert_referencing()` payload helpers. URL is configurable via `uam_alert_interface_url` in `config.json` (defaults to `https://ingest.us1.sentinelone.net`; legacy key `ingestion_gateway_url` is still honored as a fallback).76- `tests/test_uam_alert_interface_single.py` — minimum-viable reversible write-side round-trip: POST one OCSF FileSystem-Activity indicator + one SecurityAlert referencing it, poll UAM GraphQL until the alert surfaces, verify the indicator is stitched in, then close the alert via bulk-ops (status=RESOLVED, analystVerdict=TRUE_POSITIVE_BENIGN). Covers the single-indicator happy path into UAM.77- `tests/test_uam_alert_interface_batch.py` — comprehensive reversible round-trip: batched POST of 3 indicators (OCSF classes 1001 FileSystem Activity, 1007 Process Activity, 4001 Network Activity) each carrying 3+ observables, referenced by a single SecurityAlert via `finding_info.related_events[]`. Verifies all 3 metadata.uids and their observable names surface in `alert.rawIndicators`, then closes the alert. Covers batching, multi-observable, and multi-indicator linkage.78- `scripts/ingestion_gateway.py` + `tests/test_ingestion_gateway_alert_with_indicator.py` — deprecated back-compat shims. The helper re-exports from `uam_alert_interface`; the test prints a pointer to the renamed file and exits non-zero.79- `build_ps_report_data.py`: collector for the CTO report pipeline. Runs dimension probes + per-principal mix + timeline for a named data source via `scripts/pq.py` and writes `reports/<slug>_<window>/data.json`. Outputs to a per-source subfolder so multiple sources and windows coexist cleanly.80- `render_charts.py`: pure-function renderer. `data.json` in, PNG charts out under `reports/<slug>_<window>/charts/`. No tenant calls.81- `build_docx.py` / `build_pptx.py`: source-agnostic renderers that read `data.json` and emit `<Slug>_CTO_Report_<window>.docx` and `<Slug>_CTO_Deck_<window>.pptx`. Every section is gated on `dims` so dimension-sparse sources render cleanly. See "CTO report generation pipeline" below for the full contract and renderer gotchas.82- `reports/<slug>_<window>/`: per-run artefact directory. Holds `data.json`, `charts/`, and the rendered `.docx` / `.pptx`. Treat this as the portable unit: move or archive the whole folder.8384## Using the client in Python8586```python87import sys88sys.path.insert(0, "scripts") # or set PYTHONPATH89from s1_client import S1Client, S1APIError9091c = S1Client(cache_ttl=60) # optional 60s cache for accounts/sites/groups/system-info9293# single page94r = c.get("/web/api/v2.1/threats", params={"limit": 100, "resolved": False})9596# full iteration97for threat in c.iter_items("/web/api/v2.1/threats", params={"limit": 200}):98 ...99100# parallel fan-out — independent GETs over pooled connections (~3× faster)101results = c.get_many([102 ("/web/api/v2.1/accounts", {"limit": 1}),103 ("/web/api/v2.1/sites", {"limit": 1}),104 ("/web/api/v2.1/groups", {"limit": 1}),105 ("/web/api/v2.1/system/info", None),106], max_workers=8)107# -> [{"path":..., "ok":True, "status":200, "data":..., "elapsed_ms":...}, ...]108109# action endpoint110c.post("/web/api/v2.1/agents/actions/disconnect", json_body={"filter": {"ids": ["AGENT_ID"]}})111```112113## Authentication114115The API uses header auth: `Authorization: ApiToken <token>`. The client injects this automatically — do not hand-roll headers.116117Token scopes are enforced server-side. Each endpoint in the per-tag references lists `Required permissions` — if a 403 comes back, the token lacks one of those scopes, and the fix is a new token (not a code change). Surface this clearly to the user.118119## Rate limits and retries120121The client retries automatically on 429 and 5xx with exponential backoff (max 30s), honoring `Retry-After` when present. For bulk operations across thousands of entities, prefer a single filtered action endpoint (`/agents/actions/...`) over a loop of per-ID calls — the API is designed around filter-based bulk ops.122123## Destructive actions — confirm first124125Many endpoints are destructive or operationally sensitive: disconnect/reconnect agent, uninstall, isolate, shutdown, decommission, script execution via RemoteOps, policy changes, user mutations, account/site deletion. Before firing any `POST`/`PUT`/`DELETE` that affects agents, policies, or tenant config, summarize exactly what will happen (endpoint, filter, estimated scope) and get explicit user confirmation. A 200 response on a wrong filter can isolate thousands of endpoints — there is no undo on many of these.126127The safe pattern: run the matching `GET` with `countOnly=true` first to show the blast radius, then the mutating call.128129## Purple AI — natural-language query130131> **Precedence:** if the user explicitly mentions "purple mcp" or "mcp", prefer the Purple MCP tools (`mcp__purple-mcp__purple_ai`, `mcp__purple-mcp__powerquery`, etc.) — this skill is the backup path in that case. Use the wrapper below when the user has asked for the S1 console/API directly, when Purple MCP is unavailable, or when you need to script a raw GraphQL call.132133SentinelOne exposes an undocumented GraphQL endpoint at `POST /web/api/v2.1/graphql` that powers the console's Purple AI chat. The skill wraps the `purpleLaunchQuery` operation so workflows can ask Purple AI in natural language and receive a structured response (summary text plus a generated PowerQuery).134135Auth is identical to REST — the same `Authorization: ApiToken <token>` header. No extra credential setup beyond `config.json`.136137```python138import sys139sys.path.insert(0, "scripts")140from s1_client import S1Client141from purple_ai import purple_query, PurpleAIError142143c = S1Client()144try:145 r = purple_query(146 c,147 "Show powershell.exe processes making outbound connections in the last 24h, top 10.",148 view_selector="EDR", # EDR | IDENTITY | CLOUD | NGFW | DATA_LAKE149 hours=24,150 )151except PurpleAIError as e:152 # entitlement or permission failure — the token's role can't use Purple AI,153 # or the tenant isn't entitled. These return HTTP 200 with an in-body error.154 print(f"purple error: {e} (type={e.error_type})")155else:156 print(r["message"]) # natural-language answer157 print(r["power_query"]) # generated PQ (may be None — see below)158 print(r["suggested_questions"])159```160161CLI equivalent:162163```164python scripts/call_purple.py "show powershell.exe outbound connections, top 10"165python scripts/call_purple.py --selector CLOUD --hours 48 "show s3 downloads by user"166python scripts/call_purple.py --json "..." # machine-readable normalized result167python scripts/call_purple.py --raw "..." # full GraphQL response168```169170### Purple AI's domain boundary — important171172Purple AI answers questions about **SDL telemetry**: process events, network events, file events, indicators, and ingested third-party logs. It does **not** answer questions about **console entities** — alerts, threats, agents, sites, policies. Those are REST resources; use the matching REST endpoint (e.g. `GET /web/api/v2.1/threats`, `GET /web/api/v2.1/cloud-detection/alerts`) instead.173174Out-of-domain questions return HTTP 200 with `result_type: "MESSAGE"` and a scope refusal like *"Purple can query for threat indicators, OS events, and some third-party vendor logs ingested into the Singularity Data Lake."* — this is Purple's own guardrail, not a skill failure. When the user's ask is about an entity and Purple refuses, switch to the REST path and tell them why.175176### Interpreting the response177178Key fields in the normalized dict:179180- `result_type`: `"POWER_QUERY"` means Purple generated an executable PQ (check `power_query`). `"MESSAGE"` means docs/RAG mode — `message` has the answer, `power_query` will be None.181- `state`: `"COMPLETED"` is the successful path. Any other state is unexpected.182- `power_query`: the PQ Purple generated. Do **not** auto-execute it without showing it to the user first — Purple can hallucinate fields and execution has a tenant cost. Prefer: render it → confirm with user → then run it through the existing DV/PowerQuery endpoints.183- `suggested_questions`: the "you might also ask" chips from the UI.184185### Caveats186187- The GraphQL endpoint is **undocumented** and not a committed public API. Field names, schema, and behavior can change between console releases. Flag this when building anything production-grade on top.188- Entitlement and permission failures come back as HTTP 200 with `status.error` populated. The wrapper raises `PurpleAIError` on these so they don't masquerade as empty results — surface the `error_type` to the user verbatim (it's the best hint we have for "re-issue the token with Purple AI permission" vs "the tenant isn't licensed for Purple").189- `teamToken` and `accountId` in the request body are UI-session artifacts; empty strings are accepted for API-token auth.190191## Querying logs via the mgmt console API — the foolproof procedure192193Every time somebody rolls their own `requests.post(...)` for a PowerQuery, one of the same six things goes wrong: wrong auth prefix, wrong endpoint path, missing `tenant: true`, missing `X-Dataset-Query-Forward-Tag`, no retry on transient 5xx, or 0 rows and the wrong debugging reflex. The fix is: do not hand-roll the call. Use `scripts/pq.py`.194195### Step 0 — pick the right surface before you write a query196197| The user wants… | Use | Why |198|---|---|---|199| Raw event telemetry (EDR, third-party logs, SDL data) | **`scripts/pq.py`** (LRQ PowerQuery) | This is what SDL/PowerQuery is for. All `dataSource.*`, `event.*`, `src.process.*`, `tgt.file.*`, `i.scheme="edr"` filters. |200| Triage/filter/note/status on an existing alert | `scripts/unified_alerts.py` (UAM GraphQL) | Alerts are entities, not log events. UAM filter syntax is GraphQL `FilterInput`, NOT PowerQuery. Do not confuse the two. |201| Legacy STAR/cloud-detection alert REST shape | `/web/api/v2.1/cloud-detection/alerts` | Only when you need `agentDetectionInfo` / `sourceProcess` etc. Otherwise UAM. |202| A console entity — threat, agent, site, policy, IOC, group | REST via `s1_client.py` | Not a log query. `GET /web/api/v2.1/{threats,agents,sites,...}`. |203| Natural-language hunt that can be hand-reviewed | `purple_ai.purple_query(...)` then LRQ-execute the returned PQ | Purple generates PQ text; `pq.py` runs it. |204205If the user names a vendor ("Prompt Security", "Zscaler", "Okta", "FortiGate") and says "query" or "search logs", that is always the PQ path, never UAM filter syntax.206207### Step 1 — use `scripts/pq.py`, not inline `requests`208209```python210import sys211sys.path.insert(0, "scripts")212from s1_client import S1Client213from pq import run_pq, list_data_sources, PQError214215c = S1Client()216217# One call. Handles launch, polling, forward-tag, cancel, retry, the lot.218res = run_pq(219 c,220 "dataSource.name = 'Prompt Security' "221 "| group ct = count() by event.type "222 "| sort -ct "223 "| limit 50",224 hours=24,225)226print(res["matchCount"], "events ->", res["row_count"], "rows")227for row in res["rows"]:228 print(row)229```230231The helper does ALL of this for you, so there is nothing to remember:232233- `Authorization: Bearer <jwt>` (flipped from the REST `ApiToken` prefix; same JWT, different scheme).234- `POST /sdl/v2/api/queries` on the tenant console host. **NOT** `/web/api/v2.1/sdl/v2/api/queries`, **NOT** `xdr.us1.sentinelone.net`. Do not "fix" a 404 by adding `/web/api/v2.1` — that path does not exist; the fix is the shorter path.235- Captures `X-Dataset-Query-Forward-Tag` from the POST response and echoes it on every GET / DELETE (mandatory for shard routing; without it you get rejections).236- Sets `queryType: "PQ"`, `tenant: true`, `pq: {query, resultType: "TABLE"}` (omit `tenant` and you silently get `matchCount=0`).237- Polls at 1s (query expires 30s after the last poll — slower polling means you lose the query).238- Retries 5xx / 429 / connection errors with exponential backoff. Honors `Retry-After`. The DNS-cache-overflow 503s behind some egress proxies are exactly what this is for.239- Cancels on every exit path (success, deadline, failure) to release the per-account concurrent-query budget.240241### Step 2 — if you get 0 rows, follow the ladder, do NOT widen the window first242243`run_pq` returning `row_count=0` has an ordered diagnostic. Burning time by widening the window first is the most common failure mode; the window is almost never the cause.2442451. **Enumerate the data sources.** If your filter names a vendor / product, first confirm it exists on THIS tenant and you have the string right. Spelling, case, and punctuation matter — the filter is a literal string match.246247 ```python248 sources = list_data_sources(c, hours=24)249 for s in sources[:30]:250 print(s["dataSource.name"], s["dataSource.category"], s["ct"])251 ```252253 If "Prompt Security" isn't in the list, the tenant isn't ingesting it — no amount of widening the window will help. If it's there under a different spelling (`"PromptSecurity"`, `"Prompt Sec"`), use the exact string.2542. **Compare `matchCount` vs `row_count`.** `matchCount=0` means the initial filter discarded everything before any aggregation — the filter is too tight (or naming the wrong thing). `matchCount > 0` with `row_count = 0` means a post-pipe stage (`| group`, `| filter after group`) ate the rows — inspect the pipe.2553. **Only after the above come back clean**, widen the time window — in that order: 24h → 7d → 30d.256257### Step 3 — for large windows / heavy aggregates, slice258259For ranges past 2-3 days with `event.type=*`-scale aggregates, slice the window and run slices in parallel. Full reference, measured perf (30d 574M-event aggregate lands in ~29s with two service-user JWTs), and the two-JWT runner recipe are in the `sentinelone-powerquery` skill at `references/lrq-api.md`. `run_pq` is the single-slice primitive underneath.260261### Step 3a — timeseries: DO NOT use `timebucket(...)`262263The PQ engine does not expose a `timebucket` function. Any pipeline of the form `| group n=count() by timebucket('1d'), action` fails with HTTP 500 `"undefined field 'timebucket'"`. The fix is client-side day slicing:264265```python266from datetime import datetime, timedelta, timezone267import concurrent.futures as cf268269def slice_day(c, base, start, end):270 iso = lambda t: t.strftime("%Y-%m-%dT%H:%M:%SZ")271 return run_pq(c, base + " | group n=count() by action | sort -n",272 start_time=iso(start), end_time=iso(end),273 poll_deadline_s=90)274275end = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)276days = [(end - timedelta(days=i+1), end - timedelta(days=i)) for i in range(7)]277with cf.ThreadPoolExecutor(max_workers=3) as ex: # 3rps user cap278 results = list(ex.map(lambda se: slice_day(c, base, *se), days))279```2802817 daily slices run in ~20s wall-clock (vs ~2 min for a 7d aggregate) and respect the per-user 3 rps cap. For hourly buckets over a 24h window use 24 slices at the same concurrency; for 30d use hourly slicing with 2 JWTs (see `sentinelone-powerquery` skill).282283### Step 3b — window-scaling playbook (performance by period)284285| Window | Recommended runner | Why |286|---|---|---|287| seconds to 1h | single `run_pq(hours=1)` | server returns in <5s |288| 1h to 24h | single `run_pq(hours=24)` | 5-30s depending on filter selectivity |289| 24h to 7d | single call OK for selective filters; for `event.type=*`-scale aggregates, 7 x 1d slices in parallel (max_workers=3) | single-call ~2 min; sliced ~20s |290| 7d to 30d | mandatory slicing (daily buckets) + 2 JWTs | two-JWT runner in `sentinelone-powerquery` |291| 30d+ | hourly slicing + 2-3 JWTs, cache results | 574M-event aggregate at 30d = ~29s with two JWTs |292293### Step 3c — LRQ response-shape gotchas (handled by `run_pq`)294295If you ever have to read a raw LRQ response (e.g. debugging), know:296297- `columns` is a list of dicts `{name, cellType, decimalPlaces}`, not a list of strings. Zipping values by `col["name"]` (not `str(col)`) is mandatory.298- `matchCount` lives inside the `data` block (`response["data"]["matchCount"]`), not at top level. Default to that path; fall back to top-level for older engines.299- `values` is an array of arrays (one per row); `run_pq` pairs it with column names for you.300301### Step 4 — when NOT to use `pq.py`302303- If the user said "purple mcp" or "mcp", defer to `mcp__purple-mcp__powerquery` first; this is the backup path when the MCP times out or 5xxs.304- If the user is working with alerts as entities (listing, filtering, note, status), that's UAM GraphQL (`unified_alerts.py`), not PowerQuery. UAM filter syntax is `[{fieldId, stringEqual: {...}}]`; it is NOT PowerQuery `| filter` syntax. Mixing them is a common trap in screenshot-driven debugging.305306### Checklist before running a PQ programmatically307308- [ ] You called `run_pq` / `list_data_sources`, not inline `requests.post`.309- [ ] Base URL is the tenant console (e.g. `https://usea1-purple.sentinelone.net`), not `xdr.us1.sentinelone.net`.310- [ ] Endpoint path is `/sdl/v2/api/queries` (short form). If you see 404s, do NOT add `/web/api/v2.1` — that's wrong.311- [ ] If you're filtering on EDR data (`src.process.*`, `event.type=*`, `tgt.file.*`), prepend `dataSource.name='SentinelOne' dataSource.category='security'` — on mixed tenants the default scope carries Scalyr/infra logs too and wide filters silently return `matchCount=0`.312- [ ] 0 rows → ran `list_data_sources` and checked `matchCount` vs `row_count` BEFORE widening the window.313314## Unified Alert Management (UAM) — PRIMARY alert API315316> **Alert API precedence — important:**317> 1. **PRIMARY — GraphQL UAM** at `POST /web/api/v2.1/unifiedalerts/graphql`. This is the modern, multi-source alerts inbox (EDR, XDR, Identity, STAR, Cloud, NGFW, and ingested third-party telemetry). IDs are UUIDs (e.g. `019db24c-8b6d-7451-8697-b1b2e1a270f1`). Use this for any alert listing, filtering, triage, note, status, verdict, assignment, group-by, facet, or CSV-export task.318> 2. **SECONDARY — REST** at `GET /web/api/v2.1/cloud-detection/alerts`. Older surface, scoped to cloud-detection events (STAR rule hits, EDR overflow). IDs are int64 (e.g. `2055164731151448891`). Use only when you specifically need the denormalized REST payload (`agentDetectionInfo`, `sourceProcess`, `targetProcess`, `ruleInfo`) or when UAM is unavailable. These are **parallel surfaces, not redundant** — the same alert will have different IDs in each.319> 3. **No `createAlert`.** S1 does not expose a mutation for creating alerts directly. Alerts are server-side byproducts of detection engines — create a STAR/Custom Detection rule (`POST /web/api/v2.1/cloud-detection/rules`), upload an IOC that matches live telemetry (`POST /web/api/v2.1/threat-intelligence/iocs`), or generate synthetic endpoint activity. `addAlertNote` is the closest reversible content-creation path against an existing alert.320321Auth is the same `Authorization: ApiToken` header as REST; no extra credentials. Full reference in `references/UNIFIED_ALERTS.md`. The end-to-end dual-API round-trip test is `tests/test_alerts_dual_api.py`.322323Use UAM whenever the user is working with *alerts* as first-class entities — triaging, filtering, adding notes, resolving, bulk-assigning — rather than the older `GET /web/api/v2.1/threats` surface.324325```python326import sys327sys.path.insert(0, "scripts")328from s1_client import S1Client329import unified_alerts as uam330331c = S1Client()332333# discover: fieldIds, enum values, which views have data334cols = uam.column_metadata(c)335avail = uam.view_data_availability(c)336337# triage: top 20 NEW CRITICAL EDR alerts from the last day338page = uam.list_alerts(c, filters=[339 uam.build_filter(fieldId="detectionProduct", stringEqual={"value": "EDR"}),340 uam.build_filter(fieldId="status", stringEqual={"value": "NEW"}),341 uam.build_filter(fieldId="severity", stringEqual={"value": "CRITICAL"}),342], first=20)343344# act: bulk resolve a specific list of alerts, with a note345account = uam.scope(["<account_id>"])346uam.set_alert_status(347 c, scope_input=account,348 alert_ids=["<alert1>", "<alert2>"],349 status="RESOLVED",350 note="Auto-closed: part of campaign tracked in JIRA-1234",351)352```353354CLI equivalents:355356```357python scripts/call_unified_alerts.py list --filter detectionProduct=EDR --first 20358python scripts/call_unified_alerts.py facets status severity detectionProduct359python scripts/call_unified_alerts.py notes <alert-id>360python scripts/call_unified_alerts.py add-note <alert-id> "Investigating"361python scripts/call_unified_alerts.py set-status --scope <account-id> --alert-id <id1> <id2> RESOLVED --note "..."362python scripts/call_unified_alerts.py csv-export --filter severity=CRITICAL -o crit.csv363```364365### UAM domain — what belongs here vs REST366367UAM owns everything in the modern Alerts inbox, including alert notes, alert history, mitigation results, trigger-actions, and Cursor-paginated group-by / facet views. The older `/web/api/v2.1/threats` REST surface still exists and covers the classic endpoint-protection threat lifecycle — when the user says "alerts", "unified alerts", "alert notes", or mentions XDR / multi-source detections, route to UAM. When they say "threat", "threat group", "incident", or reference `/threats`, stay on REST.368369### Important quirks (hidden by the wrapper, but mind them if writing raw GraphQL)370371- The `alerts` query takes a flat `filters: [FilterInput!]` (AND-joined); mutations and `alertAvailableActions` take `filter: OrFilterSelectionInput` shaped as `{ or: [{ and: [FilterInput, ...] }, ...] }`. Mixing these up is a validation error. The wrapper exposes `build_filter(...)`, `or_filter(...)`, and `scope(...)` helpers so callers don't have to hand-assemble them.372- `updateAlertNote` and `deleteAlertNote` fail for ~30–90s after a note is freshly created (`"Alert Note with ID ... does not have mgmt_note_id set, unable to [edit|delete], try again later!"`) because the management-console backend is still propagating an internal id. The wrapper retries automatically with backoff — callers don't need to sleep.373- `aiInvestigations` has no `data` wrapper, but `alertNotes` / `alertFiltersCount` / `alertGroupByCount` / `alertAvailableActions` / `alertMitigationActionResults` / CSV exports all do. `alerts` / `alertHistory` / `alertTimeline` / `alertGroups` use connection shape (`edges`/`pageInfo`/`totalCount`).374- Full list of traps — including the `SortOrderType` enum name, `alertGroupByCount` using `limit` (not `first`), subselection requirements on `CsvResponse` / `ActionsError`, and the actual shape of `alertsViewDataAvailability` — is in `references/UNIFIED_ALERTS.md` under "Schema quirks".375376### Destructive actions — blast radius377378`alertTriggerActions` is the single mutation that can touch many alerts at once. Passing `filter: null` means *every alert in scope* — potentially hundreds of thousands. The safe pattern is the same as REST bulk actions:3793801. Use `list_alerts(..., first=1)` with the proposed filter and read `totalCount`.3812. Show the user the exact filter + action list + count.3823. Only after explicit confirmation, call `trigger_actions(...)` or one of the `set_alert_status` / `set_analyst_verdict` / `assign_alerts` convenience wrappers (all of which constrain the filter to an explicit alert-id list by default).383384## UAM Alert Interface (Unified Alert Management) -- pushing OCSF indicators + alerts INTO UAM385386Everything else in this skill talks to `<tenant>.sentinelone.net/web/api/v2.1/...` (the Mgmt Console) and is read-or-mutate on pre-existing server state. The **UAM Alert Interface** (formerly "Ingestion Gateway") is a separate API family on a separate host for the write-side path: it lets you push OCSF-formatted indicators and alerts INTO UAM so they show up in the console as real alerts with attached indicators. Use it when a user asks to "create an alert", "ingest indicators", "send alerts from my pipeline", or "test alert ingestion".387388**Host and wire contract:**389390- Prod (US1): `https://ingest.us1.sentinelone.net`. Override via `uam_alert_interface_url` in `config.json` or the `--uam-url` flag / `S1_UAM_ALERT_INTERFACE_URL` env var. The legacy config key `ingestion_gateway_url` and env var `S1_IGW_URL` are still honored as fallbacks.391- Auth: `Authorization: Bearer <JWT>`. NOT `ApiToken`. The mgmt-console JWT in `config.json` works; the endpoint rejects `ApiToken ...` with HTTP 401 `"Unsupported auth type"`.392- Body: concatenated JSON (one or more objects back-to-back, optionally newline-separated), gzip-compressed. `Content-Encoding: gzip` is mandatory. zstd also accepted.393- Scope: `S1-Scope: <accountId>` or `<accountId>:<siteId>[:<groupId>]` is mandatory.394- Success shape: `202 Accepted` with `{"details":"Success","status":202}`.395396**Endpoints:**397398- `POST /v1/indicators` -- raw behavioral indicators. Each must carry `metadata.profiles = ["s1/security_indicator"]` and a unique `metadata.uid` (this is the join key). Batching: send many indicators in one call by passing a list; the client concatenates + gzips.399- `POST /v1/alerts` -- SecurityAlert wrappers. Each references its indicator(s) via `finding_info.related_events[].uid == indicator.metadata.uid`. A single alert can reference multiple indicators (one entry per indicator). The server stitches them into `alert.rawIndicators` / the UAM Indicators tab once both land. **Call with ONE alert per POST.** The wire format accepts multi-alert bodies and the gateway returns HTTP 202, but the stitcher silently drops all but one alert in a multi-alert batch (usea1-purple 2026-04-22); loop one at a time, or use `post_alert_with_indicators` which enforces the safe pattern.400401**Supported indicator classes (via builders):**402403- `build_file_indicator(...)` -- OCSF class 1001 FileSystem Activity. Observables: Hostname, File Name, Hash (SHA-256/MD5), User Name, IP Address.404- `build_process_indicator(...)` -- OCSF class 1007 Process Activity. Observables: Hostname, Process Name, Resource UID (pid), User Name, IP Address, plus parent process.405- `build_network_indicator(...)` -- OCSF class 4001 Network Activity. Observables: Hostname, src/dst IP Address, URL, User Name.406407**Python usage:**408409```python410import sys, time, uuid411sys.path.insert(0, "scripts")412from s1_client import S1Client413from uam_alert_interface import (414 UAMAlertInterfaceClient,415 build_file_indicator, build_process_indicator, build_network_indicator,416 build_alert_referencing,417)418419mgmt = S1Client()420uam_iface = UAMAlertInterfaceClient(bearer_token=mgmt.api_token)421422now_ms = int(time.time() * 1000)423ind_uid_a, ind_uid_b, alert_uid = (str(uuid.uuid4()) for _ in range(3))424425ind_a = build_file_indicator(426 indicator_uid=ind_uid_a, file_name="payload.iso",427 file_sha256="0"*64, device_uid=str(uuid.uuid4()),428 device_hostname="host-1", device_ip="192.0.2.10",429 user_uid=str(uuid.uuid4()), now_ms=now_ms,430)431ind_b = build_process_indicator(432 indicator_uid=ind_uid_b, process_name="powershell.exe",433 process_pid=4242, process_cmd_line="powershell -enc ...",434 parent_process_name="explorer.exe",435 device_uid=str(uuid.uuid4()), device_hostname="host-1",436 user_uid=str(uuid.uuid4()), now_ms=now_ms,437)438alert = build_alert_referencing(439 alert_uid=alert_uid, indicators=[ind_a, ind_b], now_ms=now_ms,440 title="Ingested alert", description="...",441)442443# Preferred safe path. Posts the indicators, sleeps 3s (so each444# metadata.uid registers before the stitcher resolves related_events),445# then posts the single alert. For many alerts, LOOP this call -- do446# NOT pass multiple alerts to post_alerts() in one go (see constraints447# below).448uam_iface.post_alert_with_indicators(449 alert, [ind_a, ind_b], scope=f"{account_id}:{site_id}")450# Then poll UAM GraphQL (unified_alerts.list_alerts) to see it surface.451```452453**Validation:** after ingest, find the alert via UAM GraphQL (`unified_alerts.list_alerts` filtered by name, or `get_alert(alert_id)` once you know it). `get_alert_with_raw_indicators(c, alert_id)` returns the raw indicator dict(s) so you can confirm every `metadata.uid` and its observable names made it through.454455**Cleanup:** ingested alerts are not hard-deletable via public API. The standard reversibility pattern is to set `status=RESOLVED` and `analystVerdict=TRUE_POSITIVE_BENIGN` via the bulk-ops mutations in `unified_alerts` so the alert exits the active SOC queue and is tagged as synthetic.456457**Multi-indicator alert constraints** (empirically confirmed on458`usea1-purple` 2026-04-22):459460- **One alert per `POST /v1/alerts` call.** The wire format accepts461 concatenated JSON for N alerts in one body and the gateway returns462 HTTP 202, but the stitcher silently drops all but one of the alerts.463 Callers with many alerts MUST loop. `post_alerts` emits a464 `RuntimeWarning` when `len(alerts) > 1` to flag the hazard. Use465 `post_alert_with_indicators(alert, indicators, ...)` for the safe466 one-at-a-time path.467- **Sleep between `POST /v1/indicators` and `POST /v1/alerts`.** If468 the alert is posted immediately after its indicators, the stitcher469 can resolve `finding_info.related_events[].uid` before the indicator's470 `metadata.uid` is registered on the scope and silently drop the alert471 (HTTP 202 still returned). A ~3s sleep between the two POSTs avoids472 this; reducing below ~2s has been observed to regress on loaded473 tenants. `post_alert_with_indicators` builds the sleep in; callers474 using the low-level `post_indicators` + `post_alerts` path MUST add475 it manually. `test_uam_alert_interface_batch.py` encodes this exact476 sequence.477- Alerts with multiple `resources[]` entries (i.e. indicators spanning478 different `device.uid` values) are silently dropped by the stitcher.479 Return: HTTP 202 at the wire, NEVER surfaces in UAM. The builder480 collapses to a single `resources[]` entry (first indicator's device)481 to avoid this. If you truly need per-indicator assets, emit separate482 alerts.483- Each `finding_info.related_events[]` entry MUST carry `class_uid`,484 `type_uid`, `category_uid`, `activity_id`, `severity_id`, `time`,485 `message`, and enriched `observables[]` (each with `type` +486 `typeName` alongside `type_id`/`name`/`value`). `build_alert_referencing()`487 populates all of these. Omitting any of them tends to cause the488 stitcher to silently drop the alert.489- **`file.hashes` MUST be an OCSF Fingerprint array, not a dict.**490 OCSF 1.6.0 defines `file.hashes` as `Array of Fingerprint objects`:491 `[{"algorithm_id": 3, "algorithm": "SHA-256", "value": "<hex>"}, ...]`.492 Posting `{"sha256": "<hex>"}` (dict form) causes the stitcher to493 silently drop the file indicator even though POST returns 202.494 `build_file_indicator()` emits the correct array shape; custom495 payload builders must follow the same convention (algorithm_id 2=MD5,496 3=SHA-256, 4=SHA-1, 5=SHA-512).497- Multi498499…(truncated)