<< All versions

Skill v1.0.0

currentLLM-judged scan90/100
sentinel-one/ai-siem/sdl-dashboard
──Details
PublishedOctober 22, 2026 at 04:30 PM
Content Hashsha256:e5fecbdd82ca413d...
Git SHA85cda2ae0af3
──Files
Files (1 file, 50.2 KB)
SKILL.md50.2 KBactive
SKILL.md · 479 lines · 50.2 KB

version: "1.0.0" name: sdl-dashboard author: Prithvi Moses <prithvi.moses@sentinelone.com> description: > Use this skill any time the user wants to create, edit, design, generate, deploy, or debug a SentinelOne Singularity Data Lake (SDL) dashboard. Triggers include: "build me a dashboard", "create a dashboard panel", "write dashboard JSON", "add a panel to my dashboard", "deploy a dashboard to SDL", "I want a dashboard that shows...", "can you make a dashboard for...", "threat dashboard", "SOC dashboard", "network dashboard", "audit dashboard", "O365 dashboard", "hunting dashboard", or any request that involves SDL/Scalyr dashboard JSON. Also triggers when the user pastes dashboard JSON and wants help fixing, improving, or extending it. Use alongside sdl-api to deploy dashboards, and alongside powerquery to validate or compose the queries inside panels. Always use this skill when dashboards, dashboard panels, or SDL visualization is involved, even if the user just says "show me [metric] over time" in a security/SDL context.


SentinelOne SDL Dashboard Skill

<!-- CONFIG-FILE-ADDRESSING v1 -->

SDL config files: address by `udoId`, and do not trust a REST listing.
REST listFiles / getFile cannot see udoId-addressed /dashboards/ files, so getFile
returns 404 on a dashboard the console is displaying and the listing under-reports (measured on
one tenant: REST 8, GraphQL 17, console 48 files). **If a listing disagrees with what the UI
shows, the listing is wrong until proven otherwise**, change read path before concluding the
object is missing or the token lacks scope. Use the GraphQL configFiles / configFile surface.
A name-addressed addConfigFile to /dashboards/ creates a duplicate instead of updating;
address dashboards by udoId with expectedVersion. content is HJSON, not JSON. S1-Scope
changes which files exist as far as the caller can tell.

This skill helps you design, author, and deploy Singularity Data Lake (SDL) dashboards, from a single panel to a full multi-tab SOC dashboard. Dashboards live as configuration files in SDL and are authored as JSON (or a relaxed JavaScript-literal superset of it). You deploy them with the sdl_put_file MCP tool.

Talk to the tenant through the `s1-secops-mcp` MCP tools. sdl_list_files / sdl_get_file / sdl_put_file inspect and deploy dashboards, powerquery_enumerate_sources and powerquery_schema_discover drive pre-authoring discovery, and powerquery_run validates every panel query. The server runs on the user's machine, so it reaches *.sentinelone.net where 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.md in the s1-secops-skills source repo, mcp/s1-secops-mcp/README.md in ai-siem; not shipped inside the plugin). The local scripts in scripts/ that make no network calls (panel_safety_check.py, render_validation_pdf.py) run anywhere; validate_dashboard.py calls the tenant and is host-only.

Before you start: REST cannot SEE most dashboards

sdl_list_files / getFile omit every udoId-addressed `/dashboards/` file. This is a visibility gap, not just an addressing one, and it makes a live dashboard look deleted.

Symptoms, all observed together on one tenant:

  • getFile returns 404 on a dashboard the console is displaying;
  • the REST listing returns 8 dashboards where GraphQL returns 17 and the console's

Configuration Files tab shows 48 files.

If a listing disagrees with what the UI shows, the listing is wrong until proven otherwise. That contradiction is the signal to change read path, not to conclude the object is missing or that your token lacks scope. Reach for the GraphQL config-file surface (sdl-api/references/config-file-graphql.md):

graphql
query { configFiles { udoId name readOnly version } }
query f($udoId: ID!) { configFile(udoId: $udoId) { udoId name content version } }

Two things that bite immediately after a successful read:

  • `content` is HJSON, not JSON, unquoted keys and relaxed commas. json.loads raises

Expecting property name enclosed in double quotes. Parse with an HJSON reader; you can write plain JSON back.

  • `S1-Scope` is honoured and silently changes what exists as far as the caller can tell. A

site-scoped dashboard is invisible to an account-scoped listing, so "not found" is always scope-relative.

Defaults you apply without being asked

The user should not have to remember any of this. If a prompt has to carry the constraint, the skill has failed. "Build a SOC leader dashboard" is a complete instruction; treat every item below as already requested.

AlwaysNever wait to be told
Put the initial predicate of a `\datasource query inside where (...)`Do not write `\datasource alerts \filter status = 'NEW'`
Run source enumeration and schema discovery before authoring, and use only fields you confirmedDo not assume a field exists because it exists on another tenant
Search the account for a deployed dashboard already doing this, and copy its working patternDo not theorise about why something cannot work before looking
Lead the dashboard description with a bold instruction to set the data selector to All DataDo not ship a `\datasource` or alert-stream dashboard without it
Validate every panel query against the tenant, then check the visual's column contractDo not treat an API pass as a render pass
Match the time unit to the surface: now() ns, inventory createdAt ms, ActivityFeed time sDo not hand-convert without checking the table in the UQL section
Use a chained group for exact distinct counts on inventoryDo not quote estimate_distinct as exact
Terminate number and table panels with `\limit`
Say so plainly when a source is empty on this tenant, and do not ship the panelDo not ship a panel that renders empty and hope nobody looks
Name a panel after what its query actually measuresDo not let a title overstate the query

When a request conflicts with one of these, say which constraint applies and what you did instead, rather than silently following the prompt into a broken panel.


Workflow

This workflow is mandatory for every new or modified dashboard. Steps 0, 1, and 7 are non-negotiable: pre-flight discovery, the safety pre-flight check, and the post-deploy log-evidence report. Skipping any of them produces dashboards that look fine in isolation but mislead, hang, or silently drop data.

  1. Discovery (MANDATORY for every session): Re-enumerate connected data sources (| group UniqueDataSourceNames = array_agg_distinct(dataSource.name) | limit 1000), run schema discovery on every source the dashboard will touch, and validate the discriminator field for any event.type you intend to count. See Pre-authoring discovery below. Never start a panel from a remembered schema.

Execution path for discovery via s1-secops-mcp:

  1. Source enumeration: powerquery_enumerate_sources, or powerquery_run with the array_agg_distinct(dataSource.name) query, at the dashboard's scope.
  2. Schema discovery (full event JSON per source): powerquery_schema_discover per source (issue them in parallel), at the same scope. Persist the field lists with the Write tool if later steps will need them.

0.5. Establish both scopes before authoring anything. Ask, or read from the request: which scope is this dashboard deployed at, and which data should its panels read? If the deployment target is a site, every query panel gets site.id='<siteId>' unless the user explicitly asked for account-wide queries. See Scope doctrine below. Getting this wrong is not a cosmetic error: an unscoped panel on a site dashboard reports another site's numbers, and a site.name filter silently drops alert and asset records.

  1. Understand the ask: What data should the dashboard show? Who is the audience (SOC analyst, manager, customer POC)? What time range makes sense? What posture should each panel reflect (a panel that legitimately returns 0 needs a markdown header explaining the SOC-positive interpretation, see Empty results are valid evidence).
  2. Design the structure: Choose tabs (if multi-topic), then panels per tab. Match panel type to the data shape using the guide in references/panel-type-cheatsheet.md. Key decisions: flows/kill-chains → sankey; KPI vs SLA target → bullet; SOC queue health → gauge; 3D outlier detection → scattered_bubble; time-based density → heatmap; multiple queries in one panel → tabbed table. Where one event.type covers multiple semantic populations (delivery-time vs click-time, scheduled vs on-demand, inbound vs outbound), build separate sections per population, not a mixed section.
  3. Write the JSON: Use the panel type reference below and real examples in references/community-examples.md. Compute explicit x/y/w/h for every panel. Apply the naming-hygiene rule from Panel naming hygiene so titles read as SLA-grade claims.
  4. Validate queries: Sample 3-5 events per source/event-ID to confirm field semantics. Test each panel query with powerquery_run (see the powerquery skill). Run the parallel load test (see Pre-deploy validation), acceptance thresholds: slowest panel ≤ 2s, wall-clock ≤ 5s. Run scripts/panel_safety_check.py against the dashboard JSON (local, no network); resolve every flag before deploy.
  5. Deploy with the GraphQL-backed MCP tools (see references/deployment.md). First deploy: sdl_create_dashboard with name, config (the dashboard JSON as a string), isPublic: true and scope ("<accountId>:<siteId>" for a site); this is the default and the only way to deploy to a site. Record the returned id: it is the udoId (sdl_list_files with pathPrefix: "/dashboards/" resolves it if needed). Every deploy after that: sdl_get_file with udoId for the current version, then sdl_put_file with udoId, content and expectedVersion as the CAS guard. A path-addressed write to an existing dashboard is refused because it duplicates. Save a backup of the prior JSON first. Wait 3s, then re-read by udoId to verify the version bumped AND check the returned content for a canary string from your change.
  6. Iterate: Show the user what was built, explain each panel, offer to tweak. If the dashboard hangs, follow the escalation ladder in Pre-deploy validation.
  7. Log-evidence report (MANDATORY): Replay every non-markdown panel against live data and keep the evidence. A dashboard delivered without an evidence report is incomplete.
  • Replay: one powerquery_run call per panel, with the panel's exact query, the dashboard's time range (hours or startTime / endTime) and the same scope the dashboard is deployed at. Issue the calls in parallel, about 10 per turn, so a 60-panel dashboard takes a few turns, not one long blocking run. Keep maxRows small (for example 20); you need the row count, columns and the first 3 rows.
  • Persist: write the evidence JSON with the Write tool, one entry per panel keyed "<tab>::<title>" with ok, verdict (PASS / WARN / EMPTY / FAIL, or SKIP for markdown), style, query, row_count, columns, sample_rows (first 3 rows verbatim), matchCount, elapsed_s (null when not measured) and error (first 300 characters on failure). Apply the per-style pass conditions in Log-evidence report below. Write the markdown evidence file next to it.
  • Render: run scripts/render_validation_pdf.py <evidence.json> (local, no network) for the PDF (cover, per-tab sections, sample-data tables, empty-result appendix with SOC-meaningful interpretations). Deliver the PDF and the markdown alongside the dashboard.

When a stacked_bar or line panel using | transpose returns 0 rows in validation, cross-check whether the corresponding number panel for the same source shows data; if it does, record it in the Appendix as a confirmed false-empty and do not remove the panel. On the user's host, scripts/validate_dashboard.py automates the same replay and writes the same JSON shape; run it as a background process there, since it takes about 10 s per panel.

  1. Screenshot review with the user (MANDATORY). API validation proves each panel's query returns rows; it does NOT prove the panel RENDERS. Render-only failures happen in the browser, not the API, so query replay cannot see them: a panel showing "Couldn't load content", a markdown tile showing "Untitled", a number reading "34 principals" under a title that already says principals, an empty chart, or a broken legend. After EVERY deploy, ALWAYS ask the user to open the dashboard and send screenshots of each tab, then read them, diagnose each visual defect, fix the JSON, and re-deploy, without waiting to be asked. Prompt explicitly, e.g.: "The dashboard is deployed at /dashboards/<name>. Please open it and send screenshots of each tab so I can catch any render-only issues and fix them automatically." Treat this as part of deployment, not optional polish. Fixes for the common render-only defects are in the Quick triage table.

Scope doctrine

Deployment scope and query scope are two independent decisions. A site-deployed dashboard scopes its panels with site.id, never site.name, which silently drops alert and asset rows. See `references/scope-doctrine.md`.

Pre-authoring discovery

Enumerate sources live, discover each source's schema, identify the discriminator before counting, and null-check every grouping column. Mandatory every session; never author a panel from a remembered schema. See `references/pre-authoring-discovery.md`.

PowerQuery feature gaps to design around

The catalog of PowerQuery patterns that 500 or render badly inside dashboard JSON, the safe patterns to prefer, the two-pass quoted-KV parse, and the totals-plus-breakdown workaround is in `references/powerquery-gaps.md`. scripts/panel_safety_check.py scans for the failing patterns automatically.

`count(<predicate>)` is a conditional count and it works. count(severity == 'CRITICAL') and count(analystVerdict contains 'TRUE_POSITIVE') are standard in production dashboards. Only count_if(...) and sum(if(...)) fail. Read the gap catalog as "these two spellings 500", not as "conditional aggregation is unavailable".


UQL adapter queries (| datasource)

A second query surface, alongside the event stream. | datasource <adapter> reads SentinelOne inventory (current state) rather than logs, and most alert-operations dashboards want it.

text
| datasource <adapter> [from '<subset>'] where (<predicate>)
| group <aggregates> by <dimension>
| let <derived>
| filter <derived-predicate>
| sort -<field> | limit <n>
| columns <projection>

The initial predicate must live inside `where (...)`. It is an adapter parameter, not a PowerQuery command, and | datasource alerts | filter status = 'NEW' is the single most common failure. Once past the adapter, normal PowerQuery applies, including | filter on a derived field.

Adapters, per-adapter where (...) operator support, pushdown limits, the null idioms, ActivityFeed type codes and the multi-hop sankey shape are tabulated in `references/uql-datasource.md`. Read it while authoring an adapter panel; the rules below are the ones that decide whether the panel works at all.

Two syntax generations are both live. Adapter parameters (| datasource alerts where (...) count_by status timestamp_field createdAt | group sum(count)) and PowerQuery pushdown (| datasource alerts | group count() by severity). Pushdown relays PQ commands to the adapter up to the first group, or the first command that cannot be pushed. The documented minimum is a floor, not a ceiling: one tenant's alerts adapter accepted 5 group keys plus percent_of_total and max_by. Test the specific thing you need rather than assuming either way, because pushdown support is actively changing.

`timestamp_field` changes the answer. count_by <f> timestamp_field <date> picks which date the time picker filters on, and createdAt, detectedAt and lastSeenAt give materially different results. Choose deliberately and state the choice in the panel description.

Exact distinct beats `estimate_distinct` here. Chain two groups: | group n = count() by assetName | group total = count(). Exact, not approximate. estimate_distinct was observed returning 14,196 against a true 14,043.

The time picker clips every age metric. | datasource alerts only returns alerts created inside the dashboard range, so a derived age_days is capped at the picker width, and rows sitting exactly at the ceiling mean the real backlog is older. Say so in the panel description.

Two unresolved leads, a possible row cap on grouped adapter queries and facet chips blanking adapter panels, are recorded under "Leads, not facts" in the reference. They are worth knowing about when a panel is inexplicably empty, and are not established behaviour.

Inventory or event stream: choosing a source

NeedSource
Current state: status, verdict, assignee, MITRE, SLO, AI verdict`\datasource alerts`
Per-action history: who or what changed over timedataSource.name='ActivityFeed'
Alert lifecycle counts (Create / Update / Close)dataSource.name='alert' class_uid=99602001

The inventory holds current state only, so it cannot answer "how many verdicts were rendered on Tuesday". The event streams hold actions but not always identity: data.user_id and secondary_description were null on verdict-change events on one tenant, making per-analyst attribution impossible from ActivityFeed, so that has to come from the inventory's assigneeFullName.

ActivityFeed alert activity types: 16000 created, 16001 status changed, 16002 verdict changed, 16003 severity changed, 16004 assignee changed, 16005 mitigation executed, 16007 note added.

An alert with exactly one ActivityFeed row was created and never touched:

text
dataSource.name='ActivityFeed' data.alert.id=* dataSource.category='security'
| group touches=count() by data.alert.id
| filter touches == 1
| group untriaged=count() | limit 1

That cross-validated against the inventory's status='NEW' and analystVerdict='UNDEFINED' to within 1.7% (14,282 against 14,039), which is a useful sanity check when either source is in doubt.

Time units: three different scales

Getting this wrong by 1000x lands on plausible-looking numbers, which is worse than the obviously absurd case.

SurfaceUnitAge in days
now()nanosecondsn/a
Inventory createdAt / detectedAtmilliseconds(now() / 1000000 - createdAt) / 86400000
ActivityFeed timeseconds(now() / 1000000000 - time) / 86400
Event-stream timestampnanoseconds(now() - timestamp) / 86400000000000

Empty results are valid evidence (but distinguish from query errors)

A query that runs successfully and returns 0 rows is a real datapoint, not a bug. Examples:

  • A "policy violations blocked" panel returning 0 because the policy is in monitor-only mode.
  • A "malicious URL delivered" panel returning 0 because the engine hard-blocks at a different layer.
  • A "compromised account auth" panel returning 0 because no compromise occurred this window.

Rules:

  1. Never silently switch the query to make a panel "have data." If 0 is correct, surface it.
  2. Distinguish 0-row from 0-matchCount. A successful query with matchCount > 0 and rowCount = 0 means the post-pipe steps eliminated everything (often a | filter after | group). A query with matchCount = 0 means no events matched the initial filter at all. The first hints at refining the filter; the second hints at validating coverage.
  3. In dashboards, a panel that may legitimately be 0 should have a markdown header that explains the SOC-positive interpretation. Example: "0 here is the desired posture; non-zero indicates a regression worth investigating." Without this, an analyst reads a blank panel as a broken dashboard.
  4. In the evidence report (mandatory deliverable), every empty panel must include the underlying matchCount so the reader can tell whether the source has data at all. The PDF Appendix lists every empty-result panel with its SOC-meaningful interpretation.

Full-text predicate cost

Full-text raw_data matching is the only way to filter a field the parser did not promote, and it scans every event in the window. Safe for number panels and selective tables; it times out with timebucket plus transpose. See `references/query-performance.md`.

Panel naming hygiene

The single most common cause of misleading dashboards is a panel title that overstates what the underlying query measures.

Wrong titleWhy it's wrongCorrect title
"URL clicks"Counts both clicks and delivery-time scans"URL events (clicks + scans)"
"Phishing emails clicked"Counts events that include a click discriminator AND those that don't"Phishing-classified emails (events)"
"Allowed malicious URLs"Counts the rewriting policy disposition, not the user's actual click"Malicious URL events delivered (warn)"
"Distinct users compromised"Counts users associated with a detection, not confirmed-compromise users"Distinct users with detected events"

Rule: the panel title should be readable as an SLA / report claim. If a CISO would feel misled reading the title without seeing the query, rename the panel.

When two semantically distinct event populations exist within the same event.type (delivery-time vs click-time, scheduled vs on-demand), build separate sections of the dashboard for each population, with markdown headers that explain the split. A single section that mixes both populations is almost always wrong.


Dashboard JSON structure

Single-tab, multi-tab and the top-level properties. See `references/panel-types.md`.

Panel types

Every panel is an object inside graphs; the graphStyle property picks the panel type. Every panel also needs an explicit layout object with x, y, w, h (omitting them can hang the renderer). The grid is 60 columns wide: w: 60 is full width, 30 half, 20 a third. A layout authored for a 12-column grid renders as a strip in the left fifth of the page. In a TABBED document each tab's label key is `tabName`, not name; a tab with only name is refused as "one of the tabs in dashboard has a blank name".

The full per-panel JSON catalog (layout helper, line/area, stacked bar, pie/donut, table, number/gauge, honeycomb, heatmap, distribution, and markdown panels) is in `references/panel-types.md`, with a one-line-per-panel summary in `references/panel-type-cheatsheet.md`.


Common rendering pitfalls

These are silent failures, the API accepts the JSON, the panel mounts, but either nothing draws or the panel hangs on the spinner. Apply the fix preemptively when authoring panels of these shapes.

SymptomRoot causeFix
Couldn't load content: Invalid column 'dataSource.category' on a `\datasource` panelThe console data selector is on XDR, which injects preFilter: dataSource.category = 'security' into every panel query. The inventory has no such column, so the adapter hard-errors. Reproducible from the API: `\datasource alerts \filter dataSource.category=* returns 500 invalid_argument`.Set the data selector to All Data. There is no dashboard-JSON field that pins it, so make the instruction the first sentence of the dashboard description (visible on every tab) and repeat it in bold in the first tab's markdown panel. See the scope-trap section below.
Alert-stream or `\dataset` panels are silently empty, while ingested third-party telemetry on the same dashboard renders fineSame XDR preFilter. dataSource.name='alert' and `\dataset 'config://datatables/...' fall outside category='security'` and are filtered to nothing with no error. A dashboard looks half-broken while every query is correct.As above, All Data. Prefer event-stream queries where the data allows, since they survive both scopes.
Markdown panel renders blank, no errorWrong body fieldUse markdown: (NOT content:): see Markdown panel section above
Markdown panel header shows "Untitled"No title key on the markdown panel (S-26.1)Add a short plain-text title; keep prose (no repeated ## heading) in markdown
Number panel reads the unit twice, e.g. "34 principals" under "Active principals (24h)"options.suffix duplicates a unit already in the titleRemove suffix (or drop the unit from the title); keep {format, precision} only
Category bar/line panel errors "The first column ... should have numeric value in epoch"graphStyle: "bar" / "line" / "area" default to a TIME x-axis and require an epoch first column; a (category, count) query has a string first columnUse graphStyle: "stacked_bar" with "xAxis": "grouped_data"; keep the (category, value) query. bar alone is NOT a categorical bar chart
area chart with query field shows an indefinite spinner; no error in UIgraphStyle: "area" is built around the plots: [...] pattern. A query-driven multi-series chart that ends in transpose does not render under area.Switch to graphStyle: "stacked_bar" (or "line") with xAxis: "time". The query body stays the same.
plots-based line or area panel returns "No results found" even though data clearly exists (confirmed via a number or table panel on the same source)The plots: [{ "filter": "...", "facet": "..." }] filter mechanism silently ignores fields in the unmapped.* namespace. Any predicate like unmapped.action='deny' in a filter string inside plots matches 0 events regardless of actual data volume. No error is surfaced, the panel just shows empty.Replace with a PowerQuery stacked_bar + `\transpose panel. The PowerQuery engine fully supports unmapped.* fields. The query body is equivalent: <source-filter> unmapped.action in ('deny', ...) \group count=count() by timestamp=timebucket('1h'), action=unmapped.action \transpose action on timestamp`.
Couldn't load content: field=[DashboardPlotQuery.plotIndex] error=[Facet for plot at index: 0 is invalid]"facet": "count()" (with parentheses) is invalid in a plots array. Only "facet": "count" (no parentheses) is accepted. The community examples in older docs show count(); this is wrong.Use "facet": "count" (no parentheses) in every plots entry.
Couldn't load content: "transpose" can only be used as the last command in a querytranspose is the terminal command in the PQ pipeline; nothing can follow itRemove any `\limit N / \sort / \filter placed AFTER transpose`. If you need a limit, apply it pre-transpose via a subquery or a column-list filter
Couldn't load content: Identifier "x-y" is ambiguous. To subtract, add spaces: "x - y". Otherwise, add backslashes: "x\-y"The PQ parser reads hyphenated text as a single identifier, not as subtractionAdd spaces around - in arithmetic: total - min, max - min, (a - b) / (c - d). Same applies to all PQ panels and rule bodies.
transpose <field> on timestamp hangs the renderer when field values contain hyphens (e.g. db-prod-01, ISO dates, UUIDs, container names). Spaces are fine: Windows Event Logs, Anomaly Detection and Proofpoint TAP all worked as transposed column names, so do not avoid multi-word values on this account.The renderer must parse the transposed values as column names for the chart legend. The PQ parser reads db-prod-01 as subtraction and throws Identifier is ambiguous, or hangs silently. The V1 API tolerates this; the renderer does not.Option A, pre-process: `\let host_safe = replace(host_raw, '-', '_') then transpose on host_safe. replace_all does NOT exist in SDL PowerQuery (live-verified 2026-07-29); use replace. If the tenant's replace substitutes only the first occurrence, values with multiple hyphens (db-prod-01) still break, so treat Option A as best-effort. **Option B (preferred for by-host charts)**: avoid transpose on hyphenated values entirely and use "xAxis": "grouped_data" with a grouping query. Loses time dimension but renders reliably. **Option C**: only use transpose on fields whose values are guaranteed free of hyphens (numeric codes, single-token labels like Success/Failure`).
Number panel, table panel, or whole dashboard slow to load on first open"All API queries pass" ≠ "dashboard loads fast". The browser fires all panel queries in parallel; total load time ≈ slowest single panel. Serial validation in a script wildly overestimates wall-clock load time.Run a parallel load test before every put_file: see Pre-deploy validation section below. Acceptance thresholds: slowest single panel ≤ 2s, wall-clock ≤ 5s, zero failures.
get_file returns HTTP 404 immediately after a successful put_fileput_file is synchronous but the file propagates across replicas with eventual consistency (~2-3s).Always time.sleep(3) between put_file and the subsequent get_file verification call.
Heatmap panel renders blank, no error, data confirmed in PowerQueryrangesCreation: "automatic" requires empty-string middles in heatmapRangeConfig. Providing explicit numeric strings (e.g. "10", "50") conflicts with automatic mode and the renderer silently returns a blank panel.Restore to ["-∞", "", "", "", "", "∞"]. SDL auto-calculates the middle thresholds from live data.
Heatmap (or any panel) renders blank after a config change, but the query returns dataStale SDL UI session state can persist across config deployments. The browser renderer caches the prior render state and does not always reload after a put_file.Log out and log back in to the SDL console. This clears session state and forces a fresh render. Do not assume a config bug until you have ruled out a stale session.
min(timestamp) / max(timestamp) displays as a giant integer like 1.777e18Aggregating over timestamp returns raw nanoseconds. The renderer has no implicit date formatter for aggregate output.Wrap with simpledateformat(min(timestamp), 'yyyy-MM-dd HH:mm:ss z', '<TZ>'). For millisecond-typed fields (e.g. time on dataSource.name='asset'), multiply by 1000000 first: simpledateformat(max(time) * 1000000, ...). Functions that do NOT exist: format_timestamp, formatTimestamp, iso8601, date_format.
Hostname/value-list filter is slow or behaves differently in the renderer vs API`field matches '(host-a\host-b)'` is evaluated as a regex per event, and hyphenated literals inside alternation can interact with the parser.Use field in ('host-a', 'host-b', 'host-c') for any fixed list. Faster (indexed lookup), no escaping needed, consistent across renderer and API. Fall back to matches only when a true regex pattern is needed.
"User" panels dominated by machine accounts (e.g. host123$, dc-prod-01$)Machine accounts carry a trailing $ and appear in the same fields as human accounts.Add `\filter !(field matches '.*\\$$')` after the event filter and before the group. Verify with 5-10 sample rows that no machine accounts leak through.
Dashboard panel times out, indefinite spinnerA subquery inside the main query forces the engine to scan-and-aggregate twice. Dashboards rerun panels on every load, so the cost compounds.Don't gate a panel query on a subquery if you can avoid it. Hardcode top-N values via inline OR clauses, or accept the full cardinality (often small after the initial filter). If a subquery is unavoidable, prefer a lookup against a precomputed datatable.
Number panel slow on a busy indexEngine keeps scanning after the answer is computedAlways terminate number panels with `\limit 1 after the \group` that reduces to one row
Wide range + fine timebucket = thousands of points per seriesE.g. timebucket("10m") over 7d = 1,008 points × N seriesMatch bucket to duration: 1d → 10m, 7d → 1h (minimum), 30d → 1 day minimum
Two or more near-identical dashboards share a name in Configuration files/dashboards/id/<udoId>/<name> is a display string, not a path; writing to it returns no file exists at path. Duplicates come from addConfigFile(name:) creating a copy instead of updating, so every name-addressed deploy adds one. A name-addressed copy (udoId: null) can also coexist with udoId-addressed ones.Resolve the name with sdl_list_files (pathPrefix: "/dashboards/") and address every update by udoId. Name-addressed writes are for the first create only; sdl_put_file refuses the rest. Delete surplus copies by udoId.
columns resources[0].name or group by finding_info.attacks[0].tactic.name is rejected (HTTP 400, reported as 500 on some versions), or worse, the escaped form `finding_info.attacks\[0\].tactic.name` parses and returns 0 rows silentlyPowerQuery does not accept bracket-array indexing in any position. The V1 query API exposes nested arrays as flattened display keys (resources[0].name), but those are NOT valid PowerQuery field paths. When the field is not present in flattened format there is no error, just no result, and array_get(finding_info.attacks, 0).tactic.name is also a parse error. The silent-zero case is the dangerous one: an empty panel that looks like "no such activity".Prefer top-level scalars (severity_id, finding_info.title, metadata.product.name, class_name, time), or the inventory, which exposes the same data as clean scalars: `\datasource alerts has mitreTactics / mitreTechniques. The [*] wildcard accessor does work as a group key on the alert stream where the parser flattened it. Flattening is per-tenant and per-parser, so probe with a small \group count() by <field>` before relying on either bracket form.
`\parse "app=$val$" from message fails with "Start quote with no matching end quote" when the raw field value is wrapped in double quotes (e.g. app="HTTPS.BROWSER"`)The `\parse format string uses "..." as its outer delimiter. Any "` character embedded in the format, to match quote-wrapped KV values common in network device logs, is treated as a string terminator. No escape sequence (backslash, single-quote outer, hex) works around this.Use a two-pass parse: pass 1 captures the entire non-whitespace token including quotes ({regex=\\S+}), pass 2 extracts the clean value from that token. See Two-pass parse for quoted KV values below.
Table panel shows its header and no rows, while the same query returns rows in PowerQueryThe panel's layout.h is under 10, which leaves no room for rows (seen 2026-10-14: a 5-row result rendered as headers only at height 8)Give every table panel layout.h of at least 10, and check it in the pre-deploy validation.

The XDR / All Data scope trap

Worth its own section because it presents as a broken dashboard, not as a scope problem, and the API cannot reproduce it.

The top-left data selector injects a preFilter into every panel query:

text
XDR selected -> preFilter: dataSource.category = 'security'
All Data selected -> no preFilter
Panel readsResult under XDR
`\datasource <adapter>`Hard error, Invalid column 'dataSource.category'
dataSource.name='alert'Silently empty
`\dataset 'config://datatables/...'`Silently empty
Ingested XDR telemetry (parsed third-party sources)Works

So a dashboard can look half-broken while every query is correct, and the broken half is exactly the alert and lookup panels.

There is no dashboard-JSON field that pins the selector. Mitigations, in order of usefulness:

  1. Make SET THE DATA SELECTOR TO 'All Data' the first sentence of the dashboard description,

which shows on every tab.

  1. Repeat it in bold at the top of the first tab's markdown panel.
  2. Prefer event-stream queries where the data allows, since they survive both scopes.

Why data lands outside XDR at all: the category is an assigned attribute. If a parser does not run, for example a site-scoped parser with HEC ingestion where account-scope parsers win, events arrive unparsed, carry no dataSource.category, and appear only under All Data.

API-passing is not render-passing. The LRQ API never applies the console preFilter, so a panel set can validate cleanly through powerquery_run replay and still fail in the browser. Treat them as two separate gates, and do not conclude a query is wrong from a blank panel until the selector has been checked.

The underlying mechanism is recorded in `references/lessons-learned.md` section 8.6.


Parameters and filters (dynamic filtering)

SDL's two dynamic-filtering mechanisms, filters[] for TABBED dashboards and #VarName# substitution for flat and TABBED dashboards, plus the UI-only parameters[] behavior, are documented in `references/parameters-and-filters.md`.


Common SDL data sources and event patterns

Source-by-source field patterns and starting-point queries (S1 internal OCSF sources, EDR/XDR telemetry, third-party sources, and common panel query shapes) are in `references/data-source-patterns.md`. Re-run live schema discovery before authoring panels; these are starting points, not a registry.


Deploying a dashboard via API

Deployment via the sdl_* MCP tools (resolve the udoId, write with a CAS guard, verify by re-fetch with a canary grep, and handle name-vs-udoId duplicates) is documented in `references/deployment.md`.

Read-only mode. From 1.5.5, s1-secops-mcp is read-only by default (S1_READ_ONLY unset or true). sdl_create_dashboard, sdl_put_file, sdl_share_dashboard, sdl_save_dashboard_layout and sdl_delete_dashboard are refused in that mode; sdl_get_file, sdl_list_files, sdl_list_dashboards, sdl_get_dashboard and every panel query still run. Check the mode before a create, share and layout sequence: read mode.readOnly from the sentinelone://credentials-status resource, or the server's initialize instructions.

  • If a write 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 (s1_api_post to 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; .mcpb extension: turn off "Read-only mode (recommended)"; Node install: "S1_READ_ONLY": "false" in the server's env block. Detail: docs/read-only.md in the source repo (not shipped).
  • Offer the dashboard JSON as a local file via the Write tool so the user can paste it into the console editor or deploy it later.
  • Never enable write on the user's behalf by editing their MCP client config.

Reference files in this skill

  • references/uql-datasource.md: | datasource adapter lookup tables: adapters and their subset selectors, per-adapter where (...) operator support, pushdown limits, null idioms, ActivityFeed activity-type codes, the multi-hop sankey shape, and the unresolved leads. Read alongside the UQL section above whenever a panel reads inventory rather than the event stream.
  • references/panel-types.md: full per-panel JSON catalog for every graphStyle (moved out of this file).
  • references/panel-type-cheatsheet.md: one-line summary of every panel type plus gotchas.
  • references/powerquery-gaps.md: PowerQuery patterns that fail inside dashboard JSON, safe alternatives, and parse workarounds (moved out of this file).
  • references/parameters-and-filters.md: dynamic filtering deep detail (filters[], #VarName#, parameters[]) (moved out of this file).
  • references/data-source-patterns.md: source-by-source field patterns and starting-point queries (moved out of this file).
  • references/query-performance.md: per-query performance rules for dashboard panels (moved out of this file).
  • references/deployment.md: deploy via API, pre-deploy validation, escalation ladder, and the pre-deploy checklist (moved out of this file).
  • references/community-examples.md: full real-world dashboard JSON examples (console audit, threat stats, alert investigation, O365, Fortinet).
  • references/common-queries.md: ready-to-paste PowerQuery snippets for common security use cases.
  • references/lessons-learned.md: source-agnostic patterns and gotchas from production engagements (PowerQuery feature gaps, full-text cost, naming hygiene, discriminator handling, validation runner shape).
  • references/evidence-report-template.md: required format for the post-deploy log-evidence report (per-panel JSON, markdown, PDF appendix).

Read the community examples before creating a new dashboard, and read lessons-learned.md if any of: a panel may legitimately return 0, an event.type covers multiple semantic populations, the panel needs a field only present in raw_data, or a previous version of this skill produced a 500 error on count_if, sum(if()), or mid-pipeline | union.

Reading only this file is not enough, and that has cost real time: the XDR scope trap was documented in lessons-learned.md section 8.6 while a reader working from SKILL.md alone reached several wrong diagnoses. The highest-value fix for that is the one applied above, promoting the decision rules into this file, but the reference detail still matters. Before theorising about why a panel is empty, search the tenant for a deployed dashboard that already does what you are attempting; a working example beats a hypothesis.

Skill scripts (in scripts/)

The safety check and the PDF renderer are mandatory parts of the workflow, not optional tooling. Neither makes a network call.

They live in the installed plugin directory and are not always reachable from a sandbox. If scripts/panel_safety_check.py cannot be executed, do not skip the gate: the checks it performs are all stated in the Common rendering pitfalls table above, so walk the dashboard JSON against that table by hand before deploying. The highest-value manual checks are markdown rather than content, no area with a query, transpose last, spaces around - in arithmetic, no count_if or sum(if()), | limit on every number and table panel, and a layout on every panel.

  • scripts/panel_safety_check.py <dashboard.json>: pre-deploy. Scans dashboard JSON for known-bad patterns (markdown content vs markdown field, area + query, transpose-not-terminal, hyphenated arithmetic, count_if / sum(if()) / mid-pipeline | union (union-first is allowed) / named subqueries, \\s/\\d regex escapes inside matches, full-text combined with timebucket+transpose, missing layout, missing | limit on number/table panels). Exits non-zero on any flag. Run before every put_file.
  • scripts/validate_dashboard.py <dashboard.json> [--start 7d] [--out <dir>]: post-deploy, host-only (it calls the tenant, so it does not run in the Cowork sandbox; use parallel powerquery_run calls there, see step 7). Replays every non-markdown panel against the SDL power_query API, persists per-panel evidence (style, query, elapsed, rowCount, matchCount, columns, sample rows, error) to a JSON keyed on tab::title, and emits a markdown evidence file. Idempotent, resumes cleanly, persists after each panel. Reads credentials from environment variables or the OS keychain. Run it as a background process (... &).
  • scripts/render_validation_pdf.py <evidence.json> [--out <pdf>]: post-deploy. Reads the validation JSON and emits a PDF report with cover page, per-tab sections, sample-data tables (first 3 rows of N), and an Appendix listing every empty-result panel with the operator's prepared SOC-meaningful interpretation. The PDF is the leadership deliverable; the markdown evidence stays in version control.

Log-evidence report (mandatory deliverable)

Every dashboard delivered, whether new or modified, ships with a log-evidence report. The report's purpose is to prove that each panel's query runs against live data, captures actual sample rows, and either renders data or has a documented reason for being empty. Without this report a dashboard is incomplete; do not consider the workflow done.

The minimum the report captures, per panel:

  • ok (did the query execute), elapsed_s, row_count, columns, sample_rows (first 3 rows verbatim, this is the log evidence), matchCount (events scanned before grouping), error (first 300 chars when ok=False).

Verdict per panel style:

Panel stylePass condition
numberrow_count == 1 and len(columns) == 1; OR row_count == 0 (acknowledged empty)
donut / pierow_count >= 1 and len(columns) >= 2 (text + numeric); OR row_count == 0
stacked_bar / bar / line / arearow_count >= 1 and len(columns) >= 2; OR row_count == 0
tableAlways passes if ok=True; sample rows captured
markdownExcluded (no query)

The PDF report MUST include an Appendix listing every empty-result panel (row_count == 0) with its SOC-meaningful interpretation, sourced from the markdown header authored alongside the panel. A panel returning 0 rows without explanation is indistinguishable to the reader from a broken panel; always document the "why."

See references/evidence-report-template.md for the exact structure.


Query performance tips

The per-query performance rules for dashboard panels (net_rfc1918() over hand-rolled CIDR regex, | limit on number and table panels, field=* presence checks, timebucket-vs-duration sizing, estimate_distinct(), and number() casting of string-typed numeric fields) are in `references/query-performance.md`.


Pre-deploy validation

The browser-renderer execution path, the parallel load test with its acceptance thresholds, and the deploy-and-verify sleep window are in `references/deployment.md`.


Field semantics: verify before grouping

Sample events per event ID before grouping: the same field name carries different meaning across Windows event IDs, and subject vs target is the classic trap. See `references/pre-authoring-discovery.md`.

  • Vendor parsers leave common fields absent. A panel that groups by an absent field shows one null row (or "0" on a bar chart). Measure coverage first: dataSource.name='<src>' tag != 'logVolume' | group n = count(), a = count(<field> != null) | limit 1. Seen live: Palo Alto THREAT logs carry the verdict in unmapped.action with action empty; Zscaler often has no src_endpoint.hostname (present on 259 of 1,023 events on one tenant); Mimecast URL Protect click events have no email.from (0 of 37); Netskope has no http_request.* fields. Guard with a fallback: | let host_v = (src_endpoint.hostname != null) ? src_endpoint.hostname : src_endpoint.ip (verified 2026-10-14).
  • Count "blocked" on a positive match, across vendor vocabularies. "Anything but allow" over-counts: Netskope logs alert, useralert, bypass and proceed verdicts, which outnumbered real blocks by about 60 to 1 on one tenant. Matching only block under-counts: Palo Alto writes Deny / deny. Match the whole set, case-insensitively: | let blocked = action matches '(?i)block|deny|drop|reject' (verified 2026-10-14: 134 of 1,156 Palo Alto events, equal to the Deny plus deny total).

Escalation ladder when a deployed dashboard hangs

The step-by-step escalation ladder for a deployed dashboard that hangs (session reset, hard refresh, dev-tools network check, isolate the slow panel, halve the panel count, diff against a working dashboard, roll back) is in `references/deployment.md`.


Pre-deploy checklist

The full pre-deploy checklist (pre-authoring, JSON structure, query hygiene, naming and semantics, performance and load, deployment, and post-deploy items, with scripts/panel_safety_check.py-scripted items marked) is in `references/deployment.md`.


Design tips

  • Use tabs for dashboards covering multiple topics (threat overview, policy changes, user activity). Keep each tab focused.
  • Start with number panels at the top for KPIs, then tables and charts below.
  • Avoid breakdown graphs in production dashboards: they can time out and can't be pre-cached. Use explicit labeled plots instead.
  • Lock layout with options: {"layout": {"fixed": 1}} to prevent accidental repositioning.
  • Use `showBarsColumn: "true"` on table panels with a count column to get inline bar charts.
  • Time range: set duration to match how "fresh" the data needs to be. Use "24h" for security operations / alert-triage dashboards (the standard for SOC real-time views), "7 days" for trend and capacity dashboards. Never default to "7 days" for an operations dashboard, analysts lose the short-window density that makes operational dashboards useful.
  • Test queries first with the powerquery skill before embedding them in dashboard JSON.
  • Use `estimate_distinct()` for cardinality counts on the event stream: exact distinct is expensive on large datasets. It is an approximation, measured at 14,196 against a true 14,043, so do not quote it as an exact figure. On | datasource inventory queries the chained-group form is cheap and exact, and is preferred: | group n = count() by assetName | group total = count().
  • Add a markdown panel to each tab explaining what it covers: this helps both users and future editors understand the dashboard at a glance.

Why the MCP tools and not a script in the sandbox

The Cowork sandbox blocks outbound HTTPS to *.sentinelone.net, so sdl_client.py or validate_dashboard.py run from the sandbox fail with SandboxProxyBlockedError or OSError: Tunnel connection failed: 403 Forbidden. Use the s1-secops-mcp tools (sdl_put_file, sdl_get_file, powerquery_run), which run on the user's machine. If a tool reports a missing credential, ask the user to run s1-secops-mcp setup on their machine.

All versions