← KQL Lens / API
Tokens

Driving KQL Lens from your own code

Everything the web page does is one HTTP call away. Base URL https://api.skillsafe.ai/v1/app-api. Every reply is {"ok": true, "data": {...}} or {"ok": false, "error": {"code", "message", "details"}} — check ok before reading data.

Pick a language once; the choice applies to every block on the page and is remembered.

The task field comes first

One app, one system prompt, one model — and three lanes over the same work object. task is required and decides which sections the reply carries. Send an unrecognised value and the model picks the closest lane and names its choice in lane; it never blends two contracts.

taskWhat it producesSections it must fillverdict
reviewWhat is wrong with this query, stage by stage.checks (all nine), findings, coverage_check, quick_wins, summaryship | tune | rewrite
optimizeThe query to run instead, written out in full.rewrite, rewrite_notes, checks, coverage_check, summarydescribes the ORIGINAL query
explainWhat an inherited query answers, stage by stage.stages, assumptions, open_questions, checks, summaryexplained

Every lane also fills the shared envelope: lane, title, verdict, headline, scan_risk, time_window, tables, stage_count, checks, coverage_check and summary.

The run input

These are the exact fields app.js submits, taken from its readForm().

FieldTypeMeaning
taskstring, requiredThe lane: review, optimize or explain. Documented in full above - it decides which sections the reply carries.
querystring, requiredThe KQL. The app masks identifiers and clips whole stages from the middle before sending; over the API you send whatever you like, but the same clipping advice applies.
schemastring, optionalTable schemas. When this is empty the schema-grounding and scan-volume checks come back unknown - by design, not by omission.
platformstringloganalytics, appinsights, adx, sentinel, resourcegraph or unknown. Changes which conventions apply - Resource Graph has no time column, so a time-window finding there would be wrong.
goalstring, optionalWhat the query is meant to answer.
contextstring, optionalFree-form notes.
prescan_factsobject, optional{facts, flags} from a client-side read. Every flags[].id you send must come back reconciled exactly once in coverage_check. Omit it and the reply simply has nothing to reconcile.
retry_notestring, optionalSent only on a reformat retry.

A complete body, as the review lane would send it:

{
  "task": "review",
  "query": "let lookback = 30d;\nSigninLogs\n| extend AppName = tostring(parse_json(tostring(Properties)).appDisplayName)\n| where tolower(AppName) contains \"payments\"\n| where ResultType != 0\n| join AppExceptions on $left.UserPrincipalName == $right.user_UPN\n| where TimeGenerated > ago(lookback)\n| summarize Failures = count() by UserPrincipalName, bin(TimeGenerated, 1h)",
  "schema": "",
  "platform": "loganalytics",
  "goal": "failed sign-ins per user per hour for the payments app",
  "context": "Runs for minutes on the workspace and the counts do not match the identity team's.",
  "prescan_facts": {
    "facts": {
      "stage_count": 8,
      "operators": {
        "extend": 1,
        "where": 3,
        "join": 1,
        "summarize": 1
      },
      "tables": [
        "SigninLogs",
        "AppExceptions"
      ],
      "time_window": "where TimeGenerated > ago(lookback) (via let lookback)",
      "window_hours": 720,
      "lets": [
        "lookback"
      ],
      "has_schema": false,
      "prescan_verdict": "rewrite"
    },
    "flags": [
      {
        "id": "late-time-filter",
        "label": "The time filter is not the first thing the query does",
        "severity": "high",
        "stage": 5,
        "evidence": "where TimeGenerated > ago(lookback)"
      },
      {
        "id": "join-no-kind",
        "label": "join with no kind= - the default is innerunique, not inner",
        "severity": "high",
        "stage": 4,
        "evidence": "join AppExceptions on $left.UserPrincipalName == $right.user_UPN"
      }
    ]
  }
}

The nine checks, and why two of them are always unknown without a schema

Each check declares where its evidence must come from. A check whose evidence is the query may fail definitively — if nothing in the pasted pipeline bounds the time range, that is a fail, because a stage that was not pasted cannot reach into a stage that was. A check whose evidence is the schema is forced to unknown when no schema was sent, whatever the model returned. The downgrade is applied in one place, so it cannot drift.

idEvidenceWhat would make it pass
time-windowqueryThe read is bounded by a filter on the time column, early.
filter-orderqueryRow-reducing filters run before parsing, extending, joining or sorting.
term-indexqueryString predicates use has / has_any / startswith / =~ rather than contains.
column-pruningqueryColumns are projected away before any join, summarize or serialize.
join-semanticsqueryEvery join names its kind= explicitly.
result-boundqueryThe query aggregates or bounds its output.
correctness-hazardsqueryNo stage silently changes what the answer means.
schema-groundingschemaEvery column and table named exists, with the assumed type.
scan-volumeschemaThe data actually read can be estimated.

1 A tiny client, and a token

A guest token is enough for /me and /estimate. Running a lane is metered and needs a personal token — sign in through the app, then copy it from the token page. Never hard-code a real token into a file you commit; the samples below use a YOUR_TOKEN placeholder.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H 'Content-Type: application/json' \
  -d '{"slug": "kql-lens"}'

# The body key is "slug". It is NOT "app_slug" - that returns a validation
# error, with the right route and the wrong payload, which is a slow thing to
# notice. The reply carries {"data": {"token": "aut_...", "guest_id": "..."}}.

2 Who am I, and what is my balance

subject_type is user or guest; credits is the balance in credits (10,000 credits = $1).

curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json'
# -> {"subject_type": "user", "credits": 128400, ...}

3 Price the lane — free, and it charges nothing

/estimate runs no job and costs nothing. hold_credits is the reservation, not the price: you are charged charged_credits at settlement, usually far less. The hold differs per lane, so estimate the lane you are about to run — never show one lane's price for another's run. The reply also proves the model binding: model reads gpt-5.6-terra, model_alias reads gpt-terra.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"task": "review", "query": "SigninLogs | where TimeGenerated > ago(1d) | count", "platform": "loganalytics"}'
# -> {"model": "gpt-5.6-terra", "model_alias": "gpt-terra",
#     "markup_bps": 1000, "hold_credits": ..., "min_credits": ...}

4 Run it, and poll

Send an Idempotency-Key on every run. The app derives it from (lane, input, attempt) — two lanes over the same query are two distinct runs and must not collide on one key, and a retry that reuses the key cannot double-bill. /run returns a job_id; poll /jobs/{id} until status is succeeded or failed. The model's text is at data.output.output.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"task": "review", "query": "SigninLogs | where TimeGenerated > ago(1d) | count", "platform": "loganalytics"}'
# Add:  -H 'Idempotency-Key: kql-lens:review:<hash>:a1'
# -> {"job_id": "job_..."}   then GET /jobs/job_...

5 Or stream it

/run-stream is the same call over SSE. Events are job, delta and done; concatenate the delta payloads to rebuild the reply. The app advances its progress card on the envelope's own section keys arriving in that stream.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"task": "review", "query": "SigninLogs | where TimeGenerated > ago(1d) | count", "platform": "loganalytics"}'
# Content-Type: text/event-stream
# data: {"type":"delta","text":"..."}

What comes back, per lane

One envelope, three fillings. A worked example of the substantive part of each:

review

{
  "lane": "review",
  "verdict": "rewrite",
  "headline": "The 30-day bound lands after the join and the join has no `kind=`, so this reads both tables over full retention and then counts `innerunique` fan-out - which is why its failure counts disagree with the identity team's.",
  "scan_risk": "wide",
  "time_window": "where TimeGenerated > ago(lookback) (via let lookback)",
  "tables": [
    "SigninLogs",
    "AppExceptions"
  ],
  "stage_count": 9,
  "checks": [
    {
      "id": "time-window",
      "status": "fail",
      "evidence": "| where TimeGenerated > ago(lookback) - stage 6, after two `extend`s and after the join.",
      "requirement": "`| where TimeGenerated > ago(lookback)` directly under `SigninLogs`, and a matching filter inside the join's right side."
    },
    {
      "id": "filter-order",
      "status": "fail",
      "evidence": "| extend AppName = tostring(parse_json(tostring(Properties)).appDisplayName) runs on the unreduced table; the only time bound runs after the join.",
      "requirement": "Time filter first, then the term filters, then the `extend`s that parse JSON."
    },
    "\u2026 7 more"
  ],
  "findings": [
    {
      "id": "KQ-001",
      "severity": "high",
      "stage": 0,
      "title": "Nothing bounds either table until after the join",
      "evidence": "SigninLogs",
      "why": "The only time bound is `| where TimeGenerated > ago(lookback)` at stage 6. Everything above it runs against full retention: the JSON parse on every row, both string filters, and both sides of the join's shuffle. Being late also makes it narrow. If `AppExceptions` carries its own `TimeGenerated`, the join renames the right-hand copy, so stage 6 filters the sign-in side only and the exceptions side stays unbounded no matter how long the query runs.",
      "fix": "Move the identical filter to sit directly under `SigninLogs`, and delete it from stage 6. The right side gets its own copy inside the join's parentheses - see KQ-002.",
      "fixed_fragment": "SigninLogs\n| where TimeGenerated > ago(lookback)"
    },
    {
      "id": "KQ-002",
      "severity": "high",
      "stage": 5,
      "title": "The join runs `innerunique`, so `Failures` is not a count of failed sign-ins",
      "evidence": "| join AppExceptions on $left.UserPrincipalName == $right.user_UPN",
      "why": "With no `kind=` the flavour is `innerunique`, which de-duplicates the LEFT side on the join key before joining. One arbitrary `SigninLogs` row survives per `UserPrincipalName`, and that single row is then paired with every matching `AppExceptions` row. So `Failures = count()` counts exception rows, not sign-in failures; every user collapses into the one hour bin belonging to whichever row survived; and `Countries = dcount(Country)` can only ever return 1, because only one left row is left to count countries from. This is the mismatch with the identity team.",
      "fix": "Say the flavour out loud, and make it a membership test rather than a pairing - \"is this user also throwing exceptions\" is a semi-join. `kind=leftsemi` keeps each left row exactly once and returns left columns only, so `count()` goes back to counting failed sign-ins. Bound and narrow the right side inside the parentheses. The numbers will change; the new ones are the ones the header comment asks for.",
      "fixed_fragment": "| join kind=leftsemi (\n    AppExceptions\n    | where TimeGenerated > ago(lookback)\n    | distinct user_UPN\n) on $left.UserPrincipalName == $right.user_UPN"
    },
    "\u2026 4 more"
  ],
  "coverage_check": [
    {
      "flag_id": "late-time-filter",
      "status": "confirmed",
      "note": "KQ-001. Agreed, and worse than \"not first\": stage 6 sits after the join, so it cannot bound the `AppExceptions` side at all."
    },
    {
      "flag_id": "dynamic-before-filter",
      "status": "confirmed",
      "note": "KQ-003. Two costs on one stage - it runs before any row reduction, and `parse_json(tostring(...))` re-parses a value that is already dynamic."
    },
    "\u2026 5 more"
  ],
  "quick_wins": [
    "Move `| where TimeGenerated > ago(lookback)` up to sit directly under `SigninLogs`, and delete it from where it is now.",
    "Write `kind=leftsemi` on the join. Today it is `innerunique`, which is why the counts do not match.",
    "\u2026 3 more"
  ],
  "summary": "This reads `SigninLogs` and `AppExceptions` over full retention: the only time bound is at stage 6, after the join, and it cannot reach the right-hand table. The join has no `kind=`, so it runs `innerunique`, which de-duplicates `SigninLogs` to one row per user before pairing - `Failures` is therefore a count of exception rows rather than of failed sign-ins, and `dcount(Country)` is always 1, which is exactly the disagreement with the identity team. Move the time filter under the table, make the join `kind=leftsemi` over a time-bounded `distinct user_UPN`, and project to the four columns the summarize reads before re-running."
}

optimize

{
  "lane": "optimize",
  "verdict": "rewrite",
  "headline": "The join carries no kind=, so it runs as innerunique and de-duplicates SigninLogs on UserPrincipalName before anything is counted - that, not the late time filter, is why Failures disagrees with the identity team.",
  "rewrite": "// On-call: which users are failing sign-in against the payments app,\n// and are those users also throwing exceptions in the app itself?\nlet lookback = 30d;\nSigninLogs\n| where TimeGenerated > ago(lookback)\n| where Properties has \"payments\"\n| where ResultType != 0\n| extend AppName = tostring(parse_json(tostring(Properties)).appDisplayName)\n| where AppName has \"payments\"\n| extend Country = tostring(LocationDetails.countryOrRegion)\n| project TimeGenerated, UserPrincipalName, AppName, Country\n| join kind=leftsemi (\n    AppExceptions\n    | where TimeGenerated > ago(lookback)\n    | distinct user_UPN\n  ) on $left.UserPrincipalName == $right.user_UPN\n| summarize Failures = count(), Countries = dcount(Country)\n    by UserPrincipalName, AppName, bin(TimeGenerated, 1h)\n| top 200 by Failures desc",
  "rewrite_notes": [
    {
      "change": "`| where TimeGenerated > ago(lookback)` is now the first stage after `SigninLogs`, moved up from stage 6 where it sat behind two `extend` stages and the join.",
      "buys": "The 30d bound now decides what is read instead of what survives. Every later stage - the JSON parse, the join, the aggregation - is multiplied by a row set that has already been cut to the window."
    },
    {
      "change": "Added `| where Properties has \"payments\"` on the raw column, before `parse_json` runs.",
      "buys": "`has` is a term predicate on a base column, so the term index does the first cut and `parse_json` is only paid for on rows that could match. This is a superset filter - 'payments' anywhere in `Properties` passes it - which is why the exact predicate is re-applied after the parse. It matches whole terms, so an app named 'PaymentsPortal' with no separator would be dropped here where the original kept it; if that is possible, use `contains \"payments\"` in this stage instead."
    },
    "\u2026 7 more"
  ],
  "coverage_check": [
    {
      "flag_id": "late-time-filter",
      "status": "confirmed",
      "note": "Agreed and it is the first thing the rewrite fixes: the filter is now the stage directly after `SigninLogs`, and the right side of the join gets the same bound, which the original never had."
    },
    {
      "flag_id": "dynamic-before-filter",
      "status": "confirmed",
      "note": "Agreed. `parse_json(tostring(Properties))` ran on every row of the unbounded read. In the rewrite it runs after the time bound, the `Properties has \"payments\"` term filter and `ResultType != 0`; the `LocationDetails` extend moved behind all filters too."
    },
    "\u2026 5 more"
  ],
  "summary": "This query reads `SigninLogs` with nothing bounding it, parses JSON on every row, then applies its 30d filter six stages in - after the join. That explains the wait. It does not explain the wrong numbers: the join names no `kind=`, so it runs as `innerunique`, de-duplicating the left side on `UserPrincipalName` before `count()` ever sees it, which makes `Failures` a count of exception rows per user rather than failed sign-ins. The rewrite moves the window and the term filters to the front, prunes to the four columns the aggregation reads, and states `kind=leftsemi` so the exception side decides who is included without touching the count - same output columns, same hourly grain, numbers that now match their names."
}

explain

{
  "lane": "explain",
  "verdict": "explained",
  "headline": "One row is one user, one app name and one hour, and its Failures number counts joined sign-in/exception pairs rather than sign-in failures; the whole result is every such user-app-hour bucket in the last 30d, biggest first, with no cap on how many rows come back.",
  "stages": [
    {
      "n": 0,
      "operator": "source",
      "purpose": "Names the table the pipeline reads: every sign-in record in the workspace.",
      "reads": "SigninLogs, all columns",
      "note": "The two comment lines and let lookback = 30d; above this are not pipeline stages. The let only binds a value, and that value is not used until stage 6, so nothing here restricts what is read."
    },
    {
      "n": 1,
      "operator": "extend",
      "purpose": "Lifts the application's display name out of the sign-in property bag into a column called AppName, so the next stages can filter and group on it.",
      "reads": "Properties; produces AppName",
      "note": "This runs on every row of an as-yet unbounded read, so the property bag is parsed for rows the query is about to throw away. tostring(Properties) then parse_json(...) also round-trips the value through a string; if Properties is already dynamic, Properties.appDisplayName reaches the same field without that detour."
    },
    "\u2026 7 more"
  ],
  "assumptions": [
    "No schema was pasted, so every column type is taken on faith from Log Analytics convention: SigninLogs carries TimeGenerated, UserPrincipalName, ResultType, Properties and LocationDetails, and AppExceptions carries user_UPN.",
    "Properties is assumed to be the sign-in property bag that holds appDisplayName, and LocationDetails the bag that holds countryOrRegion.",
    "\u2026 6 more"
  ],
  "open_questions": [
    "Is Failures meant to be the number of failed sign-ins, the number of exceptions, or the number of users? As written it is none of those - see stage 5 and stage 7.",
    "Should the join be kind=inner, so every failed sign-in is kept and paired with every matching exception, or kind=innerunique kept deliberately for a one-row-per-user view?",
    "\u2026 7 more"
  ],
  "summary": "This returns one row per user, per app display name, per hour for the last 30d, covering users who both failed a sign-in against an app whose name contains payments and appear in AppExceptions. The number in Failures is not the failure count: the join at stage 5 states no kind=, so the default innerunique keeps one arbitrary sign-in row per user before counting, and count() then counts surviving sign-in/exception pairs - which is why the figures disagree with the identity team's. Two further things a reader should know before touching it: the time bound is the sixth stage and never reaches the AppExceptions side, and the final sort has no bound, so the number of rows returned is whatever the client truncates at."
}

Error codes

codeHTTPWhat to do
UNAUTHORIZED401No token, or a token that does not belong to this app. Mint a guest token or sign in.
FORBIDDEN403The token is valid but not permitted here - a guest token on a metered run is the usual cause.
VALIDATION_ERROR400The body failed validation. error.details names the field. A missing task is the common one.
INSUFFICIENT_CREDITS402The balance is below min_credits for this lane. Estimate first.
RATE_LIMITED429Back off and retry. Do not tight-loop.
NOT_FOUND404Wrong slug, or a job id that has expired.
INTERNAL500Retry once with the same Idempotency-Key; the key makes the retry free.