All endpoints under /api/v1 require a bearer token from Settings → API tokens. Scopes are enforced; a token with no scopes is rejected.
Authorization: Bearer klp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Errors return { "error": "..." } with an appropriate HTTP status (400 invalid payload, 401 unauthorised, 403 forbidden, 404 not found).
| Scope | Allows |
|---|---|
cases:read / cases:write |
Read or create/update cases |
cases:override_closure |
Override case-closure policy requirements (sensitive; only grant to admin-issued tokens) |
case_views:read / case_views:write |
Read or manage saved case views, counts, widgets, and personal defaults |
tasks:read / tasks:write |
Read or create/update tasks |
observables:read / observables:write |
Search or add observables |
comments:read / comments:write |
Read or post comments |
threat_intelligence:read |
Search TI indicators and inspect feed state |
threat_landscape:read |
Read current Cloudflare Radar Threat landscape data |
briefing:read |
Read Cyber brief, watched vendors, and vendor matches |
case_relationships:read |
Read case relationships and duplicate/related suggestions |
case_relationships:write |
Link, unlink, and dismiss case relationships |
playbooks:read |
Read the playbook catalogue (baseline and custom) |
audit:read |
Search the organisation audit trail and read individual audit event detail (sensitive; only grant to admin-issued tokens) |
alerts:read |
Read alerts, linked entities, and evidence items |
alerts:write |
Create/link alerts, change alert disposition, link entities, and create/update evidence items |
alerts:raw_payload:read |
Read raw provider payload references behind alerts and evidence (sensitive; only grant to admin-issued tokens) |
attack:read |
Read the ATT&CK technique catalog, technique mappings, attack stories, and coverage |
attack:write |
Attach, update, and remove ATT&CK technique mappings and attack-story entries |
content_blocks:read |
Read structured investigation content blocks and revision history |
content_blocks:write |
Create, edit, archive, reorder, promote, and link structured investigation content blocks |
reports:read |
Read report templates, previews, export history, and download released reports |
reports:write |
Generate case reports, request release approval, and manage report schedules |
reports:admin |
Create/version report templates and approve report release (sensitive; admin-issued tokens) |
reviews:read |
Read post-incident reviews, revisions, follow-ups, knowledge articles, and improvement proposals |
reviews:write |
Create and edit post-incident reviews, follow-ups, knowledge articles, and improvement proposals |
reviews:admin |
Manage review templates, org review policy, and approve reviews (sensitive; admin-issued tokens) |
investigation:read |
List investigation console commands, execution history, and results |
investigation:execute |
Execute registered investigation commands, cancel runs, approve/reject writes, and save results as evidence |
Empty scopes grant nothing. A token whose scopes array is empty fails every scope check (403). Sensitive scopes (alerts:raw_payload:read, evidence:override, audit:read, cases:override_closure, reports:admin, reviews:admin) are never implied. Migration 0026_empty_token_scopes rewrites any pre-existing empty-scope tokens to an explicit non-sensitive set so ordinary integrations keep working without retaining those sensitive powers — re-issue tokens that intentionally need sensitive scopes from Settings after upgrading.
Optional query: status, severity, classification, tlp, assignee, openedSince, limit, source, technique, tactic. status=active returns every status except closed. source filters on the exact source_system value (e.g. ?source=tawny) — it is an equality match, not a prefix or substring search. technique filters to cases with at least one ATT&CK technique mapping matching that exact technique id (e.g. ?technique=T1566.001); tactic filters to cases with at least one mapped technique belonging to that exact tactic id (e.g. ?tactic=initial-access), evaluated against the currently active catalog version.
Results are filtered by the token actor's case compartment policy (issue #61). Cases the actor must not know exist are omitted entirely (no count or facet leak). When an actor has know_exists but not view_metadata, title is replaced with [redacted] and summary is null.
{
"title": "Phishing wave against finance team",
"summary": "Lure with fake DocuSign link",
"severity": "high",
"classification": "phishing",
"tlp": "amber"
}Returns 201 { "id": "case_...", "caseNumber": "KP-2026-0042" }.
Organisation is always derived from the bearer token; it can never be supplied in the request body.
Three optional fields let an external push producer — such as Tawny; see the Tawny integration guide — attach provenance to a case it creates, and get safe-to-retry delivery for free:
| Field | Type | Rules |
|---|---|---|
sourceSystem |
string | 1–64 chars, lowercase slug matching ^[a-z0-9][a-z0-9_-]*$. Reserved managed-connector namespaces (microsoft_sentinel, microsoft_defender_xdr) are rejected — those are owned by Kelpie's own pollers and always identify themselves as <kind>:<id>, which contains a colon and can never collide with a push producer's bare slug. |
sourceReference |
string | 1–200 chars. The producer's stable alert/incident ID. Requires sourceSystem to also be present in the same request — sending sourceReference alone returns 400. |
sourceUrl |
string | ≤2048 chars. Must use the http: or https: scheme only; URLs carrying embedded credentials (https://user:pass@host) are rejected. Stored in normalised form. |
Idempotency. When both sourceSystem and sourceReference are present, (organisation, sourceSystem, sourceReference) is enforced as unique by a partial unique index, so retried or concurrently delivered payloads for the same source reference converge on a single case rather than creating duplicates:
- First delivery for a given
(sourceSystem, sourceReference)→201with"created": true. - Any later delivery for the same
(sourceSystem, sourceReference)in the same organisation →200with"created": false, returning the existing case'sidandcaseNumberunchanged. - Two deliveries racing at the same instant resolve safely to one case: whichever write loses the database conflict is read back as the existing row and answered with
200, so no duplicate case is ever created. - The same
sourceReferencemay exist independently in different organisations without colliding — the uniqueness is scoped per organisation, not global. sourceSystemwithoutsourceReferenceis accepted: the case records provenance, but there is no idempotency key, so repeated deliveries that omitsourceReferenceeach create a new case.- Invalid or oversized source metadata returns
400 { "error": "Invalid payload", "details": { ... } }(common causes: a reservedsourceSystem,sourceReferencesent withoutsourceSystem, or asourceUrlthat is nothttp(s)or exceeds 2048 characters).
Example request carrying source fields:
{
"title": "Phishing wave against finance team",
"summary": "Lure with fake DocuSign link",
"severity": "high",
"classification": "phishing",
"tlp": "amber",
"sourceSystem": "tawny",
"sourceReference": "alert_9f2c1e",
"sourceUrl": "https://tawny.example.com/alerts/9f2c1e"
}First delivery — 201:
{ "id": "case_8k2n4qz", "caseNumber": "KP-2026-0042", "created": true }Replay of the same sourceSystem/sourceReference — 200:
{ "id": "case_8k2n4qz", "caseNumber": "KP-2026-0042", "created": false }Full case with embedded observables, tasks, and a recent_timeline slice (50 most recent events). Requires compartment view_metadata. Missing cases and cases the actor must not see both return 404 with the same body. Response includes:
custom_fields— values with sensitive fields redacted to"[redacted]"when the actor lacksview_sensitivecustom_fields_detail— per-field metadata includingsensitiveandredactedflagsaccess.permissions/access.accessPolicyVersion/access.visibilityMode
Requires compartment edit in addition to cases:write.
Visibility modes: organisation (default), selected_teams, explicit_members, restricted.
Independent permissions: know_exists, view_metadata, view_sensitive, edit, export, administer_access.
Deny by default on policy failure. Assigning or mentioning a user never grants access. Export is evaluated independently of view. Break-glass always requires a reason, always expires, is fully audited, and emails organisation admins.
Returns visibility mode and the caller's effective permissions. Full grant/compartment detail requires administer_access.
Change visibility mode. Requires administer_access + cases:write.
{
"visibilityMode": "selected_teams",
"teamIds": ["team_…"],
"memberIds": ["user_…"],
"reason": "Legal hold need-to-know scope"
}Create a reason-required grant (optional expiresAt, optional object scope).
{
"subjectType": "user",
"subjectId": "user_…",
"permissions": ["know_exists", "view_metadata", "view_sensitive"],
"reason": "Lead investigator assignment",
"expiresAt": "2026-12-01T00:00:00.000Z",
"objectType": "case"
}{ "reason": "Investigation hand-off complete" }Emergency self-grant for a user-backed token. Default TTL 4 hours (max 24h).
{ "reason": "Active containment decision requires case context" }Append-only access history (grants, revocations, break-glass, visibility changes). Never contains sensitive field values. Requires administer_access.
Any subset of status, severity, classification, tlp, pap, assigneeId, title, summary. Status transitions stamp the lifecycle milestones and fire the case.status_changed webhook.
status: "closed" is rejected here (400). Use POST /api/v1/cases/{id}/close so the shared closure validator runs. Reopening a closed case also requires POST /api/v1/cases/{id}/reopen with a reason.
Closes a case through the organisation (or template) closure policy. Same validator as the UI.
{
"disposition": "resolved",
"conclusion": "Contained and eradicated; recovery verified.",
"determination": "true_positive",
"rootCause": "Phishing credential harvest",
"businessImpact": "None material",
"lessonsLearned": "Faster MFA enrolment",
"approverId": "user_…",
"reviewedRelatedCaseIds": ["case_…"],
"postIncidentReviewCompleted": true,
"version": 3,
"override": false,
"overrideReason": null
}disposition(required):resolved|false_positive|duplicate|benign|risk_acceptedconclusion(required): analyst narrativeversion(optional): optimistic concurrency;409 version_conflictwhen stale- Unmet requirements →
422 { "error": "closure_requirements_not_met", "evaluation": { … } }with a per-requirement checklist (missinglists exact tasks/fields/alerts/…) - Privileged override: set
override: true+overrideReason(≥3 chars). Requirescases:override_closureon the token (or admin in the UI). When the policy version has two-person override,approverIdmust be a distinct admin in the same organisation. - Success returns
{ ok, version, snapshot_id, was_override, evaluation }. A closure snapshot is always persisted and retained across reopen.
{ "reason": "New IOC matched in TI feed", "nextStatus": "in_progress", "version": 4 }Prior closure snapshots stay on the case (stamped with reopenedAt / reopenReason). reason is required (≥3 chars).
Preview policy evaluation without mutating the case (cases:read). GET uses placeholder disposition fields; POST accepts the same disposition body as close (without override). Also returns historical snapshots on GET.
Cross-case task inbox. Optional query: status (open, a task status, or all), mine=true, limit. Tasks include case number, title, and severity.
{ "title": "Hunt for clicks", "description": "Auth log search", "dueAt": "2026-05-25T03:00:00Z" }Any subset of status, assigneeId, dueAt, title, description. Setting status: "done" stamps completedAt and writes the task_completed timeline event.
{ "type": "ip", "value": "198.51.100.42", "tlp": "amber", "isIoc": true }Adding an observable kicks off enrichment.
Cross-case search. With exact=true does an equality match; otherwise substring.
Requires playbooks:read. Read-only; lists this organisation's playbook
catalogue (baseline and custom). Optional query: scenario (baseline
catalogue key, exact match), classification, severity, tag (exact),
observableType (exact, one of the observable
types), q (search name/description), includeInactive
(true to include deactivated playbooks; default is active-only).
Returns { "playbooks": [ { "id", "name", "description", "classification", "defaultSeverity", "isActive", "tags", "requiredObservableTypes", "catalogueKey", "catalogueVersion", "isBaseline", "stepCount", "createdAt" } ] }.
isBaseline is true when the playbook has a catalogueKey (it originated
from the baseline catalogue), false for organisation-authored custom
playbooks — this is provenance only; a baseline playbook that has since been
edited by the organisation is still reported as baseline.
Requires playbooks:read. Returns the same fields as the list endpoint for
one playbook, plus its full steps (ordered task list with cadence offsets)
and content (purpose, triggers, exclusions, severity guidance, evidence to
preserve, initial questions, decision points, approval actions, closure
criteria, follow-up improvements, MITRE ATT&CK technique references, and
case fields to capture). See docs/playbooks.md for the full catalogue
structure and maintenance rules.
Typed, confirmed links between two cases (duplicate_of, related_to, parent_of/child_of), plus a scored duplicate/related-case suggestion feed. A relationship is always visible from both cases it connects — GET on either side returns it, oriented to that case's point of view. parent_of and child_of are the same stored edge viewed from opposite ends: linking with child_of (or reading it from the child case) is just the inverse presentation of a parent_of row, not a second edge. related_to is symmetric. All six endpoints below are gated on case_relationships:read (reads and suggestions) or case_relationships:write (link, unlink, dismiss); no separate cases:* scope is required.
A case in any status — including closed — can be linked or unlinked; doing so never changes the case's status.
Returns every confirmed relationship touching the case, most recent first.
{
"relationships": [
{
"id": "caserel_8k2n4qz",
"relationshipType": "child_of",
"direction": "incoming",
"confidence": 100,
"origin": "analyst",
"ruleId": null,
"ruleVersion": null,
"reason": "Same campaign, this case tracks the broader wave",
"createdBy": "user_9f2c1e",
"createdAt": "2026-07-28T02:14:00Z",
"otherCase": {
"id": "case_3ecfe70",
"caseNumber": "KP-2026-0041",
"title": "Parent incident: credential stuffing wave",
"status": "open",
"severity": "high"
}
}
]
}relationshipType and direction are always expressed from the perspective of the case in the URL: a parent_of edge appears as child_of/incoming when read from the child case, and as parent_of/outgoing from the parent case. related_to always reports direction: "symmetric". Returns 404 if the case does not exist in the caller's organisation.
{
"targetCaseId": "case_a1b2c3",
"relationshipType": "duplicate_of",
"reason": "Same phishing lure reported by two mailboxes",
"confidence": 90
}relationshipType is one of duplicate_of, related_to, parent_of, child_of. reason is required (non-empty after trimming). confidence (0–100), origin (analyst, provider, or rule; defaults to analyst), ruleId, and ruleVersion are optional — origin: "analyst" links default to confidence: 100 when omitted. Returns 201 with the created relationship in the same shape as the GET list, oriented from the case in the URL:
{ "relationship": { "id": "caserel_8k2n4qz", "relationshipType": "duplicate_of", "direction": "outgoing", "...": "..." } }Status codes:
400— missing/emptyreason, unknownrelationshipType, out-of-rangeconfidence, or the case linking to itself.403— token lackscase_relationships:write.404— either case does not exist in the caller's organisation (this includes cross-organisation link attempts, which never leak whether the target case exists elsewhere).409— the exact edge already exists, or a conflicting reverse-direction edge already exists for a directional type (e.g. linking Bparent_ofA when Aparent_ofB is already recorded).
{ "reason": "No longer related after review" }reason is required. Returns 200 { "ok": true }. Writes a relationship_removed timeline event on both cases (the timeline is append-only — the earlier relationship_created event is never edited or deleted). Returns 400 for a missing/empty reason, 403 without case_relationships:write, 404 if the relationship does not exist on this case in the caller's organisation.
Scores every other case in the organisation against this case (title similarity, shared observables, shared tags, shared vendor mentions) and returns likely duplicates/related cases that are not already linked or previously dismissed. limit is optional, 1–25, defaults to 10.
{
"suggestions": [
{
"candidateCase": {
"id": "case_7f0a12",
"caseNumber": "KP-2026-0055",
"title": "Widespread phishing campaign targeting payroll department",
"status": "open",
"severity": "medium"
},
"score": 78,
"matchedSignals": {
"titleSimilarity": 0.82,
"sharedObservables": ["203.0.113.77", "198.51.100.44"],
"sharedTags": ["phishing", "payroll"],
"sharedVendors": []
},
"suggestedType": "duplicate_of"
}
]
}score is 0–100. suggestedType is duplicate_of at score ≥ 70, otherwise related_to. Suggestions never cross organisations. Returns 404 if the case does not exist in the caller's organisation.
Same scoring as above, but for a case that has not been created yet — used by the new-case form to surface likely duplicates before submit.
{
"title": "Widespread phishing campaign targeting payroll staff",
"summary": "Lure with fake DocuSign link",
"tags": ["phishing", "payroll"]
}Returns 200 with the same { "suggestions": [...] } shape as the per-case suggestions endpoint. title is required; summary and tags are optional.
{ "candidateCaseId": "case_7f0a12", "reason": "Reviewed — unrelated despite the overlap" }Records that an analyst reviewed and rejected this pairing so it stops appearing in either case's suggestions. reason is required. Returns 200 { "ok": true }. Returns 400 for a missing/empty reason or a candidate equal to the case itself, 403 without case_relationships:write, 404 if either case does not exist in the caller's organisation.
A case is an investigation container around independently addressable alerts (one detection each), entities (deduplicated users/devices/mailboxes/IPs/domains/URLs/files/hashes/processes/cloud resources/applications/tenants/networks/generic assets), and evidence items (indicators, log excerpts, findings — investigation-level records, distinct from the binary attachment storage documented separately). One case can hold many alerts; one alert can be linked to many entities and evidence items.
Field ownership. Every alert and evidence item column is one of three kinds, and the API enforces the boundary rather than just documenting it:
- Provider-owned (
title,description,detectionSource/detectionProduct,classification,severity— until an analyst overrides it,detectedAt,sourceUrl,normalizedFields,attackTechniqueson alerts;source,firstSeenAt/lastSeenAton evidence items): refreshed every time the owning connector re-syncs. A provider re-sync is always safe to replay — it never overwrites analyst work. - Analyst-owned (
status,determination,assigneeId,analystNotes,dismissedReasonon alerts;verdict,remediationState,analystNoteson evidence items): only ever change through thePATCHendpoints below. Once an analyst setsseverityon an alert, that override sticks — later provider syncs skip that one field but keep refreshing everything else. - Derived (alert
derivedFields): recomputable, and always carries{ value, method, computedAt }provenance rather than a bare number.
Raw provider payloads are never inlined in an alert, evidence item, or timeline event — they're bounded to 256KB, redacted (secret-shaped keys are stripped before storage, same redaction as the audit trail), and stored as a separate reference. Reading one back requires the separate, sensitive alerts:raw_payload:read scope (admin-issued tokens only) via GET /api/v1/alerts/{id}/raw-payload; it is never present in a list or detail response.
Pagination. All three list endpoints below (GET .../alerts, GET .../entities, GET .../evidence-items) use the same opaque keyset cursor as GET /api/v1/audit-events: limit (default 50, maximum 200) and cursor (from a previous response's nextCursor, null once exhausted). Ordering is most-recently-created first, so pages stay stable under concurrent inserts.
Ownership and isolation. Every alert, entity, evidence item, and link is scoped to the caller's organisation exactly like cases — a GET/PATCH/POST against an id from another organisation returns 404, never a permission error that would confirm the id exists elsewhere. (organisationId, sourceId, tenantId, externalId) is unique on alerts, so re-polling a connector never creates a duplicate; (organisationId, type, canonicalKey) is unique on entities, so the same user/device/hash resolves to one row regardless of how many alerts mention it.
Concurrency. Alerts carry an optimistic version counter, guarded the same way as case fields: pass the version you last read in a PATCH body and a conflicting concurrent write returns 409 { "error": "version_conflict", "current": { ...the alert as it now stands... } } instead of silently clobbering another analyst's change. Omitting version skips the guard (last write wins on analyst-owned fields only — provider-owned fields are never touched by this endpoint).
Reads use the alerts:read scope; every mutation below uses alerts:write (this single pair covers alerts, entities, and evidence items — there is no separate entities:* or evidence_items:* scope).
Either links an existing alert into the case — { "alertId": "alert_...", "isPrimary": true } — or creates a new manually-authored alert and links it in one call:
{ "title": "Suspicious sign-in", "severity": "high", "description": "Impossible travel flagged" }Linking is idempotent: linking an already-linked alert again returns the existing link rather than erroring or duplicating a timeline event. Returns 201 with { "alert": { ... }, "link": { ... } }.
Any subset of status (new, in_progress, closed, dismissed), determination (unknown, true_positive, false_positive, benign_positive), severity (setting this always marks it analyst-overridden), assigneeId, analystNotes, dismissedReason, plus optional version for the concurrency guard described above. Writes alert_status_changed, alert_verdict_changed, and/or alert_assigned timeline events on every case the alert is currently linked to, for whichever fields actually changed.
Resolves (or creates) an entity from one or more identifiers and links it to the alert with a role (actor, target, impacted, related):
{
"type": "user_identity",
"displayName": "sam.analyst@example.com",
"role": "actor",
"identifiers": [{ "kind": "email", "value": "sam.analyst@example.com" }]
}Entity resolution is type-aware: email/upn/hostnames/hashes normalise to lower case, SIDs to upper case, before matching. Linking is idempotent per (alert, entity, role) and writes an alert_entity_linked timeline event on every case the alert is linked to.
Sensitive; requires alerts:raw_payload:read. Returns 404 if the alert has no rawPayloadRefId, or if the reference does not exist in the caller's organisation.
Entities aggregated across every alert currently linked to the case, most-recently-seen first.
{ "type": "observable", "value": "203.0.113.9", "alertId": "alert_...", "confidence": 80 }type is required; alertId, entityId, and attachmentId (linking to a binary attachment) are all optional and independent. confidence is 0–100. Writes an evidence_item_created timeline event.
Any subset of verdict (unknown, clean, suspicious, malicious), remediationState (none, pending, remediated, not_applicable), analystNotes. Writes evidence_item_verdict_changed and/or evidence_item_remediation_changed timeline events for whichever fields actually changed.
{ "targetEvidenceId": "evitem_...", "relationshipType": "related_to", "reason": "Same campaign" }relationshipType is related_to, duplicate_of (both symmetric — canonicalised the same way as case relationships, so linking B to A afterwards returns 409 rather than a second edge), or derived_from (directional — A derived_from B and B derived_from A are independent facts). Both evidence items must belong to the same case. Writes an evidence_relationship_created timeline event.
Cases created before this model existed (via sourceSystem/sourceReference, e.g. from Microsoft Sentinel/Defender XDR import) are backfilled by npm run backfill:alerts: for every such case with no alert yet, it creates (or reuses) an alert_sources row for that source, an alerts row that preserves the exact sourceSystem as detectionSource and sourceReference as the alert's immutable externalId, and links it into the case as the primary alert. The script is idempotent — re-running it after new source-backed cases appear only backfills the ones still missing an alert; it never creates a duplicate.
Analyst-governed correlation proposes transparent groupings; it never silently merges cases. Scopes: correlation:read, correlation:write.
Signals (organisation rule config, versioned): shared canonical entities, shared observables, provider/source incident id, detection product/family, time window, tenant, ATT&CK techniques. Every suggestion stores score, contributing signals, rule key/version, generated time, and status (pending / accepted / rejected / expired / auto_applied).
Governance. Rules default to dryRun: true. Organisation policy settings.correlation.autoMergeEnabled defaults to false. Automatic apply only when policy is on, the rule is not dry-run, and (optionally) score ≥ autoAcceptThreshold. Move, merge, split, and suggestion rejection require a reason and are audited. Case merge never deletes sources — they stay navigable with supersededByCaseId pointing at the canonical case. Reverse is allowed until reverseDeadline (default 24h) when no incompatible downstream mutation blocks it.
Concurrency. Pass optional expectedVersions: { "<caseId>": <version> } on mutating calls. Mismatch returns 409 { "error": "version_conflict", "current": { ... } }.
GET/POST /api/v1/correlation/rules— list / create versioned rulesGET/PATCH /api/v1/correlation/rules/{id}— fetch / update (active material changes supersede and insert a new version)GET/PATCH /api/v1/correlation/policy—{ autoMergeEnabled, autoAcceptThreshold, mergeSafetyWindowHours }GET /api/v1/correlation/metrics?ruleKey=— suggestion / accept / reject / auto-applied countsPOST /api/v1/correlation/dry-run— preview pairs without persisting (correlation:read)POST /api/v1/correlation/evaluate— persist suggestions (and auto-apply only if policy allows)
GET /api/v1/correlation/suggestions?status=pending&caseId=GET /api/v1/cases/{id}/correlation-suggestionsGET /api/v1/correlation/suggestions/{id}POST /api/v1/correlation/suggestions/{id}with{ "action": "accept"|"reject", "reason": "..." }
POST /api/v1/correlation/attach—{ caseId, alertIds, reason }POST /api/v1/correlation/moves—{ fromCaseId, toCaseId, alertIds, reason }POST /api/v1/correlation/create-case—{ alertIds, reason, title? }POST /api/v1/correlation/splits—{ fromCaseId, alertIds, reason, title? }POST /api/v1/correlation/merges—{ canonicalCaseId, sourceCaseIds, reason }POST /api/v1/correlation/merges/{id}/reverse—{ reason }GET /api/v1/alerts/{id}/membership-history— immutable lineage (correlation:readoralerts:read)
Moves preserve alert source ids/entities; evidence items tied to moved alerts follow the destination case. Timeline events: alert_linked_to_case / alert_unlinked_from_case (with correlation payload), case_merged, case_merge_reversed, correlation_suggestion_accepted, correlation_suggestion_rejected.
Kelpie ships a versioned, organisation-independent ATT&CK Enterprise technique catalog (a bundled offline baseline snapshot by default; an administrator can refresh it from a configured URL under Settings, which runs through BullMQ and is rolled back automatically on failure). Analysts attach techniques to a case, alert, observable, evidence item, or task, recording confidence, source, notes, detection notes, response notes, and analyst-entered actor attribution as separate fields. Kelpie never infers actor attribution automatically. An alert mapping is linked to a case for timeline/audit purposes via its case_alerts link (preferring the alert's primary case, otherwise its most recently linked case); a mapping on an alert not yet linked to any case still succeeds — it is recorded on the organisation audit trail without a case timeline entry.
Search the active catalog version. q matches technique id or name substring. tactic is an exact ATT&CK tactic id (e.g. lateral-movement). Deprecated techniques are excluded unless includeDeprecated=true — deprecated techniques remain in the catalog and readable on historical mappings, they just don't surface in the default search.
Returns every mapping touching a case (the case's own mapping plus its linked alerts/observables/evidence/tasks) or the mappings on one specific entity.
{
"entityType": "observable",
"entityId": "obs_...",
"techniqueId": "T1566.001",
"confidence": 80,
"source": "analyst",
"notes": "Matches the phishing lure",
"detectionNotes": "Flagged by mail gateway attachment sandbox",
"responseNotes": "Blocked sender domain, reset affected mailbox credentials",
"actorAttribution": "Suspected commodity phishing kit, not attributed to a named actor"
}Duplicate mappings (same organisation, entity, and technique) are rejected with 409. Every create/update/remove is recorded on the case timeline and the organisation audit trail.
Update or remove a mapping. PATCH accepts any subset of confidence, source, notes, detectionNotes, responseNotes, actorAttribution.
Organisation-wide coverage: mapped techniques grouped by tactic, mappings still missing detection/response notes ("unresolved work"), and playbook/case-template coverage broken down by investigation/detection/containment/recovery guidance category.
{ "title": "Initial phishing click", "provenance": "analyst", "techniqueId": "T1566.001" }Entries are ordered by an explicit sequenceIndex set by whoever adds/reorders them; occurredAt is optional contextual timing only and is never used to infer order.
Send { "targetIndex": 2 } to reorder, or any subset of title, description, sourceRef, occurredAt to edit.
Typed investigation graph for a case (issue #65). Nodes and structural edges are derived from already-stored data (case_alerts, alert_entities, evidence_items / evidence_relationships, ATT&CK mappings, attack-story entries). Analyst/provider/rule edges with full provenance live in investigation_graph_edges — presentation never invents unsupported relationships.
Every edge exposes confidence (0–100 or null when unknown), provenance (provider | analyst | rule), source, optional observed time range, and creatorId.
Authorisation uses case compartments: authorizeCase + resolveTokenActor. Restricted/sensitive nodes are omitted entirely (no count, topology, label, or export leak).
Scopes: cases:read for graph/export; cases:write for creating/removing stored edges. Export additionally requires compartment permission export.
Node types: case, alert, identity, device, mailbox, file, process, ip, domain, url, cloud_resource, evidence, technique, email_message, application, tenant, network, asset, other.
Edge types: observed_on, communicated_with, executed, downloaded, sent_by, received_by, authenticated_to, resolved_to, parent_process, triggered_alert, belongs_to_case, related_to, derived_from, duplicate_of, maps_to_technique.
Query:
| Param | Meaning |
|---|---|
nodeTypes |
Comma-separated node types to keep |
minConfidence |
Hide edges with known confidence below this (0–100). Null-confidence structural edges still show |
view |
graph (default), story, tactic_lanes, evidence |
nodeLimit |
Progressive node cap (default 200, max 500) |
edgeLimit |
Progressive edge cap (default 500, max 2000) |
Returns { caseId, view, nodes, edges, story, tacticLanes, limits, counts, filters, generatedAt }.
storyis ordered by explicitsequenceIndexonly; entries with missing or out-of-orderoccurredAtsettimingAmbiguous+timingNote(clock/source ambiguity is visible; order never claims timestamp causality).tacticLanesgroups mapped techniques by ATT&CK tactic id.view=evidencekeeps evidence nodes and evidence–evidence edges (plus case anchor when not filtered out).countsreflect only access-visible nodes/edges after filters.
Create a stored provenanced edge (both endpoints must already exist on the case):
{
"sourceNodeType": "ip",
"sourceNodeId": "ent_…",
"targetNodeType": "domain",
"targetNodeId": "ent_…",
"edgeType": "resolved_to",
"confidence": 75,
"provenance": "analyst",
"source": "manual_investigation",
"observedAtStart": "2026-07-01T10:06:00.000Z",
"observedAtEnd": null,
"reason": "PTR and passive DNS agree"
}provenance: "rule" requires ruleId. Returns 201 { "edge": { … } }. Duplicate unique edges return 409.
Removes a stored edge only (derived edges cannot be deleted). Returns { "ok": true }.
Requires compartment export. Query: format=json|text (default json), plus the same filter params as the graph GET.
format=json→{ "snapshot": { …graph… }, "text": "…" }format=text→text/plainattachment with the textual relationship list (provenance, confidence, observed range)
{
"d3fendTechniqueId": "D3-NTA",
"d3fendTechniqueName": "Network Traffic Analysis",
"attackTechniqueIds": ["T1071"],
"playbookId": "pb_...",
"notes": "Detect C2 beaconing during containment"
}Every mapping records catalogVersion (defaults to the bundled D3FEND baseline version) and must link to a playbook and/or a response action — entirely optional and administrator/analyst curated; Kelpie never infers a countermeasure link itself.
An organisation-wide, append-only log of who did what, when — every mutation across auth, team, settings, integrations, tokens, cases, tasks, observables, evidence, tags, fields, and jobs is recorded. audit_events rows can never be updated, and can never be deleted except by the organisation's own retention purge job — this is enforced by a database trigger, not just application code, so it holds even for admin routes. Sensitive fields (passwords, secrets, API keys, tokens, HMACs, and message/comment body content) are redacted to "[redacted]" before a row is ever written, and before/after snapshots only ever contain the specific keys that changed, never a whole request or response body. Both endpoints below require the audit:read scope.
Optional query: action, actorId, targetType, targetId, from, to (ISO 8601 timestamps, inclusive range over occurredAt), q (free-text match over action, target type/id/label, and actor label), limit (default 50, maximum 200), and cursor (opaque keyset pagination cursor from a previous response's nextCursor). Results are ordered most-recent-first (occurredAt descending, id descending as a tie-breaker), so pages stay stable under concurrent inserts.
{
"events": [
{
"id": "audit_8k2n4qz",
"action": "case.updated",
"targetType": "case",
"targetId": "case_3ecfe70",
"targetLabel": "KP-2026-0041",
"actorId": "user_9f2c1e",
"actorType": "user",
"actorLabel": "sam.analyst",
"requestId": "req_a1b2c3",
"sourceIp": "203.0.113.9",
"userAgent": "Mozilla/5.0 ...",
"before": { "status": "open" },
"after": { "status": "contained" },
"metadata": {},
"occurredAt": "2026-07-28T02:14:00Z"
}
],
"nextCursor": "MjAyNi0wNy0yOFQwMjoxNDowMFp8YXVkaXRfOGsybjRxeg"
}nextCursor is null once the last page has been reached. Pass it back as ?cursor= to fetch the next page with the same other filters.
Returns the full audit event, including the (already-redacted) before, after, and metadata payloads:
{ "event": { "id": "audit_8k2n4qz", "action": "case.updated", "...": "..." } }Returns 404 if the event does not exist in the caller's organisation — this includes an id that belongs to a different organisation, which never leaks whether the id exists elsewhere.
CSV/NDJSON exports of the audit trail are requested from the admin console at Settings → Audit (/settings/audit), not a v1 API endpoint. Every export always applies the exact same filters and permissions as the equivalent search — an export can never surface an event that the matching search call wouldn't have returned. Exports expire and are removed automatically after 7 days. Retention is configurable per organisation (Settings → Audit) down to a safe minimum of 90 days; a daily job purges events older than the configured window.
Returns matching indicators plus feed state. Optional query: value, exact,
type, feedId, tag, and limit (maximum 500).
type must be one of ip, url, file_hash, domain — Kelpie threat
intelligence covers actionable network and file indicators only. CVE and
vulnerability data are out of scope; an unsupported type value returns
400 with the supported list. Feed state includes each feed's last-run
ingested/skipped counts and a skip-reason breakdown (cidr, cve, email,
unrecognised, invalid_value) so unsupported records are visible rather
than silently dropped.
Returns Cloudflare Radar configuration state, update time, confidence, rolling
window, ranked origin/target locations, top attack routes, provider annotations,
partial-enrichment warnings, and percentage breakdowns for mitigation products,
HTTP methods and versions, IP versions, targeted sectors, and managed-rule
signals. Requires CLOUDFLARE_RADAR_API_TOKEN on the Kelpie app container.
Returns public cyber reporting with watched-vendor matches. Optional query:
q, source, vendor (catalog slug or watched), sort (newest,
oldest, source), page, and pageSize (maximum 100).
Kelpie exposes the same machine data over stateless Streamable HTTP:
POST https://your-kelpie.example/api/mcp
Authorization: Bearer klp_yourtoken
Accept: application/json, text/event-stream
Content-Type: application/json
Administrators should onboard agents from Settings → MCP agent setup rather
than assembling endpoint, scopes, client config, and instructions by hand. That
UI derives the public endpoint from the configured APP_URL, issues a
least-privilege token (secret shown once), and copies connection details,
client configuration, an AGENTS.md block, and the canonical LLM.txt prompt.
Tool-to-scope mappings in Settings are generated from the same catalogue the
MCP route serves (src/lib/mcp/catalogue.ts) so documentation cannot drift.
Configure an MCP client with the endpoint above and a Kelpie API token carrying one or more machine-data scopes. Available tools (canonical catalogue):
search_threat_intelligence—threat_intelligence:readget_threat_landscape—threat_landscape:readget_cyber_briefing—briefing:readlist_watched_vendors—briefing:readcase_relationships_list—case_relationships:readcase_relationship_suggestions_list—case_relationships:readevidence_list—evidence:readevidence_custody_list—evidence:readplaybooks_list—playbooks:readplaybooks_get—playbooks:readattack_techniques_search—attack:readattack_mappings_list—attack:readattack_coverage_get—attack:readattack_technique_attach—attack:write
Every tool except attack_technique_attach is read-only (see each tool's
readOnlyHint in tools/list). Tool discovery only returns tools permitted by
the token's scopes. playbooks_list/playbooks_get are the recommended way for
an agent to discover Kelpie's playbook catalogue; see /LLM.txt in the
repository root (also copyable from Settings → MCP agent setup and Guides) for
a full agent prompt, and docs/playbooks.md for the catalogue's structure and a
worked example.
{ "body": "VT result: malicious=12, suspicious=3. @sam.analyst please review" }@handle mentions trigger the same email path as the UI.
Promote a comment into a structured content block (content_blocks:write). Preserves the original author, original timestamp, source comment id, and promoting actor.
{
"type": "investigation_note",
"title": "Field observation"
}Returns 201 { "block": { ... } }. A second promotion of the same comment returns 409.
Ordered, versioned case content blocks for findings, decisions, and report sections. Separate from conversational comments. Body is sanitised Markdown (no active HTML). Revisions are append-only; restoring an earlier revision creates a new head rather than deleting later history. Reordering writes one timeline event for the whole operation.
Block types: investigation_note, finding, hypothesis, decision, evidence_summary, containment_record, eradication_record, recovery_validation, stakeholder_update, code_query, table, checklist, external_reference, report_section.
Link types (organisation- and case-authorised on write): alert, entity, evidence_item, task, attack_technique, attack_mapping.
Sensitive blocks default to includeInReport: false (conservative export). Reports include non-archived blocks with includeInReport: true and sensitive: false.
Optional query: includeArchived=true. Returns { "blocks": [...] } ordered by sequenceIndex. Each block embeds links and head revisionNumber.
{
"type": "finding",
"title": "Initial foothold",
"content": "Attacker used **valid accounts**.",
"tlp": "amber",
"pap": "amber",
"sensitive": false,
"includeInReport": true
}Returns 201 { "block": { ... } }.
Any subset of content fields, or one of the special actions:
| Field | Effect |
|---|---|
| (content fields) | Appends a new revision and updates the head |
targetIndex |
Reorders this block among active blocks (one timeline event) |
archive: true |
Soft-archives the block |
restoreRevision: N |
Restores revision N as a new head revision |
Soft-archives the block (same as PATCH with archive: true).
Append-only revision list ordered by revisionNumber.
{ "linkType": "task", "targetId": "task_..." }Returns 201 { "link": { ... } }. Cross-organisation or wrong-case targets return 404.
Reusable, versioned report templates with audience ceilings (TLP/PAP), redaction preview, BullMQ PDF/JSON generation, SHA-256 stamps, optional release approval, and scheduled generation into organisation export history. Files are organisation-scoped via the evidence storage abstraction. Unsafe Markdown/HTML is sanitised; redaction preview never reveals hidden content.
Variants: executive, technical, regulatory, post_incident.
Section keys: summary, metadata, tasks, observables, timeline, comments, evidence_inventory, ttp_mappings, attack_story, related_cases, custom_fields, investigation_blocks, post_incident_review, closure.
Baseline templates (seeded per organisation, idempotent by catalogueKey): executive summary, technical incident report, post-incident review, regulatory export.
Optional includeInactive=true. Seeds missing baseline templates, then lists active templates with current version sections and inclusion rules. Scope: reports:read.
Create a custom template (version 1). Scope: reports:admin.
Optional ?version=N to read a historical version. Includes version history list. Scope: reports:read.
Metadata updates in place; changes to sections/rules/ceilings/approval insert a new immutable version. Scope: reports:admin.
{
"templateId": "rpt_...",
"format": "pdf",
"sectionOverrides": { "comments": true, "observables": false }
}Returns selected sections, data revision, content fingerprint, redaction summary (included/excluded/masked — no hidden raw values), and a Markdown preview. Scope: reports:read.
{
"templateId": "rpt_...",
"format": "json",
"processInline": false
}Creates an export (pending → BullMQ generate-case-report). When requireApproval on the template version is true, status becomes awaiting_approval after render; otherwise completed. Set processInline: true only for tests/offline workers. Scope: reports:write. Responses never include storageKey.
Export history for the case. Scope: reports:read.
Export status plus pending approval binding (if any). Scope: reports:read.
{ "decision": "approve" }or "reject". Re-checks live case data revision against the bound fingerprint; invalidates if data/template binding drifted (409). Scope: reports:admin. Token must have been issued by a user (createdBy).
Downloads the file when status is completed or released. Verifies SHA-256. Header x-kelpie-sha256 echoes the digest. Scope: reports:read.
{
"templateId": "rpt_...",
"format": "pdf",
"intervalMinutes": 1440
}Destination is always { "kind": "export_history" } — arbitrary external destinations are out of scope. Worker job run-report-schedules re-checks template activity and case membership at execution time. Scope: reports:write.
Versioned post-incident review templates and case reviews with immutable revisions, approval that binds an exact revision fingerprint, follow-up actions (separate from incident-response case_tasks), knowledge article stubs, and playbook/detection improvement proposals.
Operational case closure and review completion are independent: a case may close while a required review stays draft / in_progress / pending_approval. Org policy (severity / classification / require-all) and per-template required severities/classifications determine whether a review is required.
Knowledge summaries exclude sensitiveEvidenceNotes and restrictedNotes by default. Including sensitive content requires case compartment view_sensitive (via authorizeCase) and an explicit includeSensitive: true opt-in.
Scopes: reviews:read, reviews:write, reviews:admin (sensitive).
Read or replace organisation policy (enabled, requireBySeverities, requireByClassifications, requireForAllCases, dueDaysAfterClose). PUT requires reviews:admin.
List (seeds baseline) or create templates. POST requires reviews:admin.
Read or update. Section / approval changes insert a new immutable template version.
List or create a review for a case. Create stamps requiredByPolicy + dueAt from policy evaluation. Scope: reviews:read / reviews:write.
Org-wide list (?status=&overdue=true&limit=) or single review with current/approved revision metadata.
{ "content": { "incidentSummary": "…", "knowledgeSummary": "…", "sensitiveEvidenceNotes": "…" } }Saves content. If the current revision is approved (or review is approved/published/pending_approval), creates a new unapproved revision and moves status to in_progress.
Moves review to pending_approval. Scope: reviews:write.
{ "decision": "approved", "notes": "optional" }or "rejected". Approval binds revision.id + contentFingerprint on the revision row. Scope: reviews:admin.
Immutable revision history including bound fingerprints and approval metadata.
Follow-up actions with owner, due date, theme, optional external ticket ref. Lifecycle is independent of case_tasks.
Publish a knowledge article stub from the approved (or current) revision. Default redacts sensitive fields. Body: { "title?", "includeSensitive?", "status?" }.
List or read knowledge articles. Sensitive body fields are stripped when the actor lacks view_sensitive.
Playbook revision / detection improvement / control gap proposals linked back to the source review and case. External tickets are references only — Kelpie audit history remains authoritative.
Summary: overdue reviews, open required reviews, reviews still open after case close, overdue/open follow-ups, recurring themes, improvement counts by kind.
Restricted, case-scoped portal for IT owners, vendors, legal, HR, and customers (issue #63). External parties are not organisation members and do not use BetterAuth staff sessions.
List invitations for a case. Scope: cases:read. Requires case view_metadata (compartment-aware); missing/forbidden cases return the same 404 shape.
{
"email": "vendor@example.com",
"displayName": "Vendor SOC",
"role": "evidence_provider",
"purpose": "Upload firewall logs for KP-2026-0042",
"maxTlp": "amber",
"maxPap": "amber",
"expiresInHours": 72,
"singleUse": true
}Roles: update_reader | evidence_provider | respondent | approver.
Returns 201 with { "id", "token", "expiresAt", "role", "status" }. The plaintext token (prefix kstk_) is shown once; only a SHA-256 hash is stored.
Sharing is denied (403) when:
- the inviter lacks case export permission (compartment / restricted visibility), or
- case TLP/PAP exceeds the invitation ceiling.
Scope: cases:write.
Analyst preview of the exact redacted external view. Scope: cases:read. Path caseId must match the invitation’s case; mismatch → 404.
Revoke invitation; optional body { "reason": "..." }. Requires case edit. Path caseId must match the invitation’s case (prevents cross-case revoke by id). Immediately revokes all active external sessions for that invite. Scope: cases:write.
External sessions use token prefix ksts_ via Authorization: Bearer or the kelpie_stakeholder_session cookie. These tokens never grant staff access.
Session cookie is dual-path (Path=/portal and Path=/api/portal) so it covers the portal UI and portal APIs without a site-wide Path=/ scope. Prefer Authorization: Bearer for API calls.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/portal/accept |
Exchange invite token → session ({ "token": "kstk_..." }). Response: { ok, role, expiresAt, sessionToken } — no raw case UUID |
GET |
/api/portal/me |
Redacted case portal view (no org/member enumeration) |
POST |
/api/portal/responses |
Post external response (respondent) |
POST |
/api/portal/updates/{updateId}/read |
Read receipt |
POST |
/api/portal/evidence-requests/{id}/upload |
Multipart file upload (evidence_provider) — same quarantine/custody pipeline as #44 |
POST |
/api/portal/approvals/{id} |
{ "decision": "approved" | "rejected", "note"? } |
POST |
/api/portal/logout |
Revoke current session |
UI entry: /portal?token=kstk_... (email bootstrap only). Security note: query-string invite secrets can leak via Referer, proxy logs, and browser history. The UI POSTs the token to /api/portal/accept then strips token from the URL via history.replaceState. Prefer delivering links over channels that support fragment or one-time POST where possible; do not put invite secrets in staff-facing analytics.
Invalid, expired, revoked, and replayed tokens all return the same 401. Single-use accepts claim the invite with an atomic UPDATE … WHERE status = 'pending' RETURNING before minting a session. Wrong object IDs return 404 (no existence oracle). When case classification exceeds the invite ceiling, external view redacts title, severity, and status. External contributions are attributed as source: "external" on the case timeline and in reports.
Configure under Settings → Outbound webhooks. Each delivery is signed:
X-Kelpie-Event: case.status_changed
X-Kelpie-Signature: sha256=<hex-hmac>
X-Kelpie-Delivery: wd_...
Content-Type: application/json
{ "event": "case.status_changed", "payload": { "case_id": "case_...", "to": "contained" } }
Verify in your receiver:
const sig = req.headers["x-kelpie-signature"];
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) reject();Retries: 1m, 5m, 30m, 2h, then failed. The last 50 deliveries per webhook are retained.
The breach checker, webhook delivery, and enrichment runners are simple HTTP endpoints protected by CRON_SECRET:
POST /api/cron/sla
POST /api/cron/webhooks
POST /api/cron/enrichment
POST /api/cron/ti
POST /api/cron/case-sources
POST /api/cron/mobile-push
Authorization: Bearer ${CRON_SECRET}
A separate scheduler (Docker Compose sidecar, cron, k8s CronJob) hits each once per minute.
The iOS companion signs in against the same BetterAuth account and receives a 30-day, role-scoped bearer token. Tokens for read_only users contain read scopes only.
{ "email": "analyst@example.com", "password": "..." }Returns token, expiresAt, scopes, and the user/organisation summary. Accounts requiring SSO, MFA, onboarding, or a password reset return a specific 403 error so the app can direct the user to the web console.
Validates the current mobile bearer token and returns its user and scopes.
Revokes the current mobile bearer token.
{ "token": "<APNs token as hex>", "environment": "sandbox" }Uploads the current APNs token. The app sends this on every APNs registration callback because device tokens can change.
{ "token": "<APNs token as hex>" }Disassociates the device during sign out.
The push outbox routes sla_breach and comment_mention events. Configure APNS_TEAM_ID, APNS_KEY_ID, APNS_PRIVATE_KEY, and the server-controlled APNS_BUNDLE_ID; the authenticated /api/cron/mobile-push worker delivers pending messages over APNs HTTP/2 and deactivates tokens rejected with HTTP 410.
Organisation-scoped business context for assets, identities, applications, and business services. Used by explainable case priority scoring (separate from source severity). Provider updates never overwrite analyst override fields.
Optional query: kind, criticalOnly=true, crownJewelOnly=true, limit.
Upsert a context record (REST provider). Body includes kind, displayName, primaryIdentifierKind, primaryIdentifierValue, and optional criticality / privilege / exposure fields. Returns 201 when created, 200 when updated. Ambiguous entity matches return matchReviewId instead of auto-linking.
Set or clear analyst overrides (criticalityOverride, privilegeLevelOverride, exposureOverride, isCrownJewelOverride, recoveryPriorityOverride). Pass null to clear an override.
CSV dry-run/import and provider batch import:
{ "source": "csv", "dryRun": true, "csvText": "kind,display_name,..." }{ "source": "entra", "dryRun": false, "users": [{ "id": "...", "userPrincipalName": "a@b.com", "displayName": "A" }] }{ "source": "defender", "dryRun": false, "devices": [{ "id": "...", "deviceName": "wkstn-01" }] }{ "source": "cmdb", "dryRun": true, "records": [{ "externalId": "1", "kind": "asset", "displayName": "db1", "identifierKind": "hostname", "identifierValue": "db1" }] }CSV required columns: kind, display_name, identifier_kind, identifier_value. Optional: criticality, privilege_level, exposure, environment, is_crown_jewel, recovery_priority, owner_team, owner_email, business_service, application_name, data_classifications, regulatory_scope, external_id.
Pending ambiguous entity matches.
{ "reviewId": "mrev_...", "action": "link", "entityId": "ent_..." }or { "reviewId": "mrev_...", "action": "dismiss" }.
Explainable priority score with factors, weights, calculation version, and linked critical contexts. Also accepts cases:read.
Recalculate. Analyst score overrides are preserved.
{ "score": 90, "reason": "Crown jewel + active exfil" }Pass "score": null to clear the override.
Organisation scoring settings: enabled, bounded weights, staleContextPolicy (discount | exclude | include), staleAfterHours.
Named, shareable case-list configurations (filters, sort, columns, page size, optional SLA/workload widgets, bulk-action shapes). Visibility is personal, team, or organisation. Queries, counts, and widgets are always organisation-scoped; counts use complete aggregates, not the current page. Bulk presets never store case IDs, never auto-execute, and never skip confirmation or permissions.
List views the token's creator can access. Requires case_views:read.
{
"name": "Critical open queue",
"description": "High urgency triage",
"visibility": "personal",
"config": {
"status": "open",
"severity": "critical",
"sort": "priority",
"pageSize": 25,
"columns": ["number", "title", "severity", "sla", "assignee"],
"widgets": ["sla_summary", "workload_summary"],
"bulkPresets": [
{
"id": "mark-high",
"name": "Mark high",
"operationType": "set_severity",
"params": { "severity": "high" }
}
]
}
}visibility: "team" requires teamId. Organisation views must be created by an admin session (API tokens are limited to personal/team). Unknown filter/widget/action fields return 400. Requires case_views:write.
Optional body: { "name", "visibility", "teamId" }. Defaults to a personal copy.
Complete inbox-style count: { "count": { "total", "active", "critical", "high" } }.
Bounded widgets configured on the view (severity_breakdown, status_breakdown, sla_summary, workload_summary), computed from the same full filter query.
{ "presetId": "mark-high", "caseIds": ["case_…"] }Re-resolves targets to the token's organisation and returns impact preview. Does not execute.
Personal default only via API tokens. Body: { "scope": "personal", "viewId": "cview_…" | null }. Role/team defaults require an admin session.
Empty scopes grant nothing. A token whose scopes array is empty fails every scope check (403). Sensitive scopes (alerts:raw_payload:read, evidence:override, audit:read) are never implied. Migration 0026_empty_token_scopes rewrites any pre-existing empty-scope tokens to an explicit non-sensitive set so ordinary integrations keep working without retaining those sensitive powers — re-issue tokens that intentionally need sensitive scopes from Settings after upgrading.
| Scope | Allows |
|---|---|
cases:read / cases:write |
Read or create/update cases |
tasks:read / tasks:write |
Read or create/update tasks |
observables:read / observables:write |
Search or add observables |
comments:read / comments:write |
Read or post comments |
threat_intelligence:read |
Search TI indicators and inspect feed state |
threat_landscape:read |
Read current Cloudflare Radar Threat landscape data |
briefing:read |
Read Cyber brief, watched vendors, and vendor matches |
case_relationships:read |
Read case relationships and duplicate/related suggestions |
case_relationships:write |
Link, unlink, and dismiss case relationships |
playbooks:read |
Read the playbook catalogue (baseline and custom) |
audit:read |
Search the organisation audit trail and read individual audit event detail (sensitive; only grant to admin-issued tokens) |
alerts:read |
Read alerts, linked entities, and evidence items |
alerts:write |
Create/link alerts, change alert disposition, link entities, and create/update evidence items |
alerts:raw_payload:read |
Read raw provider payload references behind alerts and evidence (sensitive; only grant to admin-issued tokens) |
attack:read |
Read the ATT&CK technique catalog, technique mappings, attack stories, and coverage |
attack:write |
Attach, update, and remove ATT&CK technique mappings and attack-story entries |
integrations:read |
Read integration health, open sync conflicts, and support-safe diagnostics |
integrations:write |
Pause/resume connections, run connection tests, resolve sync conflicts, toggle outbound writes |
case_views:read |
Read saved case views, complete counts, widgets, and bulk-preset previews |
case_views:write |
Create, update, delete, duplicate, and set personal defaults for saved case views |
investigation:read |
List investigation console commands, execution history, and results |
investigation:execute |
Execute registered investigation commands, cancel runs, approve/reject writes, and save results as evidence |
Empty scopes grant nothing. A token whose scopes array is empty fails every scope check (403). Sensitive scopes (alerts:raw_payload:read, evidence:override, audit:read) are never implied. Migration 0026_empty_token_scopes rewrites any pre-existing empty-scope tokens to an explicit non-sensitive set so ordinary integrations keep working without retaining those sensitive powers — re-issue tokens that intentionally need sensitive scopes from Settings after upgrading.
Governed analyst investigation queries and connector commands (issue #62). Only trusted, code-registered handlers may run. Arbitrary shell, user scripts, executable code, and free-form destination URLs are prohibited. Parameters are schema-validated server-side. Results are redacted, size-bounded, and tenant-scoped. Write-class commands require dual-control approval (a different user must approve).
Registered handlers (initial set):
| Command | Class | Notes |
|---|---|---|
kelpie.previous_cases |
read | Previous org cases sharing an observable value |
virustotal.report |
read | VirusTotal summary (mock when unconfigured); fixed VT API paths only |
kelpie.flag_entity_reviewed |
write | Append entity review note; requires approval |
List registered command descriptors (name, version, parameters, scopes, limits, approval). Scope: investigation:read.
History. Query: caseId, commandName, limit. When caseId is set, case compartment access is required. Scope: investigation:read.
{
"commandName": "kelpie.previous_cases",
"params": { "value": "203.0.113.10", "type": "ip", "limit": 10 },
"caseId": "case_...",
"entityId": "ent_...",
"idempotencyKey": "optional-client-key"
}Executes a registered command in case/entity/evidence/alert context. Read commands run immediately; write commands enter awaiting_approval. Scope: investigation:execute plus each handler's requiredScopes. Case context uses authorizeCase(..., "edit").
Optional ?includeResult=1 for full stored result payload. Scope: investigation:read.
Cancel queued/awaiting approval, or best-effort cancel a running execution. Scope: investigation:execute.
Approve a write-class execution (approver must differ from requester). Scope: investigation:execute.
{ "reason": "Not needed" }Scope: investigation:execute.
{ "caseId": "case_..." }Saves the result as case evidence, preserving command name/version, redacted params, provider request id, timestamps, and SHA-256. Scopes: investigation:execute and evidence:write.
{ "entityIds": ["ent_..."], "alertIds": ["alert_..."] }Link result to org-scoped entities/alerts. Scope: investigation:execute.
Durable detection, control, and process improvements linked to cases and post-incident reviews (issue #66). Distinct from lightweight review improvement proposals under /api/v1/reviews/{id}/improvements — promote those into the register with POST /api/v1/improvement-register/from-proposal.
Types: detection_gap, logging_gap, integration_defect, playbook_defect, security_control_gap, process_failure, training_need, documentation_gap.
Statuses: open → in_review → accepted → in_progress → validated → closed, plus reopened, rejected, deferred. Closing requires a validation method + evidence; reopen preserves prior closure history in events.
List (filters: status, type, ownerId, caseId, overdueOnly, limit) or create. Create may set immutable caseId / reviewId source links. Scope: improvements:read / improvements:write. When case-linked, case compartment access is required for create/edit.
Read or update fields (title, description, evidence, severity, residual risk, status, owner, due date, playbook). Closing and reopening use dedicated endpoints. Sensitive evidence is redacted unless the actor has view_sensitive on a linked case.
Link additional cases, reviews, proposals, or playbooks. Multiple cases raise recurrenceCount (distinct case links). Immutable source links (isSource: true) cannot be deleted.
{ "validationMethod": "retest", "validationEvidence": "Detection rule shipped; retest case CASE-123 clean." }Validation methods: retest, monitoring, peer_review, document_review, exercise, other. Records actor + timestamp.
{ "reason": "Recurred in CASE-456" }Clears current validation fields but retains prior closure/validation in the event log (priorClosure payload).
Bounded external ticket reference only:
{ "externalTicketRef": "ENG-42", "externalTicketUrl": "https://…", "syncState": "synced" }or { "conflict": true, "error": "…" }. Never replaces Kelpie owner, links, status, recurrence, or audit history.
Append-only lifecycle history (created, linked, validated, closed, reopened, ticket_synced, ticket_conflict, …).
{ "type": "detection_gap", "title": "Missing EDR telemetry on Linux fleet", "description": "…" }Returns ranked candidates with matchedFields explanations. Response always includes "autoMerge": false — suggestions never merge records.
Recurring categories (byType, highRecurrence), severity, owners, overdue actions, validation-pending work, and totals.
{ "proposalId": "pir_imp_…", "severity": "high", "dueAt": "2026-08-01T00:00:00.000Z" }Promotes a #64 review improvement proposal into the register with immutable source links. Idempotent on proposalId. Marks the proposal accepted when still proposed/deferred.