Skill v1.0.0
currentLLM-judged scan90/100version: "1.0.0" name: powerquery author: Prithvi Moses <prithvi.moses@sentinelone.com> description: >- Use any time the user wants to author, debug, optimize, explain, or run a SentinelOne PowerQuery (PQ): Deep Visibility / Event Search queries, XDR/EDR threat hunting, investigations, STAR / Custom Detection rule bodies, PowerQuery Alerts, or Singularity Data Lake dashboard panels. Trigger on PowerQuery, PQ, pq, query, Event Search, Deep Visibility, S1QL, SDL, STAR rule, Custom Detection rule, PowerQuery Alert; on queries using fields like event.type, src.process.*, tgt.file.*, indicator.*, agent.uuid; on pipes like | group, | filter, | let, | join, | parse, | columns, | compare, | top, | union, | lookup, | savelookup, | dataset. Also trigger when asked to hunt a TTP, IOC, behaviour, or alert pattern on a SentinelOne tenant, even casually ("find powershell reaching out to the internet", "write a detection for lsass access"). Explicitly NOT Microsoft Power Query / M / Excel and NOT Splunk SPL; this is SentinelOne's pipeline query language for security telemetry.
SentinelOne PowerQuery
<!-- CONFIG-FILE-ADDRESSING v1 -->
SDL config files: address by `udoId`, and do not trust a REST listing.RESTlistFiles/getFilecannot see udoId-addressed/dashboards/files, sogetFilereturns404on a dashboard the console is displaying and the listing under-reports (measured onone tenant: REST 8, GraphQL 17, console 48 files). **If a listing disagrees with what the UIshows, the listing is wrong until proven otherwise**, change read path before concluding theobject is missing or the token lacks scope. Use the GraphQLconfigFiles/configFilesurface.A name-addressedaddConfigFileto/dashboards/creates a duplicate instead of updating;address dashboards byudoIdwithexpectedVersion.contentis HJSON, not JSON.S1-Scopechanges which files exist as far as the caller can tell.Full detail: `sdl-api/references/config-file-graphql.md`
PowerQuery (PQ) is SentinelOne's pipeline query language for the Singularity Data Lake. It reads like filter | command | command | …, events that match the initial filter flow through a sequence of piped transformations (group, let, join, sort, columns, etc.).
Use this skill to write correct, efficient, runnable PowerQueries for threat hunting, investigations, detection rule bodies, and dashboards.
Run queries with the `s1-secops-mcp` MCP tools.powerquery_runruns any PQ through the LRQ API (plusqueryType: "LOG"for raw events,slices/mergefor long windows,outputFilefor bulk results),powerquery_enumerate_sourceslists data sources, andpowerquery_schema_discoversamples a source's fields. The server runs on the user's machine, so it reaches*.sentinelone.netwhere the Cowork sandbox cannot, and it reads credentials from environment variables or the OS keychain. If the tools are missing, point the user to the s1-secops-mcp README (s1-secops-mcp/README.mdin the s1-secops-skills source repo,mcp/s1-secops-mcp/README.mdin ai-siem; not shipped inside the plugin). Python runners are host-only (Claude Code or a terminal).
Workflow
When the user asks you to write or investigate with a PowerQuery:
- Clarify the intent if it's ambiguous (time range, data view, what the output should look like). A good PQ is scoped, not everything needs to be hunted over 30 days.
- Draft the query following the grammar below. Favor
filter | group | sort | limit | columnsas the default shape; it's what most real investigations need. - Run it against the tenant with
powerquery_run, which uses the Long Running Query (LRQ) API atPOST /sdl/v2/api/querieson the tenant's console URL. LRQ is the fastest, highest-limit, most reliable path for any programmatic use and supersedes both/api/powerQueryand the Deep Visibility/dv/events/pqendpoint (both deprecated; sunset Feb 15 2027). It is async and parallelizes across time slices (slicesplusmerge). Reach for the Purple MCPpowerquerytool only for a single quick exploratory check. See "Running queries (LRQ API by default)" below andreferences/lrq-api.mdfor the body schema, auth, rate limits, and the gotchas that make it fail silently with 0 rows. If the user's request is clear and low-risk (read-only query), just run it; don't ask permission. - Iterate: if the query errors or returns obviously wrong results, read the error, fix, rerun. If the query returns nothing, that is a legitimate result, don't blindly loosen it; check the time range and filter logic first. If you ran via the Purple MCP
powerquerytool and it timed out or returned a server error (common for anything past 24h or with wide initial filters), don't retry and don't shrink the range to fit the MCP budget - switch topowerquery_run(see "Fallback" under Running queries below). - Explain the result briefly and cite any fields you're relying on. If you used a non-obvious pattern (subquery,
savelookup,transpose,compare), explain why you chose it.
The grammar in one page
initial-filter-expression| command| command| …
Initial filter (everything before the first |) is the only place where * contains "x" and * matches "regex" multi-field search works. It can be empty, start the query with | and it is treated as "all events" (e.g., | group ct=count() by event.type).
Commands (each starts with |):
filter expr: keep matching rows (initial filter implicit)columns f1, "Renamed f2"=f2, …, select, rename, compute output columns (creates a new record set, previous fields not accessible after)let f = expr, …: add computed fields without discarding existing onesgroup agg(x), name2=agg2(y) by f1, "grouped name"=f2: aggregate; also creates a new record setsort +f1, -f2:-= descendinglimit N: truncate (default shows 10 without it; output is capped at 1,000 rows if nolimit/group)parse "…$field$…" from srcField: extract fields from unstructured textlookup col, … from tableName by key=expr: join against a CSV/JSON config data table| dataset 'config://datatables/<name>': read a lookup table as the source of the pipeline (leading|required)datasource <name> [from <dataset>]: read SentinelOne-managed inventory outside the event store (assets, alerts, vulnerabilities, misconfigurations, metering); the only PQ path to the Asset Inventory / AD identity attributes. Seereferences/datasource-command.mdsavelookup 'tableName'[, 'merge']: persist current result as a reusable lookup table| [inner|left|outer|sql inner|sql left|sql outer] join (q1), (q2), … on k1, a.x = b.y: correlate subqueries (must start| join, not justjoin)| union (q1), (q2), …: merge heterogeneous result sets (up to 10 queries; use whenfilter (…or…)can't express it)| transpose colName on keyCol, …: pivot a column into many columns (must be LAST command)| compare [name=]timeshift('-1w'): re-run the same query shifted in time and put both in one table (must be LAST command; only onecompareallowed)| top K agg(x) by f1, f2: probabilistic top-N (fast on huge ranges;count()/sum()are "(estimated)",min/maxexact)| nolimit: raise the row cap to 3 GB (slow; one concurrent nolimit query at a time; never use in Dashboards or PowerQuery Alerts)
Expressions use standard operators: = / == / !=, < / <= / > / >=, && / || / ! (or AND / OR / NOT), ternary a ? b : c (put spaces around the :), arithmetic + - * / %, and these text operators:
| Operator | Meaning | |
|---|---|---|
x contains 'sub' | substring (case-insensitive): also contains ('a','b','c') for OR | |
x contains:matchcase 'Sub' | case-sensitive substring | |
x matches 'regex' | regex (case-insensitive, double-escape): matches ('a','b') for OR | |
x matches:matchcase '…' | case-sensitive regex | |
x in ('a','b',123,true) | exact equals any; case-sensitive; in:anycase for case-insensitive; does NOT match null | |
any(a, b) in ('x') / any(a, b) = 'x' / all(a, b) contains 'x' | one predicate across several fields (filter stage only, not in let) | |
x starts_with 'p' / x ends_with 's' | prefix / suffix (case-insensitive; :matchcase('P') for case-sensitive) | |
!(x contains 'y') | negation: ALWAYS parenthesise; NOT x contains 'y' silently returns 0 rows | |
x = * | field is present/non-null | |
!(x = *) | field is null/missing | |
$"regex" | shorthand for message matches "regex" (initial filter only) | |
#shortcut = 'value' | pre-defined multi-field shortcut (e.g., #ip, #hash, #name, #cmdline, #storylineid, #username) |
Strings need quotes ('foo' or "foo"); numbers and booleans don't. Underscores in numbers are OK for readability (1_000_000).
BANNED functions: do not use, ever
These function names do not exist in PowerQuery. Using any of them produces Unknown function '<name>' (HTTP 500). Do not invent plausible-sounding names, if a function isn't in references/functions-reference.md, it doesn't exist.
| Do NOT write | Write this instead | |
|---|---|---|
formattime(...) | strftime(ts) / strftime(ts, pattern) | |
formatdate(...) | strftime(ts) / simpledateformat(ts, pattern) | |
floor_time(...) | bucket=timebucket(unit) in group by | |
date_trunc(...) | timebucket(unit) | |
coalesce(a, b) | a ? a : b (bare-field ternary) | |
ifnull(a, b) | a ? a : b | |
if(cond, a, b) inside aggregates | count(predicate) | |
percentile(x, N) | p50(x) / p95(x) / p99(x) | |
first(x) / last(x) | min_by(x, timestamp) / max_by(x, timestamp) | |
sort count desc / sort field asc | sort -count / sort +field: PowerQuery uses -/+ prefix, NOT SQL-style desc/asc. Using desc or asc causes HTTP 500 "Unable to parse the entire query". Purple AI frequently generates this wrong; always correct before running. | |
` field.name ` (backtick-quoted identifiers) | field.name: dotted field names are written bare, no backticks. Using backtick quoting returns HTTP 500 "Don't understand [`]". |
The only valid date/time functions are: strftime, simpledateformat, strptime, simpledateparse, timebucket, querystart, queryend, queryspan.
The most important rules (learned the hard way)
These are where queries go wrong. Internalize them before writing.
- `*` alone is NOT a valid initial filter.
* | limit 5returns a 500 error ("Don't understand []"). There are three distinct `` idioms, pick the right one for your intent:
- Field presence / attribute wildcard:
dataSource.name=*means "field is present/non-null". Use as a query-opener for aggregations, e.g.dataSource.name=* | group count=count() by dataSource.name. This is NOT an all-column search. - All-column text search:
* contains 'evil.com'or* matches 'regex'in the initial filter (before the first|) searches ALL indexed fields; use when you need to find text anywhere in the event. Dramatically faster thanmessage contains. Only works before the first|; not valid in| filter …after a pipe, and not valid in Alerts. - Empty filter (all events): start the query with
|, e.g.| group ct=count() by event.type.
- Double-escape regex almost everywhere.
src.process.cmdline matches "\\d+",tgt.file.path matches '^C:\\\\Windows\\\\Temp\\\\[a-z]{8}\\.tmp$'. The only place you don't double-escape is the$"…"shorthand (searchesmessage). - Regex lazy quantifiers (`?`) are not supported. The SDL regex engine does not support lazy (non-greedy) quantifiers:
.*?,.+?,[^x]*?etc. all return HTTP 500 "Dangling meta character '?'". Use a negated character class instead:[^"]*in place of.*?",[^ ]*in place of.*?, etc. - After `columns` or `group`, previous fields are gone. These commands create an entirely new record set. If you'll need a field later, carry it through:
group ct=count(), host=any(endpoint.name) by src.process.storyline.id; don't expectendpoint.nameto still be addressable after thatgroupunless you aggregate it. - Subqueries can't go after `group`, `sort`, or `limit`. And the subquery must itself produce the column named in the
in (...)expression (viacolumnsorgroup, an alias counts).user in (action='login' | group 1 by user)is valid;user in (action='login')is not. Subqueries match one raw field only (any(a, b) in (subquery)is HTTP 400Subquery requires a literal field name): on aletfield they silently return 0 rows, they never match null, and!(x in (<empty inner>))returns every row. Details inreferences/commands-reference.md§14. - `compare` and `transpose` must be the LAST command. Put
sortbeforecompareif you want to order the non-shifted side. - `join` must start with a pipe.
| join (…), (…) on x, without the|, "join" is interpreted as a search term. Inner/left joins allow up to 10 subqueries;sql innerandsql leftallow only 2. - `null` behaves like false in boolean context.
filter x = nullworks after the field is defined by a prior command; before then, use!(x = *)for is-null andx = *for is-not-null. - `contains` is case-insensitive by default; `in` is case-sensitive by default. The
:matchcase/:anycasesuffixes reverse this. - Performance: filter early, group narrow. Push filters above the first pipe when possible. In
group, prefer low-cardinality fields; for long ranges, consider| top K …instead (probabilistic but orders of magnitude faster). - Alerts and Dashboards have tighter limits. A PowerQuery Alert is capped at 1,000 rows intermediate / 1 MB RAM. Don't put
nolimitin a dashboard panel. - Shortcut fields (`#cmdline`, `#name`, `#hash`, …) don't work as initial filters on every tenant. They're documented but return 500 on many deployments. Prefer explicit field names (
src.process.cmdline contains 'x'); they're as terse and always work. Save shortcuts for exploratory Event Search where you're not scripting against the API. - Aggregates to prefer: `min_by` / `max_by` over `first` / `last`.
first(x)andlast(x)are sometimes listed as aggregates but fail on many tenants. Usemin_by(x, timestamp)andmax_by(x, timestamp); they're explicit about ordering and always work. - Percentiles: use `p50`/`p95`/`p99`, not `percentile(x, N)`. The latter isn't a real function and returns 500.
- Null-filter at the wrong stage: `filter x = null` before `x` is computed returns 500. Use
filter !(x =*)for is-null until after alet/join/lookuphas producedx. - Coalesce-style fallback uses bare-field truthy test, NOT `(field = *) ? a : b`. PQ has no
coalesce()/ifnull()/nvl(). To pick the first non-null of several fields inside alet, chain bare-field ternaries; they evaluate the field's truthiness directly:
```text
? actor.user.email_addr : (actor.user.name ? actor.user.name : src.process.user) ```
The (field = *) ? a : b form (i.e. wrapping the field-presence test in parens before the ternary) returns HTTP 500 inside `let` on this engine, field = * is a filter operator, not a boolean expression usable in computed columns. Bare-field truthy is the only working coalesce idiom in PQ.
- `if(...)` is not a function in aggregates.
sum(if(cond, 1, 0))returns 500. Usecount(<predicate>)instead,count(severity_id == 5)evaluates the predicate per row and sums the truthy ones. Same for any "count where X" semantic. Field-presence counting uses `!= null`, not `= *`:count(user.name=*)returns 400 "Don't understand []" because `=is a filter operator, whilecount(user.name != null)works (live-verified 2026-10-03: 1,000 of 1,016 Okta events). Several presence counts in one pass:| group n = count(), a = count(f1 != null), b = count(f2 != null)` (re-verified 2026-10-14). - Always filter `field=*` before projecting or inspecting any field.
| limit N | columns fieldreturns the first N events regardless of whether the field is populated, most rows will be null. Addfield=*to the initial filter to scope to events that actually carry the field:
```text // Wrong: returns nulls; message may not be present on most events dataSource.name='FortiGate' | limit 3 | columns message
// Correct: only events where message is present dataSource.name='FortiGate' message=* | limit 3 | columns message ```
This applies to every field, not just message. Any time you want to sample, inspect, or aggregate a field, include field=* in the initial filter.
- Statistical baselining is two queries plus a client-side merge, not one inline join. Subqueries inside a single
| join, andin (...)subqueries, share the parent query's time range. To compare a 24h live window against a 7d/30d baseline, run them as separate LRQs (or as separatesavelookup+lookuprounds) and merge; there is no single-pass form. Pattern inexamples/behavioral-baselines.md.
- Parenthesise every negation.
NOT src.process.name contains 'x'andNOT src.process.name = 'x'apply the negation to the bare field and silently return 0 rows. Write!(src.process.name contains 'x'). Likewise| filter xon a bare field or variable is a text search, not a truthiness test; use| filter bool(x).
- Exclude SDL ingest-metering rows: `tag != 'logVolume'` in the initial filter. Every ingest writes receive-time accounting rows (
tag='logVolume', fieldsmetric/value/path1, plussca:bytesToChargeandsca:ingestTime) under the source's owndataSource.name. Any query anchored only ondataSource.name,dataSource.vendor,site.idorsca:*counts them, and a silent source looks live (measured 2026-10-03: 7,390 of 383,044 rows in 12h, all carryingsca:bytesToCharge). Queries anchored on an event field (class_uid,event.type, a principal=*) are already immune.powerquery_runandpq.run_pq()add the predicate automatically (opt out withincludeMetering/include_metering=True). For persisted queries (dashboard panels, STAR / scheduled rule bodies) write it yourself, in the initial filter where it is cheapest:dataSource.name='X' tag != 'logVolume' | ....!=keeps rows that have notag, soOR !(tag = *)is not needed in PowerQuery. Omit it only when the question is ingest volume or data usage. The console's XDR view already hides these rows; All Data does not.
- No `round()`. It is an unknown function: HTTP 400 "Unknown function 'round'". Round to one decimal with
floor(x * 10 + 0.5) / 10;floorworks (live-verified 2026-10-14).
- A field cannot be defined twice. Assigning the same name in two
letstages, or twice in onelet, is HTTP 400 "duplicate definition of field 'x'". Use a new name and rename it incolumns:| let x_r = floor(x * 10 + 0.5) / 10 | columns ..., x = x_r. An offline render never catches this, so check panel and rule queries for repeatedletnames before deploying (live-verified 2026-10-14, both forms).
When to delegate baselining + anomaly detection to the mgmt-console-api skill
If the user asks for any of the following, you need MORE than this skill, load the mgmt-console-api skill alongside, because the runner, the schema discovery, and the source-agnostic key picker live there:
- "Baseline behaviour on
<source>" / "establish a baseline" / "build a 7d / 30d baseline" - "Detect anomalies" / "find users / hosts / IPs behaving differently than usual"
- "Spot statistical outliers" / "find spikes vs typical" / "find pairs that went silent"
- Porting any moving-average + stddev / z-score / Prophet / Isolation Forest pattern
- "Run this for all sources" / source-agnostic anomaly detection
What to use:
- Schema discovery:
powerquery_schema_discoverper source, so you don't hand-hardcodeactor.user.email_addrfor every source; pick the principal field from whatever the source actually carries (Okta uses email, FortiGate uses IP, SentinelOne uses process user, etc.). On the host,mgmt-console-apiscripts/inspect_source.pyalso classifies fields intoprincipal_user/principal_host/principal_ip/actionviapick_keys(schema). - Daily slices:
powerquery_runwithslicesandmergefor a summed baseline, or onepowerquery_runcall per day issued in parallel when you need per-day rows for mean and stddev. - Host-only productionised baseliner:
mgmt-console-apiscripts/baseline_anomaly.py(30-day, DoW-stratified, takes adataSource.name, discovers the schema, produces anomalies). Read its source for the canonical end-to-end pattern; run it from Claude Code or a terminal.
Use examples/behavioral-baselines.md in THIS skill for the PQ building blocks (per-day slice, live slice, z-score math, silent-pair detector).
Running queries (LRQ API by default)
The primary execution path is the Long Running Query API, through the powerquery_run MCP tool. LRQ is async (launch + poll + cancel), handles queries that would time out on any other endpoint, scales cleanly to 30-day aggregates, and is the only path that stays supported after Feb 15 2027 when both /api/powerQuery and /web/api/v2.1/dv/events/pq retire.
`powerquery_run` parameters that matter:
| Parameter | Use | ||||
|---|---|---|---|---|---|
query | The PQ. With queryType: "LOG", a filter expression only (no pipes). | ||||
hours or startTime / endTime | Window. ISO-8601 UTC for explicit bounds. | ||||
queryType | "PQ" (default) or "LOG": raw events with every parsed field, server cap logLimit (max 5000) per query or slice; truncatedByServerCap: true means the window held more, so slice it. | ||||
slices + merge | 2-15 parallel time slices for windows past a day. merge: {"keys": [...], "sum": [...], "min": [...], "max": [...]} re-aggregates a group-by; count, sum, min and max only. The tool refuses estimate_distinct, avg, percentiles, top and savelookup across slices. List every group-by column in keys with its exact output name: rows are combined on those keys, and the tool does not check them, so a misspelled or missing key silently collapses rows. A trailing `\ | sort / \ | limit is applied after the merge; any other command after the last \ | group is refused. Without merge`, slice rows are concatenated. | |
maxRows | Caps rows in the inline response (default 1000). With outputFile every row is written. | ||||
edrStrict | Sends top-level scheme: "edr" so an unknown or wrongly cased EDR field returns HTTP 400 instead of silent 0 rows. | ||||
outputFile (+ overwrite) | Absolute path on the user's machine; .csv, .jsonl or JSON. The full result is written there (mode 0600, must be inside S1_OUTPUT_DIRS, default home and temp; under the Docker launcher, inside the mounted S1_OUTPUT_DIR) and only a summary plus a 5-row preview returns. If the server refuses the path because no folder is shared with the container, tell the user to run ~/.local/bin/s1-secops-mcp-launch.sh install --output-dir <folder> (Windows: -OutputDir) and restart Claude Desktop; do not ask them to set S1_OUTPUT_DIRS, which never reaches the container. | ||||
scope | "<accountId>" or "<accountId>:<siteId>"; log reads are scope-filtered. | ||||
includeMetering | Keep SDL tag='logVolume' ingest-metering rows (excluded by default). |
The wire protocol below is what the tool does for you; it matters when you debug a result or write a host-only runner.
Three calls, in order:
POST https://<console>.sentinelone.net/sdl/v2/api/queries -> launch, returns {id, ...}GET https://<console>.sentinelone.net/sdl/v2/api/queries/{id}?lastStepSeen=N -> poll every 1-2sDELETE https://<console>.sentinelone.net/sdl/v2/api/queries/{id} -> cancel when done
Required body fields for a PowerQuery:
{"queryType": "PQ","tenant": true,"startTime": "2026-04-21T00:00:00Z","endTime": "2026-04-22T00:00:00Z","queryPriority": "HIGH","pq": { "query": "<your PQ>", "resultType": "TABLE" }}
Five things that will bite you if you skip them (full list in references/lrq-api.md):
- Auth: `Authorization: Bearer <jwt>`, not
ApiToken. Same JWT from the mgmt console, different prefix. Wrong prefix returns HTTP 500 "Header must start with Bearer". - `queryType` is required. Omit and you get 400 "Query type must be specified".
- `tenant: true` is required unless you pass
accountIds. Without either, the query runs against a near-empty default scope and returnsmatchCount=0. On a global or multi-account tokentenant: truespans EVERY account and theS1-Scopeheader does not narrow it: for one account sendtenant: false, accountIds: ["<accountId>"], for one site also addsite.id='<siteId>'to the initial filter. With the MCP, passscope: "<accountId>[:<siteId>]"topowerquery_run(1.5.3+) for the account or site the user named. - Echo `X-Dataset-Query-Forward-Tag` from the POST response header on every poll and the cancel. GET/DELETE without it is rejected.
- EDR filter for SentinelOne telemetry: prepend
dataSource.name='SentinelOne' dataSource.category='security'(ori.scheme="edr") to your query. Without it you get Scalyr / infra logs mixed with everything, and on some tenants you get only infra. - Fail loud on EDR field typos: add top-level
"scheme": "edr"to the launch body for EDR queries. A wrongly cased or unknown field then returns HTTP 400Unknown EDR field: ...instead of a silentmatchCount=0(thei.schemequery filter does not do this). Correct fields return the same rows. Treat that 400 as "fix the query", never as "retry".
Rate limits (measured 2026-10-05, S-26.3.4, one token, one IP): about 30 calls/s sustained with zero 429s; 429s start around 35 calls/s and hit only launches (POST), never polls or cancels. The old "3 req/sec per user" figure is stale, and a second token is not needed for speed. Default to a token bucket at about 25 calls/s with 15 to 20 slices in flight, and retry a launch 429 with exponential backoff.
Query expires 30s after launch or 30s after the last poll. Poll every 1-2s; don't let the deadline slip.
Sizing & parallelism. Time-slice long windows and run the slices in parallel, then merge. Measured 2026-10-05 for a 30d | group n=count() by dataSource.name over 14.1M events, one token at 25 calls/s: 1 x 30d 21 to 40 s; 7 x ~4d in parallel 4.7 to 5.0 s; 15 x 2d in parallel 4.9 to 5.2 s (the sweet spot); 30 x 1d in parallel 10.2 s (past about 15 to 20 in flight it slows down again). Merged totals matched the single query every time. Defaults table and the full benchmark live in references/lrq-api.md.
Runner. powerquery_run is the runner (rate limiting, slice runner, aggregate merge across slices). For a host-only Python runner (Claude Code or a terminal), the design is documented in references/lrq-api.md and implemented in the mgmt-console-api skill's scripts/pq.py and the sdl-solutions skill's scripts/lrq_sliced.py. Do not write a new runner from scratch.
Quick one-shot exploration: the Purple MCP mcp__purple-mcp__powerquery tool is fine for an interactive 24h hunt. It wraps the same engine but with lower limits, tighter timeouts, and no parallelism. Pair it with mcp__purple-mcp__get_timestamp_range(hours=24) for ISO-8601 ranges, and mcp__purple-mcp__purple_ai when you need a starting-point query draft from natural language. Prefer powerquery_run for anything programmatic, multi-slice, over long windows, or producing results the user will use downstream.
Fallback: when the Purple MCP `powerquery` tool times out or returns an error (common for ranges > 24h, large aggregates, or wide initial filters), do NOT retry with a tighter time range as a first resort. Re-run the same query with powerquery_run. If the window is longer than a couple of days or the aggregate is heavy, add slices (15 for 30 days) and a merge spec; one token is enough. There's no need to shrink the user's requested range to fit the Purple MCP budget.
{"query": "dataSource.name='SentinelOne' dataSource.category='security' event.type=* | group n = count() by event.type","startTime": "2026-09-07T00:00:00Z","endTime": "2026-10-07T00:00:00Z","slices": 15,"merge": { "keys": ["event.type"], "sum": ["n"] }}
Reference files: read as needed
Don't read these upfront. Read the one you need.
references/lrq-api.md- the Long Running Query API thatpowerquery_runwraps: auth, body schema, forward-tag routing, measured rate limits, 30-second query expiration, slicing/parallelism patterns, LOG queries. Read when a query silently returnsmatchCount=0, or before writing a host-only runner.references/syntax-and-operators.md: full operator reference, identifier rules, shortcut fields, regex dialect, date/time formats, short-circuit||.references/commands-reference.md: deep dive on every command (join variants, subqueries, lookup / dataset / savelookup, transpose, compare, top, nolimit). Read before writing anything non-trivial with join, transpose, or compare.references/functions-reference.md: all built-in functions: string, numeric, JSON, network, URL, aggregate, array (method chaining), geolocation, timestamp, time, string-formatting. Read when you need a function and can't remember the name.references/fields-and-schema.md: common EDR/XDR field paths (src.process.*,tgt.file.*,event.login.*,dst.ip.*,indicator.*, etc.) and OCSF conventions. Read when you're not sure what field holds the thing you want.references/o365-fields.md: Microsoft 365 / Exchange / Teams / SharePoint audit field shape, covering OCSF vsunmapped.*duality, fields that live only inside the JSONmessageblob, the discover-before-you-filter rule, send-style operations, RecordType, service-tier IP filtering, and investigation-noise separator. Read before writing any PQ against an M365 audit source.references/detection-rules.md: how to author PowerQuery Alerts / STAR / Custom Detection rule bodies, including the 1,000-row / 1 MB alert constraints and which PQ features are supported in alert context.references/query-cost.md: where query time actually goes. The cost hierarchy (datatable vs entity datasource vs event lake), why alookupAFTERgroupcan beat one before it (118s -> 40s measured, faster while scanning 4.8x more events), why a rolling window couples scan size to the run hour, slice width and why parallel slices measured SLOWER than sequential,savelookup's separate timeout budget, abandoned-query cleanup, and how to benchmark without fooling yourself (cache-order artifacts, poll-interval floors, tenant variance). Read before optimising anything or quoting a timing.references/pitfalls.md: curated list of common failures and their fixes (the*-as-filter trap, forgetting|beforejoin, subquery position errors, memory-limit messages,message containsvs* containson JSON-blob sources, and more).references/automatic-lookups.md: tenant-wide/automaticLookupsenrichment that applies to every search and PowerQuery with no| lookuptyped: config schema, the "output value fields must be unique across all specs" rule, the write-time limits (200 rows, 1 MB per table, 5 MB total, 10 output fields per spec, 50 total), deploy-via-SDL-API flow, verifiedlookup/datasetgotchas, and a full Windows Event Logs SID-to-username worked example. Read when the user wants to add a lookup for SID/username (or any key) that everyone should see automatically, or asks about/automaticLookups.references/datasource-command.md: the| datasource <name> [from <dataset>]command for querying SentinelOne-managed inventory (Asset Inventory, Alerts, Vulnerabilities, Misconfigurations, Metering, SDL retention) that lives outside the event store. Covers datasource names, theassets/meteringdatasets, column discovery, time-series via*_aggregated_snapshots, and the tenant-validated specifics for asset enrichment:from 'surface/identity'vs sparsefrom identity,from 'surface/endpoint'vs sparsefrom device, single-quoting slash dataset names, empty-riskFactors("[]") suppression, and thedatasource ... | savelookuppattern for building enrichment lookup tables. Read whenever the user asks about assets, identities, vulnerabilities, alerts inventory, or building an asset-enrichment lookup.
Examples library: read when a hunt matches
examples/investigations.md: ready-to-run investigation queries (PowerShell outbound, suspicious cmdline patterns, lateral movement, LOLBins, credential access, defense evasion, user-activity baselines, endpoint heartbeat, indicator prevalence). Each example includes a brief "what this finds" note and the full PQ.examples/detection-library.md: PQ bodies ready to paste into a STAR / Custom Detection / PowerQuery Alert, sized to stay within the 1,000-row/1 MB alert budget. Each entry names the MITRE technique and gives athresholdsuggestion.examples/behavioral-baselines.md: statistical baselining recipes for any data source: per-day count slices, moving-average + stddev, z-score detection, silent-pair detection, day-of-week stratification, and geo-velocity (impossible travel in km/h). Read when the user asks to "baseline X behaviour", "detect anomalies in Y", "find users / hosts / IPs behaving differently than usual", "impossible travel", "logins from two countries at once", or any equivalent. Source-agnostic, works on EDR, identity, network, cloud, email, or any custom log source.examples/o365-email-hunting.md: workflow recipes for the most common M365 audit hunts, covering discovery-first patterns, "did user X send mail" questions, per-day cadence, outbound external mail with recipient extraction, top recipient domains, DLP correlation, "user appears but never as actor" attribution gap, and identity-investigation-noise separation. Use whenever the hunt is against an M365 audit source.
When to reach for join vs union vs subquery
These three blur together. Quick rules:
- Subquery (
field in (inner | group 1 by field)): single-field "is this value in that set" filtering. Simplest and usually fastest. Use for allowlist / denylist / top-N-and-pivot patterns. Two single-field subqueries are independent, so they cannot allowlist pairs (they also exclude cross combinations that were never listed); for a few fixed pairs write!((a='x' && b='y') || (a='z' && b='w')), otherwise usejoin. A subquery never matches a null key whilejoindoes, so the two differ when the key can be absent. - Join: multi-field correlation where columns from both sides of a row must match each other (
on a.user = b.user, a.host = b.host) or you need to bring extra columns from the second query into your output. - Union: heterogeneous result sets that you want stacked as rows, possibly with rename/unification. Handy when the same logical event lives in two different log sources with different field names.
Prefer subqueries for exclusion/inclusion; reach for join when a row must "know" multiple things at once.
Detection engineering
Read this before writing any rule. Choosing the wrong queryType is the most expensive mistake available here, because the rule usually still deploys and still fires; it just fires on the wrong thing, or fires with no asset attached.
Which rule type
| Real time | Window | Body | Fits | ||
|---|---|---|---|---|---|
Single event (events) | yes | none | one boolean S1QL filter, no pipes | one event is damning on its own | |
Correlation (correlation) | yes | short, minutes to hours | correlationParams, no PQ | several events that only mean something together | |
Scheduled (scheduled) | no | long, up to 14 days | PowerQuery, pipes allowed | aggregation, baselines, lookups, low-and-slow |
Prefer `scheduled` unless you need real time. It is the only type that takes a PowerQuery body, so it is the only one that can aggregate, join, threshold on a computed value, or exclude via a lookup. It is also the only type where you control the entity binding, which decides whether the alert names an asset or says "Unknown Device". On a live tenant this preference is what practitioners already act on: of 87 rules, 74 were scheduled, 9 single-event, 4 correlation, and 67 of the 74 scheduled rules carried an explicit entity mapping.
Reach past scheduled only when the answer is genuinely "I cannot wait for the next run":
- Single event when one event is a finding by itself, and latency matters.
No window, no aggregation, no exceptions list beyond what the filter expresses.
- Correlation when the finding is "A and B within N minutes, keyed by the
same thing", the parts are individually boring, and you need it as it happens.
Entity binding decides whether the alert is usable
This is the practical reason to prefer scheduled, and it splits by type:
events: the entity is taken from the matched event, **when the event carries a
device identity the console can reconcile against inventory**. EDR telemetry does. Third-party data ingested over the event collector generally does not, and then the alert says "Unknown Device" with nothing you can configure to fix it.
correlation: the entity is whatever you correlate on.entityMappingsis
not used and reads back as null on every correlation rule.
scheduled: nothing is bound unless you say so, and that is the advantage.
Project the column in the query body, then name that exact output column in top-level entityMappings. See references/detection-rules.md.
Measured end to end on one tenant, same ingested events, two rules firing off them:
| Rule | Alert asset | |
|---|---|---|
events on collector-ingested data | Unknown Device | |
scheduled with entityMappings: [{"columnName": "probe_host"}] | the real hostnames |
That is the whole argument for preferring scheduled on non-EDR sources: it is the only type where you can make the alert name the asset. The reproduction is tools/e2e_detection_rules.py at the root of the s1-secops-skills source repo; it is a maintainer harness and is not shipped inside the plugin.
Correlation keys, including a custom one
correlationParams carries the whole logic; s1ql stays empty.
subQueries[]is what makes correlation multi-event. Each entry is
{matchesRequired, subQuery}. One sub-query with matchesRequired: N is a threshold; several sub-queries is a multi-stage detection.
matchInOrder: truemakes it a sequence (1, then 2);falsematches in any order.timeWindow.windowMinutesbounds the whole thing.entityis the correlation key. Built-in values such asuserandipgroup
by that identity.
For a key the built-ins do not cover, set entity: "custom" and supply entitiesAndFields, a list of entity groups: fields inside a group are OR'd (alternative names for the same identity), groups are AND'd (a compound key), and the number of groups does not depend on the number of sub-queries:
"correlationParams": {"entity": "custom","entitiesAndFields": [ ["src.process.parent.storyline.id", "tgt.process.storyline.id"] ],"matchInOrder": false,"timeWindow": {"windowMinutes": 10},"subQueries": [{"matchesRequired": 1, "subQuery": "event.type='Process Creation'"},{"matchesRequired": 1, "subQuery": "event.type='IP Connect'"}]}
That reads as "the entity is the storyline id, whichever of the two fields holds it", which is how you join two different field paths that hold the same identity. Do not split them into two groups ([["src..."], ["tgt..."]]): that means "both fields, AND'd", the API accepts it, and it never fires when each event carries only one of the fields (live-validated 2026-10-05, see references/detection-rules.md, "Custom correlation key").
Three things the API rejects, each confirmed by the error it returns:
- the field is
entitiesAndFields, notcustomEntityKey(Unknown field) - it is a list of lists; a flat list of strings fails with
Not a valid liston element 0 - a field path may appear in only one group. Repeating it gives
entityFields contains duplicate alias(es).
Creating a rule at a scope above your token's returns can not create rule with higher scope; pass filter.accountIds rather than filter.tenant when the token is account-scoped.
Before you deploy
- Run the body as a hunt first. A scheduled body that errors is a rule that never fires.
- For
scheduled, confirm the entity column exists in the output and is named inentityMappings. - Confirm the rule is
Activebefore ingesting test data; a Draft rule silently matches nothing. - Validate by triggering a real alert, not by reading the rule back. Creation succeeding says nothing about whether it fires.
API shapes, limits, patterns, and the deployment recipe: references/detection-rules.md.
Writing detection rules vs ad-hoc hunts
SentinelOne Custom Detection (STAR) rules come in three queryType flavors, all created at POST /web/api/v2.1/cloud-detection/rules with queryLang: "2.0":
- STAR single-event (
queryType: "events"): body is one boolean S1QL filter with NO pipes indata.s1ql, fires per matching event. Use for deterministic single-event signatures. - STAR multi-event / correlation (
queryType: "correlation"):s1qlempty, logic indata.correlationParams(entity +subQueries[]with per-querymatchesRequiredover atimeWindow). Use for thresholds (N of X) and A-then-B sequences. - Scheduled (
queryType: "scheduled"): a PowerQuery body (pipes allowed) indata.scheduledParams.query, runs on a schedule. Use for aggregation, baselines, and lookup/anti-join exclusions.
Only the scheduled type uses a PowerQuery body, and only it is bound by the constraints below. Single-event and correlation bodies are boolean S1QL (the same grammar as a PQ initial filter, no pipes).
A PowerQuery scheduled-rule body is more constrained than a hunt query:
- Intermediate and output tables must stay under 1,000 rows and 1 MB of RAM.
- No
nolimit. - No
compare, usually notranspose(depends on version). - The rule should produce one row per finding, with stable columns the detection engine can map to alert fields (e.g.,
agent.uuid,endpoint.name,src.process.storyline.id,timestamp). - Keep the initial filter as specific as possible: this is what's evaluated in the summary service and is what gates cost.
For all three rule types (API shapes, the correlation correlationParams block, the events/correlation S1QL escaping rule), detection patterns, and a checklist, see references/detection-rules.md and examples/detection-library.md.
A minimal but realistic example
Hunt: PowerShell that made an outbound connection to a non-RFC1918 IP in the last 24 hours, with command line.
src.process.name contains 'powershell' dst.ip.address = *| let is_private = net_rfc1918(dst.ip.address)| filter is_private = false| group hits = count(),ips = array_agg_distinct(dst.ip.address, 20),cmdline = any(src.process.cmdline)by endpoint.name, src.process.storyline.id| sort -hits| limit 50
Notice: filter early (dst.ip.address = * prunes events without a destination IP), net_rfc1918 is the right way to split internal vs external (don't hand-roll CIDRs), array_agg_distinct caps the array so the row stays small, any(src.process.cmdline) grabs a representative cmdline since we're collapsing per storyline.
PowerQuery execution via s1-secops-mcp
PowerQuery execution uses the s1-secops-mcp MCP tools, which run on the user's machine and reach *.sentinelone.net where the Cowork sandbox cannot. Use powerquery_run and powerquery_schema_discover directly; the mgmt-console-api skill scripts are host-only. If a tool reports a missing credential, ask the user to run s1-secops-mcp setup on their machine; never ask for a token in the chat.
Read-only mode. From 1.5.5, s1-secops-mcp is read-only by default (S1_READ_ONLY unset or true). Every query runs, except that powerquery_run refuses a query containing | savelookup, because it writes a lookup table. Deploying a detection rule through s1_api_post is refused as well. Before a hunt or deployment that builds lookup tables, check mode.readOnly in the sentinelone://credentials-status resource or the server's initialize instructions.
- If a call returns "s1-secops-mcp is in read-only mode (S1_READ_ONLY).", stop. Do not retry, and do not route around it with another tool (purple-mcp
powerquerywith| savelookup,s1_api_postto a different endpoint, a hand-built GraphQL mutation). - Tell the user which action was refused and how to enable write: Docker launcher
s1-secops-mcp-launch.sh install --read-write(Windows-ReadWrite), then restart Claude Desktop;.mcpbextension: turn off "Read-only mode (recommended)"; Node install:"S1_READ_ONLY": "false"in the server'senvblock. Detail:docs/read-only.mdin the source repo (not shipped). - Offer the query or rule body as a local file via
outputFileor the Write tool. A read-only alternative often exists: drop the| savelookupand return the rows instead. - Never enable write on the user's behalf by editing their MCP client config.
Timestamp fields on HEC-ingested (isParsed) events (learnings)
event.timeis NOT populated on HECisParsedevents; the real event timestamp istimestamp(epoch NANOSECONDS).newest(event.time)returns null,newest(timestamp)works.strftime(event.time,'%H')silently returns "00" for every row (so an off-hours filterhod < '05'matched everything, a false-positive trap), whilestrftime(timestamp,'%H')returns the real hour.timebucket()reads the implicit event time and is unaffected. When mixing a table-stored ns timestamp with an HA-injected epoch-ms "now", convert:number(last_ms)/1000000.
Scheduled (Custom Detection / STAR) rules run on a pre-aggregated data layer (learnings)
A PowerQuery scheduled rule runs on top of a pre-aggregated data layer, not the raw event stream, so several traditional PowerQuery functions are not relevant as part of scheduled-rule detection logic and must not be used in a scheduled-rule body:
datasetdatasourcenowquerystart,queryend,queryspantopKsavelookuplookupby CIDR or wildcard (=:cidr,=:wildcard)lookupagainst a table with more than 10,000 rows- time-shifted queries using
timebucket timebucketwith a bucket size smaller than 30 seconds
These all work in interactive PowerQuery (Event Search / Deep Visibility / dashboards / savelookup builders), which runs on raw events. The alternate for a use case whose detection logic needs any of them is a Hyperautomation watchdog (a scheduled or manual/run-now flow that runs the full PowerQuery as an LRQ, then posts an OCSF alert to UAM); see the hyperautomation skill. This is why the UEBA SILENT / DORMANT detections, which anti-join live counts against the baseline with left join + dataset to find absent / zero-event pairs, run as HA watchdogs rather than scheduled rules.
Scope note: | lookup ... from <table> (10,000 rows or fewer, exact-match keys) IS allowed in a scheduled-rule body, but lookup tables / datatables are ACCOUNT-level objects: a rule whose body reads one can only be created at account scope (filter.accountIds); site-scoped creation of lookup-reading rules is invalid.